From 88b2635e8590fb686e03de87a35749c9ac697164 Mon Sep 17 00:00:00 2001 From: Esteban Gutierrez Date: Mon, 9 Mar 2026 13:15:44 -0500 Subject: [PATCH 001/206] chore: ci/cd workflow improvements and fixes (#6127) --- .github/actions/setup-release-env/action.yml | 4 +- .github/workflows/approve-rc.yml | 4 +- .../workflows/benchmark-comment-trigger.yml | 4 +- .github/workflows/benchmarks.yml | 6 +-- .github/workflows/buf-publish.yml | 6 +-- .../workflows/build_linux_wheel/action.yml | 6 +-- .github/workflows/build_mac_wheel/action.yml | 2 +- .../workflows/build_windows_wheel/action.yml | 4 +- .github/workflows/cargo-publish.yml | 17 +++--- .github/workflows/ci-benchmarks.yml | 10 ++-- .github/workflows/claude-code-review.yml | 11 ++-- .github/workflows/claude.yml | 19 ++++--- .github/workflows/codex-backport-pr.yml | 14 ++--- .github/workflows/codex-fix-ci.yml | 14 ++--- .github/workflows/create-rc.yml | 4 +- .github/workflows/create-release-branch.yml | 4 +- .github/workflows/docs-check.yml | 6 +-- .github/workflows/docs-deploy.yml | 18 +++---- .github/workflows/file_verification.yml | 6 +-- .github/workflows/java-publish.yml | 24 ++++----- .github/workflows/java.yml | 20 +++---- .github/workflows/license-header-check.yml | 8 ++- .github/workflows/nightly_run.yml | 4 +- .github/workflows/notebook.yml | 4 +- .github/workflows/pr-title.yml | 21 ++++---- .github/workflows/publish-beta.yml | 4 +- .github/workflows/pypi-publish.yml | 20 +++---- .github/workflows/python.yml | 42 +++++++-------- .github/workflows/recurring-tests.yml | 12 ++--- .github/workflows/run_tests/action.yml | 2 +- .github/workflows/rust-benchmark.yml | 4 +- .github/workflows/rust.yml | 52 +++++++++---------- .github/workflows/stale.yml | 2 +- .github/workflows/typos.yml | 4 +- python/python/lance/download.py | 23 +++++++- .../lance-namespace-impls/src/dir/manifest.rs | 12 +++-- 36 files changed, 229 insertions(+), 188 deletions(-) diff --git a/.github/actions/setup-release-env/action.yml b/.github/actions/setup-release-env/action.yml index c06f28271bb..8840ebfcb3b 100644 --- a/.github/actions/setup-release-env/action.yml +++ b/.github/actions/setup-release-env/action.yml @@ -4,7 +4,7 @@ runs: using: "composite" steps: - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4 with: python-version: "3.11" @@ -14,7 +14,7 @@ runs: pip install bump-my-version packaging PyGithub PyYAML - name: Set up Rust - uses: actions-rs/toolchain@v1 + uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1 with: toolchain: stable override: true diff --git a/.github/workflows/approve-rc.yml b/.github/workflows/approve-rc.yml index 622cd65a174..82a1045d2aa 100644 --- a/.github/workflows/approve-rc.yml +++ b/.github/workflows/approve-rc.yml @@ -27,7 +27,7 @@ jobs: run: echo "${{ toJSON(github.event.inputs) }}" - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: token: ${{ secrets.LANCE_RELEASE_TOKEN }} fetch-depth: 0 @@ -73,7 +73,7 @@ jobs: - name: Create GitHub Release (if not dry run) if: ${{ !inputs.dry_run }} - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: ${{ steps.approve.outputs.STABLE_TAG }} name: ${{ steps.approve.outputs.STABLE_TAG }} diff --git a/.github/workflows/benchmark-comment-trigger.yml b/.github/workflows/benchmark-comment-trigger.yml index 1259f4faa05..ebda5ba8d2f 100644 --- a/.github/workflows/benchmark-comment-trigger.yml +++ b/.github/workflows/benchmark-comment-trigger.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Get PR details id: pr - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const pr = await github.rest.pulls.get({ @@ -36,7 +36,7 @@ jobs: core.setOutput('head_sha', pr.data.head.sha); - name: Forward to lance-bench - uses: peter-evans/repository-dispatch@v2 + uses: peter-evans/repository-dispatch@bf47d102fdb849e755b0b0023ea3e81a44b6f570 # v2 with: token: ${{ secrets.LANCE_BENCH_DISPATCH_TOKEN }} repository: lancedb/lance-bench diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index e6b5a199001..0a543dd116d 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -11,12 +11,12 @@ jobs: matrix: dataset: ["sift"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4 with: python-version: "3.10" - uses: ./.github/workflows/build_linux_wheel @@ -32,7 +32,7 @@ jobs: run: | ./test_dataset.sh - name: Archive results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ matrix.dataset }}-results path: | diff --git a/.github/workflows/buf-publish.yml b/.github/workflows/buf-publish.yml index 45e714ac8ed..4546b9b6b60 100644 --- a/.github/workflows/buf-publish.yml +++ b/.github/workflows/buf-publish.yml @@ -15,12 +15,12 @@ jobs: push-module: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.ref || github.ref }} fetch-depth: 0 lfs: true - - uses: bufbuild/buf-setup-action@v1 - - uses: bufbuild/buf-push-action@v1 + - uses: bufbuild/buf-setup-action@a47c93e0b1648d5651a065437926377d060baa99 # v1.50.0 + - uses: bufbuild/buf-push-action@a654ff18effe4641ebea4a4ce242c49800728459 # v1.2.0 with: buf_token: ${{ secrets.BUF_TOKEN }} diff --git a/.github/workflows/build_linux_wheel/action.yml b/.github/workflows/build_linux_wheel/action.yml index 19098fcf0d4..fbfcff687ce 100644 --- a/.github/workflows/build_linux_wheel/action.yml +++ b/.github/workflows/build_linux_wheel/action.yml @@ -32,7 +32,7 @@ runs: rm -f python/target/wheels/pylance-*.whl || true - name: Build x86_64 Manylinux2014 wheel if: ${{ inputs.arm-build == 'false' && inputs.manylinux == '2_17' }} - uses: PyO3/maturin-action@v1 + uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1 with: command: build working-directory: python @@ -48,7 +48,7 @@ runs: && rm /tmp/protoc.zip - name: Build x86_64 Manylinux {manylinux} wheel if: ${{ inputs.arm-build == 'false' && inputs.manylinux != '2_17' }} - uses: PyO3/maturin-action@v1 + uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1 with: command: build working-directory: python @@ -67,7 +67,7 @@ runs: && rm /tmp/protoc.zip - name: Build Arm Manylinux Wheel if: ${{ inputs.arm-build == 'true' }} - uses: PyO3/maturin-action@v1 + uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1 with: command: build working-directory: python diff --git a/.github/workflows/build_mac_wheel/action.yml b/.github/workflows/build_mac_wheel/action.yml index 3a3af5917fd..9d45bde42aa 100644 --- a/.github/workflows/build_mac_wheel/action.yml +++ b/.github/workflows/build_mac_wheel/action.yml @@ -17,7 +17,7 @@ runs: run: | brew install protobuf - name: Build wheel - uses: PyO3/maturin-action@v1 + uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1 with: command: build args: ${{ inputs.args }} diff --git a/.github/workflows/build_windows_wheel/action.yml b/.github/workflows/build_windows_wheel/action.yml index 28f13656110..03b601db019 100644 --- a/.github/workflows/build_windows_wheel/action.yml +++ b/.github/workflows/build_windows_wheel/action.yml @@ -22,12 +22,12 @@ runs: Add-Content $env:GITHUB_PATH "C:\protoc\bin" shell: powershell - name: Build wheel - uses: PyO3/maturin-action@v1 + uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1 with: command: build args: ${{ inputs.args }} working-directory: python - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: windows-wheels path: python\target\wheels diff --git a/.github/workflows/cargo-publish.yml b/.github/workflows/cargo-publish.yml index ea9963315a7..2d0a5f83b28 100644 --- a/.github/workflows/cargo-publish.yml +++ b/.github/workflows/cargo-publish.yml @@ -39,7 +39,7 @@ jobs: run: working-directory: . steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Check if stable release id: check_version run: | @@ -60,19 +60,20 @@ jobs: echo "Stable version detected: $TAG" echo "skip=false" >> $GITHUB_OUTPUT fi - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 if: steps.check_version.outputs.skip != 'true' with: workspaces: rust - name: Verify and checkout specified tag if: github.event_name == 'workflow_dispatch' && steps.check_version.outputs.skip != 'true' run: | + TAG="${{ github.event.inputs.tag }}" git fetch --all --tags - if git rev-parse ${{ github.event.inputs.tag }} >/dev/null 2>&1; then - git checkout ${{ github.event.inputs.tag }} - echo "Successfully checked out tag ${{ github.event.inputs.tag }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + git checkout "$TAG" + echo "Successfully checked out tag $TAG" else - echo "Error: Tag ${{ github.event.inputs.tag }} does not exist" + echo "Error: Tag $TAG does not exist" echo "Available tags:" git tag -l exit 1 @@ -85,7 +86,7 @@ jobs: # Wait until https://github.com/rust-lang/crates-io-auth-action/issues/51 fixed # - uses: rust-lang/crates-io-auth-action@v1 # id: auth - - uses: albertlockett/publish-crates@v2.2 + - uses: albertlockett/publish-crates@85f0989f1298bc3889830b5fc28122c7586efeec # v2.2 if: steps.check_version.outputs.skip != 'true' with: # registry-token: ${{ steps.auth.outputs.token }} @@ -102,7 +103,7 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: ./.github/actions/create-failure-issue with: job-results: ${{ toJSON(needs) }} diff --git a/.github/workflows/ci-benchmarks.yml b/.github/workflows/ci-benchmarks.yml index ebe55cd146e..71c35a1b8ab 100644 --- a/.github/workflows/ci-benchmarks.yml +++ b/.github/workflows/ci-benchmarks.yml @@ -19,21 +19,21 @@ jobs: shell: bash working-directory: python steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true - name: Authenticate with GCS - uses: "google-github-actions/auth@v2" + uses: "google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2" with: credentials_json: "${{ secrets.GCLOUD_BENCH_STORAGE_USER_KEY }}" - name: Install bencher - uses: bencherdev/bencher@main + uses: bencherdev/bencher@8151077aa7b1bceaac11c4b308265417cae60e2b # v0.5.10 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: 3.11 # Ray does not support 3.12 yet. - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python - name: Install dependencies diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 571a740bb5d..3247ab3269f 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,7 +12,11 @@ on: jobs: claude-review: - if: github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' + if: | + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'OWNER' || + github.event.pull_request.author_association == 'CONTRIBUTOR' # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || @@ -24,17 +28,16 @@ jobs: contents: read pull-requests: write issues: write - id-token: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 1 - name: Run Claude Code Review id: claude-review - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@26ec041249acb0a944c0a47b6c0c13f05dbc5b44 # v1 with: anthropic_api_key: ${{ secrets.CLAUDE_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index bbb84322a25..3cb01a00fc8 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -13,26 +13,31 @@ on: jobs: claude: if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + ( + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && + contains(fromJSON('["MEMBER","COLLABORATOR","OWNER","CONTRIBUTOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && + contains(fromJSON('["MEMBER","COLLABORATOR","OWNER","CONTRIBUTOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && + contains(fromJSON('["MEMBER","COLLABORATOR","OWNER","CONTRIBUTOR"]'), github.event.review.author_association)) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && + contains(fromJSON('["MEMBER","COLLABORATOR","OWNER","CONTRIBUTOR"]'), github.event.issue.author_association)) + ) runs-on: ubuntu-latest permissions: contents: read pull-requests: write issues: write - id-token: write actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 1 - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@26ec041249acb0a944c0a47b6c0c13f05dbc5b44 # v1 with: anthropic_api_key: ${{ secrets.CLAUDE_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codex-backport-pr.yml b/.github/workflows/codex-backport-pr.yml index 164ca428afe..2730d61445c 100644 --- a/.github/workflows/codex-backport-pr.yml +++ b/.github/workflows/codex-backport-pr.yml @@ -48,13 +48,13 @@ jobs: echo "guidelines = ${{ inputs.guidelines }}" - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 persist-credentials: true - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 @@ -62,14 +62,14 @@ jobs: run: npm install -g @openai/codex - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: toolchain: stable components: clippy, rustfmt - - uses: rui314/setup-mold@v1 + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Install system dependencies run: | @@ -77,7 +77,7 @@ jobs: sudo apt-get install -y protobuf-compiler libssl-dev - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.11' @@ -86,7 +86,7 @@ jobs: pip install maturin ruff pytest pyarrow pandas polars - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin java-version: '11' diff --git a/.github/workflows/codex-fix-ci.yml b/.github/workflows/codex-fix-ci.yml index d311db94e4d..5298096caf6 100644 --- a/.github/workflows/codex-fix-ci.yml +++ b/.github/workflows/codex-fix-ci.yml @@ -48,14 +48,14 @@ jobs: echo "guidelines = ${{ inputs.guidelines }}" - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.branch }} fetch-depth: 0 persist-credentials: true - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 @@ -63,14 +63,14 @@ jobs: run: npm install -g @openai/codex - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: toolchain: stable components: clippy, rustfmt - - uses: rui314/setup-mold@v1 + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Install system dependencies run: | @@ -78,7 +78,7 @@ jobs: sudo apt-get install -y protobuf-compiler libssl-dev - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.11' @@ -87,7 +87,7 @@ jobs: pip install maturin ruff pytest pyarrow pandas polars - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin java-version: '11' diff --git a/.github/workflows/create-rc.yml b/.github/workflows/create-rc.yml index 86f3df2c88c..d70e06f7ab3 100644 --- a/.github/workflows/create-rc.yml +++ b/.github/workflows/create-rc.yml @@ -24,7 +24,7 @@ jobs: run: echo "${{ toJSON(github.event.inputs) }}" - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: token: ${{ secrets.LANCE_RELEASE_TOKEN }} fetch-depth: 0 @@ -84,7 +84,7 @@ jobs: - name: Create GitHub Pre-Release (if not dry run) if: ${{ !inputs.dry_run }} - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: ${{ steps.create.outputs.RC_TAG }} name: ${{ steps.create.outputs.RC_TAG }} diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml index a338977270f..8b033913b6b 100644 --- a/.github/workflows/create-release-branch.yml +++ b/.github/workflows/create-release-branch.yml @@ -28,7 +28,7 @@ jobs: run: echo "${{ toJSON(github.event.inputs) }}" - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.source_release_branch || 'main' }} token: ${{ secrets.LANCE_RELEASE_TOKEN }} @@ -90,7 +90,7 @@ jobs: - name: Create GitHub Pre-Release (if not dry run) if: ${{ !inputs.dry_run }} - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: ${{ steps.create_branch.outputs.RC_TAG }} name: ${{ steps.create_branch.outputs.RC_TAG }} diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 02c0ce3b751..903826bbf37 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -25,13 +25,13 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: "Set up Python" - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version-file: "docs/pyproject.toml" - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 with: enable-cache: true - name: Check links diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index a8f83f6ce11..36628e92ca7 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -24,34 +24,34 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Checkout lance-spark - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: lancedb/lance-spark path: lance-spark - name: Checkout lance-namespace - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: lancedb/lance-namespace path: lance-namespace - name: Checkout lance-ray - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: lancedb/lance-ray path: lance-ray - name: Checkout lance-huggingface - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: lance-format/lance-huggingface path: lance-huggingface - name: Checkout lance-namespace-impls - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: lance-format/lance-namespace-impls path: lance-namespace-impls - name: Checkout lance-trino - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: lance-format/lance-trino path: lance-trino @@ -60,11 +60,11 @@ jobs: git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - name: "Set up Python" - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version-file: "docs/pyproject.toml" - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 with: enable-cache: true - name: Copy lance-namespace docs diff --git a/.github/workflows/file_verification.yml b/.github/workflows/file_verification.yml index 94f36443ba8..cf55982a0cd 100644 --- a/.github/workflows/file_verification.yml +++ b/.github/workflows/file_verification.yml @@ -11,10 +11,10 @@ jobs: runs-on: warp-ubuntu-latest-x64-8x steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" @@ -32,7 +32,7 @@ jobs: echo "AWS credentials validation successful" - name: Set up AWS CLI - uses: aws-actions/configure-aws-credentials@v3 + uses: aws-actions/configure-aws-credentials@50ac8dd1e1b10d09dac7b8727528b91bed831ac0 # v3 with: aws-access-key-id: ${{ secrets.LANCEDB_S3_READER_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.LANCEDB_S3_READER_SECRET }} diff --git a/.github/workflows/java-publish.yml b/.github/workflows/java-publish.yml index 30d07658d17..aa5ca1ff77c 100644 --- a/.github/workflows/java-publish.yml +++ b/.github/workflows/java-publish.yml @@ -31,11 +31,11 @@ jobs: timeout-minutes: 60 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.ref || github.ref }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Check glibc version outside docker run: ldd --version - name: Build and run in Debian 10 Arm64 container @@ -97,7 +97,7 @@ jobs: cargo build --release " - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: liblance_jni_linux_arm_64.zip path: java/lance-jni/target/release/liblance_jni.so @@ -109,11 +109,11 @@ jobs: timeout-minutes: 60 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.ref || github.ref }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Check glibc version outside docker run: ldd --version - name: Build and run in Debian 10 X86-64 container @@ -175,7 +175,7 @@ jobs: cargo build --release " - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: liblance_jni_linux_x86_64.zip path: java/lance-jni/target/release/liblance_jni.so @@ -190,12 +190,12 @@ jobs: - linux-x86 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.ref || github.ref }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Set up Java 11 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: corretto java-version: 11 @@ -205,13 +205,13 @@ jobs: server-password: SONATYPE_TOKEN gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} - - uses: Homebrew/actions/setup-homebrew@master + - uses: Homebrew/actions/setup-homebrew@50b8c2ab4a835c38897ed2c56c293b07167c0b59 # master 2026-03-07 - name: Install dependencies run: | brew install protobuf brew install gpg - name: Download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - name: Copy native libs run: | mkdir -p ./java/target/classes/nativelib/linux-x86-64 ./java/target/classes/nativelib/linux-aarch64 @@ -250,7 +250,7 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: ./.github/actions/create-failure-issue with: job-results: ${{ toJSON(needs) }} diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index f4b8222bbaa..22f73c71125 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -26,8 +26,8 @@ jobs: name: Rust Clippy and Fmt Check steps: - name: Checkout repository - uses: actions/checkout@v4 - - uses: Swatinem/rust-cache@v2 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: | lance @@ -36,9 +36,9 @@ jobs: run: | sudo apt update sudo apt install -y protobuf-compiler libssl-dev - - uses: rui314/setup-mold@v1 + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # cargo-llvm-cov - name: Run cargo fmt working-directory: java/lance-jni run: cargo fmt --check @@ -55,23 +55,23 @@ jobs: name: Build and Test with Java ${{ matrix.java-version }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install dependencies run: | sudo apt update sudo apt install -y protobuf-compiler libssl-dev # pin the toolchain version to avoid surprises - - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@a0b538fa0b742a6aa35d6e2c169b4bd06d225a98 # v1 with: toolchain: stable - - uses: rui314/setup-mold@v1 + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - uses: Swatinem/rust-cache@v2 + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # cargo-llvm-cov + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: java/lance-jni -> ../target/rust-maven-plugin/lance-jni - name: Set up Java ${{ matrix.java-version }} - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin java-version: ${{ matrix.java-version }} diff --git a/.github/workflows/license-header-check.yml b/.github/workflows/license-header-check.yml index 1e321687681..b190136f6f1 100644 --- a/.github/workflows/license-header-check.yml +++ b/.github/workflows/license-header-check.yml @@ -17,9 +17,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install license-header-checker - run: curl -s https://raw.githubusercontent.com/lluissm/license-header-checker/master/install.sh | bash + run: | + set -euo pipefail + curl -sfSL https://raw.githubusercontent.com/lluissm/license-header-checker/master/install.sh -o /tmp/install-lhc.sh + bash /tmp/install-lhc.sh + rm -f /tmp/install-lhc.sh - name: Check license headers (rust) run: ./bin/license-header-checker -a -v ./rust/license_header.txt rust rs && [[ -z `git status -s` ]] - name: Check license headers (python) diff --git a/.github/workflows/nightly_run.yml b/.github/workflows/nightly_run.yml index 0c2c609e438..79e9899c7e7 100644 --- a/.github/workflows/nightly_run.yml +++ b/.github/workflows/nightly_run.yml @@ -11,7 +11,7 @@ jobs: if: github.repository == 'lancedb/lance' steps: - name: Nightly Run File Verification Workflow - uses: benc-uk/workflow-dispatch@v1 + uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1 with: workflow: file_verification.yml ref: main @@ -25,7 +25,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install dependencies run: | sudo apt update diff --git a/.github/workflows/notebook.yml b/.github/workflows/notebook.yml index 6efec33e92c..d4863b5c805 100644 --- a/.github/workflows/notebook.yml +++ b/.github/workflows/notebook.yml @@ -24,12 +24,12 @@ jobs: timeout-minutes: 30 runs-on: "ubuntu-22.04" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4 with: python-version: "3.10" - uses: ./.github/workflows/build_linux_wheel diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 96b0c5a338c..51c899f56a3 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -17,7 +17,7 @@ jobs: name: Label PR runs-on: ubuntu-latest steps: - - uses: srvaroa/labeler@master + - uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -28,7 +28,7 @@ jobs: name: Verify PR title / description conforms to semantic-release runs-on: ubuntu-latest steps: - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "20" # These rules are disabled because Github will always ensure there @@ -44,14 +44,17 @@ jobs: "body-leading-blank": [0, "always"] } }' > .commitlintrc.js - - run: npx commitlint --extends @commitlint/config-conventional --verbose <<< $COMMIT_MSG - env: - COMMIT_MSG: > - ${{ github.event.pull_request.title }} - - ${{ github.event.pull_request.body }} + - name: Write commit message to file + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const fs = require('fs'); + const title = context.payload.pull_request.title || ''; + const body = context.payload.pull_request.body || ''; + fs.writeFileSync(process.env.RUNNER_TEMP + '/commit_msg.txt', title + '\n\n' + body); + - run: npx commitlint --extends @commitlint/config-conventional --verbose < "$RUNNER_TEMP/commit_msg.txt" - if: failure() - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const message = `**ACTION NEEDED** diff --git a/.github/workflows/publish-beta.yml b/.github/workflows/publish-beta.yml index 0ef837e124d..e7fe350ab0f 100644 --- a/.github/workflows/publish-beta.yml +++ b/.github/workflows/publish-beta.yml @@ -27,7 +27,7 @@ jobs: run: echo "${{ toJSON(github.event.inputs) }}" - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: token: ${{ secrets.LANCE_RELEASE_TOKEN }} fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: - name: Create GitHub Pre-Release (if not dry run) if: ${{ !inputs.dry_run }} - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: ${{ steps.publish.outputs.BETA_TAG }} name: ${{ steps.publish.outputs.BETA_TAG }} diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 20195e679f2..94651d00ffd 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -52,13 +52,13 @@ jobs: runner: warp-ubuntu-latest-arm64-4x runs-on: ${{ matrix.config.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.ref || github.ref }} fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4 with: python-version: 3.${{ matrix.python-minor-version }} - name: Handle tag @@ -82,7 +82,7 @@ jobs: manylinux: ${{ matrix.config.manylinux }} - name: Upload wheels as artifacts if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: pylance-debug-manylinux_${{ matrix.config.manylinux }}_${{ matrix.config.platform }} path: python/target/wheels/*.whl @@ -105,13 +105,13 @@ jobs: env: MACOSX_DEPLOYMENT_TARGET: 10.15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.ref || github.ref }} fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4 with: python-version: 3.13 - name: Handle tag @@ -133,7 +133,7 @@ jobs: args: "--release ${{ (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.debug)) && '--strip' || '' }} --target ${{ matrix.config.target }} --features fp16kernels" - name: Upload wheels as artifacts if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: pylance-debug-macosx_arm64 path: python/target/wheels/*.whl @@ -151,13 +151,13 @@ jobs: matrix: python-minor-version: ["9"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.ref || github.ref }} fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4 with: python-version: 3.${{ matrix.python-minor-version }} - name: Handle tag @@ -180,7 +180,7 @@ jobs: args: "--release ${{ (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && !inputs.debug)) && '--strip' || '' }}" - name: Upload wheels as artifacts if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: pylance-debug-win_amd64 path: python/target/wheels/*.whl @@ -200,7 +200,7 @@ jobs: issues: write if: always() && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: ./.github/actions/create-failure-issue with: job-results: ${{ toJSON(needs) }} diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 6755d38087c..95811836667 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -49,15 +49,15 @@ jobs: CC: clang-18 CXX: clang++-18 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: 3.11 # Ray does not support 3.12 yet. - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python prefix-key: ${{ env.CACHE_PREFIX }} @@ -102,15 +102,15 @@ jobs: shell: bash working-directory: python steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: 3.${{ matrix.python-minor-version }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python prefix-key: ${{ env.CACHE_PREFIX }} @@ -122,7 +122,7 @@ jobs: memtest: true - name: Upload wheels as artifacts if: ${{ matrix.python-minor-version == '13' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: linux-wheels path: python/target/wheels/pylance-*.whl @@ -137,16 +137,16 @@ jobs: shell: bash working-directory: python steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: 3.13 - name: Download wheels - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: linux-wheels path: python/wheels @@ -168,15 +168,15 @@ jobs: shell: bash working-directory: python steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: 3.13 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python prefix-key: ${{ env.CACHE_PREFIX }} @@ -199,15 +199,15 @@ jobs: shell: bash working-directory: python steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.13" - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python prefix-key: ${{ env.CACHE_PREFIX }} @@ -226,11 +226,11 @@ jobs: shell: powershell working-directory: python steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python prefix-key: ${{ env.CACHE_PREFIX }} @@ -246,15 +246,15 @@ jobs: shell: bash working-directory: python steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" # TODO: upgrade when ray supports 3.12 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python prefix-key: ${{ env.CACHE_PREFIX }} diff --git a/.github/workflows/recurring-tests.yml b/.github/workflows/recurring-tests.yml index df99d6513ef..57e619a0092 100644 --- a/.github/workflows/recurring-tests.yml +++ b/.github/workflows/recurring-tests.yml @@ -59,7 +59,7 @@ jobs: shell: bash working-directory: python steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true @@ -68,7 +68,7 @@ jobs: sudo apt update sudo apt install -y protobuf-compiler - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.13" - name: Install dependencies @@ -76,7 +76,7 @@ jobs: shell: bash run: | pip install -e ".[tests]" - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python - name: Install Pylance @@ -100,7 +100,7 @@ jobs: shell: bash working-directory: python steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 lfs: true @@ -109,10 +109,10 @@ jobs: sudo apt update sudo apt install -y protobuf-compiler - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.13" - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python - name: Install Lance diff --git a/.github/workflows/run_tests/action.yml b/.github/workflows/run_tests/action.yml index 64d009f7a2d..ab761532eeb 100644 --- a/.github/workflows/run_tests/action.yml +++ b/.github/workflows/run_tests/action.yml @@ -18,7 +18,7 @@ runs: steps: - name: Setup MSVC for torch.compile if: runner.os == 'Windows' - uses: ilammy/msvc-dev-cmd@v1 + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1 - name: Install dependencies working-directory: python shell: bash diff --git a/.github/workflows/rust-benchmark.yml b/.github/workflows/rust-benchmark.yml index 7f26fb4e305..bb0960148a9 100644 --- a/.github/workflows/rust-benchmark.yml +++ b/.github/workflows/rust-benchmark.yml @@ -40,7 +40,7 @@ jobs: sudo apt update sudo apt install -y protobuf-compiler - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Run linalg benchmarks working-directory: ./rust/lance-linalg run: | @@ -53,7 +53,7 @@ jobs: cargo bench --bench sq --bench hnsw --bench inverted --bench pq_dist_table --bench pq_assignment -- --output-format bencher | tee -a ../../output.txt - name: Store benchmark result if: github.event_name != 'pull_request' - uses: benchmark-action/github-action-benchmark@v1 + uses: benchmark-action/github-action-benchmark@a7bc2366eda11037936ea57d811a43b3418d3073 # v1.21.0 with: name: Lance Rust Benchmarks tool: "cargo" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 275dbd32323..e4bde42c48b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -32,8 +32,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions-rust-lang/setup-rust-toolchain@a0b538fa0b742a6aa35d6e2c169b4bd06d225a98 # v1 with: components: rustfmt - name: Check formatting @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install dependencies run: | sudo apt update @@ -56,8 +56,8 @@ jobs: checks: write runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Install dependencies run: | sudo apt update @@ -73,8 +73,8 @@ jobs: name: Check Rust dependencies (cargo-deny) runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - uses: EmbarkStudios/cargo-deny-action@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2 with: log-level: warn command: check @@ -89,14 +89,14 @@ jobs: # Treat warnings as errors to catch issues early RUSTFLAGS: "-D warnings" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 # pin the toolchain version to avoid surprises - name: Setup rust toolchain run: | rustup toolchain install nightly rustup default nightly - - uses: rui314/setup-mold@v1 - - uses: Swatinem/rust-cache@v2 + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Install dependencies run: | sudo apt update @@ -104,13 +104,13 @@ jobs: - name: Start DynamodDB and S3 run: docker compose -f docker-compose.yml up -d --wait - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # cargo-llvm-cov - name: Run tests run: | ALL_FEATURES=`cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v -e protoc -e slow_tests | sort | uniq | paste -s -d "," -` cargo +nightly llvm-cov --profile ci --locked --workspace --codecov --output-path coverage.codecov --features ${ALL_FEATURES} - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 with: token: ${{ secrets.CODECOV_TOKEN }} codecov_yml_path: codecov.yml @@ -121,13 +121,13 @@ jobs: runs-on: warp-ubuntu-latest-arm64-4x timeout-minutes: 75 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup rust toolchain run: | rustup toolchain install stable rustup default stable - - uses: rui314/setup-mold@v1 - - uses: Swatinem/rust-cache@v2 + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Install dependencies run: | sudo apt -y -qq update @@ -149,13 +149,13 @@ jobs: # We use opt-level 1 which makes some tests 5x faster to run. RUSTFLAGS: "-C debuginfo=1 -C opt-level=1" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup rust toolchain run: | rustup toolchain install stable rustup default stable - - uses: rui314/setup-mold@v1 - - uses: Swatinem/rust-cache@v2 + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: cache-targets: false cache-workspace-crates: true @@ -177,7 +177,7 @@ jobs: CC: clang CXX: clang++ steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Rust run: | rustup update stable @@ -185,7 +185,7 @@ jobs: # Remote cargo.lock to force a fresh build - name: Remove Cargo.lock run: rm -f Cargo.lock - - uses: rui314/setup-mold@v1 + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - name: Install dependencies run: | sudo apt update @@ -205,7 +205,7 @@ jobs: run: working-directory: ./rust steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Select new xcode # Default XCode right now is 15.0.1, which contains a bug that causes # backtraces to not show properly. See: @@ -216,7 +216,7 @@ jobs: - name: Set up Rust run: | rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Build tests run: | cargo test --profile ci --locked --features fp16kernels,cli,dynamodb,substrait --no-run @@ -232,8 +232,8 @@ jobs: run: working-directory: rust steps: - - uses: actions/checkout@v4 - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Install Protoc v21.12 working-directory: C:\ run: | @@ -262,10 +262,10 @@ jobs: CC: clang CXX: clang++ steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: true - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Install dependencies run: | sudo apt update diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d57ae0c396c..b1779b7b8ba 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v10 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10 with: close-pr-message: "Thank you for your contribution. This PR has been inactive for a while, so we're closing it to free up bandwidth. Feel free to reopen it if you still find it useful." days-before-issue-stale: 360 diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 73c240e6d81..d8916a34527 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -11,7 +11,7 @@ jobs: runs-on: "ubuntu-24.04" steps: - name: Checkout Actions Repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Check spelling of the entire repository - uses: crate-ci/typos@v1.26.0 \ No newline at end of file + uses: crate-ci/typos@6802cc60d4e7f78b9d5454f6cf3935c042d5e1e3 # v1.26.0 \ No newline at end of file diff --git a/python/python/lance/download.py b/python/python/lance/download.py index 34b43eef1d0..9dc5ecbdac4 100644 --- a/python/python/lance/download.py +++ b/python/python/lance/download.py @@ -4,6 +4,7 @@ import os import shutil import subprocess +import sys import tarfile import traceback from io import BytesIO @@ -13,6 +14,26 @@ LANGUAGE_MODEL_HOME = language_model_home() +def _safe_tar_extractall(tar, dest_dir): + """Extract tar safely, blocking path traversal attacks (CVE-2007-4559). + + On Python >= 3.12 uses the built-in ``filter="data"`` safeguard. + On older versions, manually validates every member path. + """ + if sys.version_info >= (3, 12): + tar.extractall(path=dest_dir, filter="data") + else: + abs_dest = os.path.realpath(dest_dir) + for member in tar.getmembers(): + member_path = os.path.join(dest_dir, member.name) + abs_member = os.path.realpath(member_path) + if not abs_member.startswith(abs_dest + os.sep) and abs_member != abs_dest: + raise Exception( + f"Tar member '{member.name}' would extract outside target directory" + ) + tar.extractall(path=dest_dir) + + def check_lindera(): if not shutil.which("lindera"): raise Exception( @@ -69,7 +90,7 @@ def download_lindera(lm: str): try: os.chdir(src_dirname) with tarfile.open(fileobj=BytesIO(data)) as tar: - tar.extractall() + _safe_tar_extractall(tar, src_dirname) name = tar.getnames()[0] cmd = [ "lindera", diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index 70ef7be7147..af78e41856c 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -659,7 +659,8 @@ impl ManifestNamespace { /// Check if the manifest contains an object with the given ID async fn manifest_contains_object(&self, object_id: &str) -> Result { - let filter = format!("object_id = '{}'", object_id); + let escaped_id = object_id.replace('\'', "''"); + let filter = format!("object_id = '{}'", escaped_id); let dataset_guard = self.manifest_dataset.get().await?; let mut scanner = dataset_guard.scan(); @@ -693,7 +694,8 @@ impl ManifestNamespace { /// Query the manifest for a table with the given object ID async fn query_manifest_for_table(&self, object_id: &str) -> Result> { - let filter = format!("object_id = '{}' AND object_type = 'table'", object_id); + let escaped_id = object_id.replace('\'', "''"); + let filter = format!("object_id = '{}' AND object_type = 'table'", escaped_id); let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { Error::io_source(box_error(std::io::Error::other(format!( @@ -959,7 +961,8 @@ impl ManifestNamespace { /// Query the manifest for a namespace with the given object ID async fn query_manifest_for_namespace(&self, object_id: &str) -> Result> { - let filter = format!("object_id = '{}' AND object_type = 'namespace'", object_id); + let escaped_id = object_id.replace('\'', "''"); + let filter = format!("object_id = '{}' AND object_type = 'namespace'", escaped_id); let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { Error::io_source(box_error(std::io::Error::other(format!( @@ -1663,7 +1666,8 @@ impl LanceNamespace for ManifestNamespace { } // Check for child namespaces - let prefix = format!("{}{}", object_id, DELIMITER); + let escaped_id = object_id.replace('\'', "''"); + let prefix = format!("{}{}", escaped_id, DELIMITER); let filter = format!("starts_with(object_id, '{}')", prefix); let mut scanner = self.manifest_scanner().boxed().await?; scanner.filter(&filter).map_err(|e| { From e7eb014de4a8d9d958ed9eaa8d62eadb9cae3f40 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Tue, 10 Mar 2026 02:57:06 +0000 Subject: [PATCH 002/206] chore: release beta version 4.0.0-beta.8 --- .bumpversion.toml | 2 +- Cargo.lock | 92 +++++++++++++++++++-------------------- Cargo.toml | 40 ++++++++--------- java/lance-jni/Cargo.lock | 77 ++++++++++++++++---------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 84 +++++++++++++++++------------------ python/Cargo.toml | 2 +- 8 files changed, 151 insertions(+), 150 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 7906eb3efd5..cb43fdac547 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.0.0-beta.7" +current_version = "4.0.0-beta.8" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 13973540900..409c2ed915e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3443,7 +3443,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "lance-datagen", @@ -4212,7 +4212,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -4735,7 +4735,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "all_asserts", "approx", @@ -4830,7 +4830,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "arrow-buffer", @@ -4850,7 +4850,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrayref", "paste", @@ -4859,7 +4859,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "arrow-buffer", @@ -4899,7 +4899,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -4933,7 +4933,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -4953,7 +4953,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-arith", "arrow-array", @@ -5000,7 +5000,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "all_asserts", "arrow", @@ -5026,7 +5026,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-arith", "arrow-array", @@ -5069,7 +5069,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "datafusion", "geo-traits", @@ -5083,7 +5083,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "approx", "arrow", @@ -5163,7 +5163,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-arith", @@ -5211,7 +5211,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "approx", "arrow-array", @@ -5232,7 +5232,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "async-trait", @@ -5246,7 +5246,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -5264,7 +5264,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-ipc", @@ -5322,7 +5322,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -5368,7 +5368,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "proc-macro2", "quote", @@ -5377,7 +5377,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "arrow-schema", @@ -5388,7 +5388,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "clap", "lance-core", @@ -5500,9 +5500,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libflate" @@ -6956,9 +6956,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -7133,7 +7133,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.37", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -7142,9 +7142,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -7170,7 +7170,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -8276,12 +8276,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8973,7 +8973,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -9045,18 +9045,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", "toml_datetime", @@ -9454,9 +9454,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -10036,9 +10036,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -10253,18 +10253,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 307d1d2138b..52ec2d42076 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.0.0-beta.7", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.0.0-beta.7", path = "./rust/lance-arrow" } -lance-core = { version = "=4.0.0-beta.7", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.0.0-beta.7", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.0.0-beta.7", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.0.0-beta.7", path = "./rust/lance-encoding" } -lance-file = { version = "=4.0.0-beta.7", path = "./rust/lance-file" } -lance-geo = { version = "=4.0.0-beta.7", path = "./rust/lance-geo" } -lance-index = { version = "=4.0.0-beta.7", path = "./rust/lance-index" } -lance-io = { version = "=4.0.0-beta.7", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.0.0-beta.7", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.0.0-beta.7", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.0.0-beta.7", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.0.0-beta.7", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=4.0.0-beta.8", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=4.0.0-beta.8", path = "./rust/lance-arrow" } +lance-core = { version = "=4.0.0-beta.8", path = "./rust/lance-core" } +lance-datafusion = { version = "=4.0.0-beta.8", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=4.0.0-beta.8", path = "./rust/lance-datagen" } +lance-encoding = { version = "=4.0.0-beta.8", path = "./rust/lance-encoding" } +lance-file = { version = "=4.0.0-beta.8", path = "./rust/lance-file" } +lance-geo = { version = "=4.0.0-beta.8", path = "./rust/lance-geo" } +lance-index = { version = "=4.0.0-beta.8", path = "./rust/lance-index" } +lance-io = { version = "=4.0.0-beta.8", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=4.0.0-beta.8", path = "./rust/lance-linalg" } +lance-namespace = { version = "=4.0.0-beta.8", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=4.0.0-beta.8", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=4.0.0-beta.8", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.5.2" -lance-table = { version = "=4.0.0-beta.7", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.0.0-beta.7", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.0.0-beta.7", path = "./rust/lance-testing" } +lance-table = { version = "=4.0.0-beta.8", path = "./rust/lance-table" } +lance-test-macros = { version = "=4.0.0-beta.8", path = "./rust/lance-test-macros" } +lance-testing = { version = "=4.0.0-beta.8", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.0.0-beta.7", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=4.0.0-beta.8", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.0.0-beta.7", path = "./rust/compression/fsst" } +fsst = { version = "=4.0.0-beta.8", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 7cd5c1cefec..0a1eb4f5dc8 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3353,7 +3353,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-arith", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "arrow-buffer", @@ -3439,7 +3439,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrayref", "paste", @@ -3448,7 +3448,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "arrow-buffer", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -3511,13 +3511,14 @@ dependencies = [ "prost", "prost-build", "snafu", + "substrait", "tokio", "tracing", ] [[package]] name = "lance-datagen" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -3535,7 +3536,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-arith", "arrow-array", @@ -3572,7 +3573,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-arith", "arrow-array", @@ -3604,7 +3605,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "datafusion", "geo-traits", @@ -3618,7 +3619,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-arith", @@ -3685,7 +3686,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-arith", @@ -3726,7 +3727,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-schema", @@ -3760,7 +3761,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "arrow-buffer", @@ -3776,7 +3777,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "async-trait", @@ -3788,7 +3789,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-ipc", @@ -3832,7 +3833,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -3947,9 +3948,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libm" @@ -4826,9 +4827,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -4935,9 +4936,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -5827,12 +5828,12 @@ checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6372,18 +6373,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", "toml_datetime", @@ -6678,9 +6679,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -7207,9 +7208,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -7379,18 +7380,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 62a6ff82a52..8bc75c17cf1 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 09faa47f958..609df9fd839 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.0.0-beta.7 + 4.0.0-beta.8 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index 7f461c0348e..d4649565a9e 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2824,7 +2824,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3456,7 +3456,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -3911,7 +3911,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-arith", @@ -3978,7 +3978,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "arrow-buffer", @@ -3998,7 +3998,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrayref", "paste", @@ -4007,7 +4007,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "arrow-buffer", @@ -4044,7 +4044,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -4077,7 +4077,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-arith", "arrow-array", @@ -4132,7 +4132,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-arith", "arrow-array", @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "datafusion", "geo-traits", @@ -4178,7 +4178,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-arith", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-arith", @@ -4289,7 +4289,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow-array", "arrow-buffer", @@ -4305,7 +4305,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "async-trait", @@ -4317,7 +4317,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-ipc", @@ -4361,7 +4361,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -4484,9 +4484,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libflate" @@ -5624,9 +5624,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" dependencies = [ "arrow", "arrow-array", @@ -5881,7 +5881,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.37", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -5890,9 +5890,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -5918,7 +5918,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -6868,12 +6868,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7413,7 +7413,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -7475,18 +7475,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", "toml_datetime", @@ -7846,9 +7846,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -8315,9 +8315,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -8503,18 +8503,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", diff --git a/python/Cargo.toml b/python/Cargo.toml index 7dbc96f9ecb..9e28d3d3b0d 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.0.0-beta.7" +version = "4.0.0-beta.8" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 31de8d4546661b17dd150af1b0e0bb7de4ba69e4 Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Tue, 10 Mar 2026 15:49:39 +0800 Subject: [PATCH 003/206] fix: replace `fetch_arrow_table` with `to_arrow_table` (#6146) fix CI error: `FAILED python/tests/test_integration.py::test_duckdb_pushdown_extension_types - _duckdb.Error: DeprecationWarning: fetch_arrow_table() is deprecated, use to_arrow_table() instead.` --- python/python/tests/test_integration.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/python/tests/test_integration.py b/python/python/tests/test_integration.py index 399565f1024..3647ec83eef 100644 --- a/python/python/tests/test_integration.py +++ b/python/python/tests/test_integration.py @@ -16,7 +16,7 @@ def test_duckdb_filter_on_rowid(tmp_path): expected = tab.slice(1, 1) actual = duckdb.query( f"SELECT * FROM ds WHERE _rowid = {row_ids[1]}" - ).fetch_arrow_table() + ).to_arrow_table() assert actual.to_pydict() == expected.to_pydict() @@ -37,11 +37,11 @@ def test_duckdb_pushdown_extension_types(tmp_path): ) ds = lance.write_dataset(tab, str(tmp_path)) # noqa: F841 expected = tab.slice(1, 1) - actual = duckdb.query("SELECT * FROM ds WHERE filterme = 2").fetch_arrow_table() + actual = duckdb.query("SELECT * FROM ds WHERE filterme = 2").to_arrow_table() assert actual.to_pydict() == expected.to_pydict() expected = tab.slice(0, 1) - actual = duckdb.query("SELECT * FROM ds WHERE othercol = 4").fetch_arrow_table() + actual = duckdb.query("SELECT * FROM ds WHERE othercol = 4").to_arrow_table() assert actual.to_pydict() == expected.to_pydict() # Not the best error message but hopefully this is short lived until datafusion @@ -64,6 +64,6 @@ def test_duckdb_pushdown_extension_types(tmp_path): "filterme IS NOT NULL", "filterme < 2", ]: - expected = duckdb.query(f"SELECT * FROM tab WHERE {filt}").fetch_arrow_table() - actual = duckdb.query(f"SELECT * FROM ds WHERE {filt}").fetch_arrow_table() + expected = duckdb.query(f"SELECT * FROM tab WHERE {filt}").to_arrow_table() + actual = duckdb.query(f"SELECT * FROM ds WHERE {filt}").to_arrow_table() assert actual == expected From 0681a088b64802b3455919e2e923beed9a4de8da Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Tue, 10 Mar 2026 22:11:13 +0800 Subject: [PATCH 004/206] perf: parallelize FTS prewarming (#6144) 20%+ faster for 2GB index, could be more for larger index --- rust/lance-index/src/scalar/inverted/index.rs | 206 +++++++++++++++--- 1 file changed, 176 insertions(+), 30 deletions(-) diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index dbd5c2f6eff..0e3fd1b8798 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -8,7 +8,7 @@ use std::{ cmp::{Reverse, min}, collections::BinaryHeap, }; -use std::{collections::HashMap, ops::Range}; +use std::{collections::HashMap, ops::Range, time::Instant}; use crate::metrics::NoOpMetricsCollector; use crate::prefilter::NoFilter; @@ -528,9 +528,19 @@ impl Index for InvertedIndex { } async fn prewarm(&self) -> Result<()> { - for part in &self.partitions { - part.inverted_list.prewarm().await?; - } + let io_parallelism = self.store.io_parallelism(); + let prewarm_futures = self + .partitions + .iter() + .map(Arc::clone) + .map(|part| async move { + part.inverted_list.prewarm().await?; + Result::Ok(()) + }); + stream::iter(prewarm_futures) + .buffer_unordered(io_parallelism) + .try_collect::>() + .await?; Ok(()) } @@ -1393,12 +1403,21 @@ impl PostingListReader { Ok(posting) } + fn posting_list_from_batch_parts( + batch: &RecordBatch, + max_score: Option, + length: Option, + ) -> Result { + let posting_list = PostingList::from_batch(batch, max_score, length)?; + Ok(posting_list) + } + pub(crate) fn posting_list_from_batch( &self, batch: &RecordBatch, token_id: u32, ) -> Result { - let posting_list = PostingList::from_batch( + Self::posting_list_from_batch_parts( batch, self.max_scores .as_ref() @@ -1406,35 +1425,81 @@ impl PostingListReader { self.lengths .as_ref() .map(|lengths| lengths[token_id as usize]), - )?; - Ok(posting_list) + ) + } + + fn build_prewarm_posting_lists( + batch: RecordBatch, + offsets: Option>, + max_scores: Option>, + lengths: Option>, + ) -> Result> { + let token_count = if let Some(offsets) = offsets.as_ref() { + offsets.len() + } else if let Some(lengths) = lengths.as_ref() { + lengths.len() + } else { + batch.num_rows() + }; + + let mut posting_lists = Vec::with_capacity(token_count); + for token_id in 0..token_count { + let batch = if let Some(offsets) = offsets.as_ref() { + let start = offsets[token_id]; + let end = if token_id + 1 < offsets.len() { + offsets[token_id + 1] + } else { + batch.num_rows() + }; + batch.slice(start, end - start) + } else { + batch.slice(token_id, 1) + }; + let batch = batch.shrink_to_fit()?; + let posting_list = Self::posting_list_from_batch_parts( + &batch, + max_scores.as_ref().map(|scores| scores[token_id]), + lengths.as_ref().map(|lengths| lengths[token_id]), + )?; + posting_lists.push((token_id as u32, posting_list)); + } + + Ok(posting_lists) } async fn prewarm(&self) -> Result<()> { + let read_batch_start = Instant::now(); let batch = self.read_batch(false).await?; - for token_id in 0..self.len() { - let posting_range = self.posting_list_range(token_id as u32); - let batch = batch.slice(posting_range.start, posting_range.end - posting_range.start); - // Apply shrink_to_fit to create a deep copy with compacted buffers - // This ensures each cached entry has its own memory, not shared references - let batch = batch.shrink_to_fit()?; - let posting_list = self.posting_list_from_batch(&batch, token_id as u32)?; - let inserted = self - .index_cache - .insert_with_key( - &PostingListKey { - token_id: token_id as u32, - }, - Arc::new(posting_list), - ) + let read_batch_elapsed = read_batch_start.elapsed(); + + let legacy_layout = self.offsets.is_some(); + let offsets = self.offsets.clone(); + let max_scores = self.max_scores.clone(); + let lengths = self.lengths.clone(); + let populate_start = Instant::now(); + let posting_lists = spawn_blocking(move || { + Self::build_prewarm_posting_lists(batch, offsets, max_scores, lengths) + }) + .await + .map_err(|err| { + Error::internal(format!( + "Failed to build prewarm posting lists in blocking task: {err}" + )) + })??; + for (token_id, posting_list) in posting_lists { + self.index_cache + .insert_with_key(&PostingListKey { token_id }, Arc::new(posting_list)) .await; - - if !inserted { - return Err(Error::internal( - "Failed to prewarm index: cache is no longer available".to_string(), - )); - } } + let populate_elapsed = populate_start.elapsed(); + + info!( + legacy_layout, + token_count = self.len(), + read_batch_ms = read_batch_elapsed.as_secs_f64() * 1000.0, + post_read_loop_ms = populate_elapsed.as_secs_f64() * 1000.0, + "posting list prewarm timing" + ); Ok(()) } @@ -1675,7 +1740,7 @@ impl DeepSizeOf for PlainPostingList { + self .positions .as_ref() - .map(|positions| positions.get_buffer_memory_size()) + .map(Array::get_buffer_memory_size) .unwrap_or(0) } } @@ -1776,7 +1841,7 @@ impl DeepSizeOf for CompressedPostingList { + self .positions .as_ref() - .map(|positions| positions.get_buffer_memory_size()) + .map(Array::get_buffer_memory_size) .unwrap_or(0) } } @@ -3022,6 +3087,87 @@ mod tests { ); } + #[tokio::test] + async fn test_modern_prewarm_shrinks_cached_posting_buffers() { + let tmpdir = TempObjDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + tmpdir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default()); + builder.tokens.add("alpha".to_owned()); + builder.tokens.add("beta".to_owned()); + builder.posting_lists.push(PostingListBuilder::new(false)); + builder.posting_lists.push(PostingListBuilder::new(false)); + builder.posting_lists[0].add(0, PositionRecorder::Count(1)); + builder.posting_lists[0].add(1, PositionRecorder::Count(2)); + builder.posting_lists[1].add(2, PositionRecorder::Count(3)); + builder.posting_lists[1].add(3, PositionRecorder::Count(4)); + builder.docs.append(100, 1); + builder.docs.append(101, 2); + builder.docs.append(102, 3); + builder.docs.append(103, 4); + builder.write(store.as_ref()).await.unwrap(); + + let metadata = std::collections::HashMap::from_iter(vec![ + ( + "partitions".to_owned(), + serde_json::to_string(&vec![0u64]).unwrap(), + ), + ( + "params".to_owned(), + serde_json::to_string(&InvertedIndexParams::default()).unwrap(), + ), + ( + TOKEN_SET_FORMAT_KEY.to_owned(), + TokenSetFormat::default().to_string(), + ), + ]); + let mut writer = store + .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty())) + .await + .unwrap(); + writer.finish_with_metadata(metadata).await.unwrap(); + + let cache = Arc::new(LanceCache::with_capacity(4096)); + let index = InvertedIndex::load(store.clone(), None, cache.as_ref()) + .await + .unwrap(); + let inverted_list = &index.partitions[0].inverted_list; + assert!( + inverted_list.offsets.is_none(), + "test should use modern posting layout" + ); + + inverted_list.prewarm().await.unwrap(); + + let alpha = inverted_list + .index_cache + .get_with_key(&PostingListKey { token_id: 0 }) + .await + .unwrap(); + let beta = inverted_list + .index_cache + .get_with_key(&PostingListKey { token_id: 1 }) + .await + .unwrap(); + + let PostingList::Compressed(alpha) = alpha.as_ref() else { + panic!("expected compressed posting list for token 0"); + }; + let PostingList::Compressed(beta) = beta.as_ref() else { + panic!("expected compressed posting list for token 1"); + }; + + assert_ne!( + alpha.blocks.values().as_ptr(), + beta.blocks.values().as_ptr(), + "prewarm should not leave cached posting lists sharing the same values buffer" + ); + } + #[test] fn test_block_max_scores_capacity_matches_block_count() { let mut docs = DocSet::default(); From e1aa70a27e280650b3b51bce354d63dbed628cb4 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 11 Mar 2026 02:06:28 +0800 Subject: [PATCH 005/206] ci: fix pinned auth action ref in benchmarks workflow (#6153) This PR fixes the regression benchmarks workflow failing to resolve the pinned `google-github-actions/auth` action. The workflow had quoted the entire `uses` value, which caused the trailing `# v2` comment to be parsed as part of the action ref. --- .github/workflows/ci-benchmarks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-benchmarks.yml b/.github/workflows/ci-benchmarks.yml index 71c35a1b8ab..3ed477f26d5 100644 --- a/.github/workflows/ci-benchmarks.yml +++ b/.github/workflows/ci-benchmarks.yml @@ -24,7 +24,7 @@ jobs: fetch-depth: 0 lfs: true - name: Authenticate with GCS - uses: "google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2" + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2 with: credentials_json: "${{ secrets.GCLOUD_BENCH_STORAGE_USER_KEY }}" - name: Install bencher From 6be1b5807e28e6f259ccf0a0b91a0bb693eec520 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Tue, 10 Mar 2026 15:56:20 -0700 Subject: [PATCH 006/206] docs: document the rules for transaction conflicts (#6158) There was a conflict table in transaction.rs but this was incomplete (some rows/columns missing) and seemed to be imprecise or incorrect in a few spots. I've attempted to more thoroughly document this in transaction.md instead. --- docs/src/format/table/transaction.md | 267 ++++++++++++++++++++++++-- rust/lance/src/dataset/transaction.rs | 37 +--- 2 files changed, 250 insertions(+), 54 deletions(-) diff --git a/docs/src/format/table/transaction.md b/docs/src/format/table/transaction.md index 56b867a4683..a78dc3aae64 100644 --- a/docs/src/format/table/transaction.md +++ b/docs/src/format/table/transaction.md @@ -52,6 +52,12 @@ The authoritative specification for transaction types is defined in [`protos/tra Each transaction contains a `read_version` field indicating the table version from which the transaction was built, a `uuid` field uniquely identifying the transaction, and an `operation` field specifying one of the following transaction types: +In the following section, we will describe each transaction type and its compatibility with other transaction types. This +compatibility is not always bi-directional. We are describing it from the perspective of the operation being committed. For example, we say that an Append is not compatible with an Overwrite which means that if we are trying to commit an Append, and an +Overwrite has already been committed (since we started the Append), then the Append will fail. On the other hand, when describing the +Overwrite operation, we say that it does not conflict with Append. This is because, if we are trying to commit an Overwrite, and an +Append operation has occurred in the meantime, we still allow the Overwrite to proceed. + ### Append Adds new fragments to the table without modifying existing data. @@ -66,6 +72,16 @@ Fragment IDs are not assigned at transaction creation time; they are assigned du +#### Append Compatibility + +The append operation is one of the most common operations and is designed to be compatible with most other operations, even +itself. This is to ensure that multiple writers can append without worry about conflicts. These are the operations +that conflict with append: + +- Overwrite +- Restore +- UpdateMemWalState + ### Delete Marks rows as deleted using deletion vectors. @@ -81,6 +97,30 @@ The `predicate` field stores the deletion condition, enabling conflict detection +#### Delete Compatibility + +Delete modifies an existing fragment, so there may be conflicts with other operations on overlapping fragments. +Generally these conflicts are rebaseable or retryable. + +These are the operations that conflict with delete: + +- Overwrite +- Restore +- UpdateMemWalState + +These operations conflict with delete but can be retried: + +- Merge (only if there are overlapping fragments) +- Rewrite (only if there are overlapping fragments) +- DataReplacement (only if there are overlapping fragments) + +These operations conflict with delete but can potentially be rebased. The deletion +masks from the two operations will be merged. However, if both operations modified +the same rows, then the conflict becomes a retryable conflict. + +- Delete +- Update + ### Overwrite Creates or completely overwrites the table with new data, schema, and configuration. @@ -94,6 +134,18 @@ Creates or completely overwrites the table with new data, schema, and configurat +#### Overwrite Compatibility + +An overwrite operation completely overwrites the table. Generally, we do not care what has happened since +the read version. + +However, the overwrite does not necessarily rewrite the table config. As a result, we consider the following +to be retryable conflicts: + +- UpdateConfig (only if the two operations modify the same config key) +- Overwrite (always) +- UpdateMemWalState (always) + ### CreateIndex Adds, replaces, or removes secondary indices (vector indices, scalar indices, full-text search indices). @@ -107,6 +159,40 @@ Adds, replaces, or removes secondary indices (vector indices, scalar indices, fu +#### CreateIndex Compatibility + +Indexes record which fragments are covered by the index and we don't require all fragments be covered. As a result, it +is typically ok for an index to be created concurrently with the addition of new fragments. These new fragments will simply +be unindexed. + +Updates and deletes are also compatible with index creation. This is because it is ok for an index to refer to deleted rows. +Those results will be filtered out after the index search. If an update occurs then the old value will be filtered out and the +new value will be considered part of the unindexed set. + +If two CreateIndex operations are committed concurrently then it is allowed. If the indexes have different names this is no +problem. If the indexes have the same name then the second operation will win and replace the first. + +These operations conflict with index creation: + +- Overwrite +- Restore +- UpdateMemWalState + +Data replacement operations will conflict with index creation if the column being replaced is being indexed. Rewrite operations +will conflict with index creation if the rewritten fragments are covered by the index. This is because an index refers to row +addresses and the rewrite operation changes the row addresses. However, if a fragment reuse index is being used, or if the stable +row ids feature is enable, then the rewrite operation is compatible with index creation. As a result, these are the operations +that are retryable conflicts with index creation: + +- Rewrite (only if overlapping fragments, no stable row ids, and no fragment reuse index) +- DataReplacement (only if overlapping fragments and the column being replaced is being indexed) + +Some indices are special singleton indices. For example, the fragment reuse index and the mem wal index. If a conflict occurs +between two operations that are modifying the same singleton index, then we must rebase the operation and merge the indexes. +As a result, these are the operations that are rebaseable conflicts with index creation: + +- CreateIndex (only if both operations are modifying the same singleton index) + ### Rewrite Reorganizes data without semantic modification. @@ -123,6 +209,38 @@ New fragment IDs must be reserved via `ReserveFragments` before executing a `Rew +#### Rewrite Compatibility + +Rewrite operations do not change data but they can materialize deletions and they do replace fragments. As a result, +they can potentially conflict with other operations that modify the fragments being rewritten. + +These are the operations that conflict with rewrite: + +- Overwrite +- Restore + +Rewrite is not compatible with CreateIndex by default because the operation will change the row addresses that the CreateIndex +refers to. However, a fragment reuse index or the stable row ids feature can allow these operations to be compatible. + +Several operations modify existing fragments. As a result, they can potentially conflict with Rewrite if they modify +the same fragments. However, Merge is [overly general](#overly-general-operation) and so no conflict detection is possible. +As a result, here are the operations that are retryable conflicts with Rewrite: + +- Merge (always) +- DataReplacement (only if overlapping fragments) +- Delete (only if overlapping fragments) +- Update (only if overlapping fragments) +- Rewrite (if overlapping fragments or both carry a fragment reuse index) +- CreateIndex (overlapping fragments and no fragment reuse index or stable row ids) + +There is one case where a Rewrite will rebase. This is when the Rewrite operation has a fragment reuse index and there is +a CreateIndex operation that is writing the fragment reuse index. In this case the Rewrite will rebase and update its +fragment reuse index to include the conflicting fragment reuse index. + +As a result, these are the operations that are rebaseable conflicts with Rewrite: + +- CreateIndex (if the CreateIndex is writing the fragment reuse index and the Rewrite is carrying a fragment reuse index) + ### Merge Adds new columns to the table, modifying the schema. @@ -137,6 +255,31 @@ All fragments must be updated to include the new columns. +#### Overly General Operation + +The Merge operation is a very generic operation. The set of fragments provided in the operation will be the final set of +fragments in the resulting dataset. As a result, it has a high potential for conflicts with other operations. If possible, +more restrictive operations such as Rewrite, DataReplacement, or Append should be preferred over Merge. + +#### Merge Compatibility + +As mentioned above, Merge is a very generic operation, as a result it has a high potential for conflicts with other operations. +The following operations conflict with Merge: + +- Overwrite +- Restore +- UpdateMemWalState +- Project + +These operations are retryable conflicts with Merge: + +- Update (always) +- Append (always) +- Delete (always) +- Merge (always) +- Rewrite (always) +- DataReplacement (always) + ### Project Removes columns from the table, modifying the schema. @@ -151,6 +294,23 @@ This is a metadata-only operation; data files are not modified. +#### Project Compatibility + +Since project only modifies the schema, it is compatible with most other operations. However, it is not compatible with Merge +because the Merge operation modifies the schema (can potentially add columns) and the logic to rebase those changes does not +currently exist (project is cheap and easy enough to retry). + +These are the operations that conflict with Project: + +- Overwrite +- Restore +- UpdateMemWalState + +The following operations are retryable conflicts with Project: + +- Project (always) +- Merge (always) + ### Restore Reverts the table to a previous version. @@ -164,6 +324,13 @@ Reverts the table to a previous version. +#### Restore Compatibility + +The Restore operation reverts the table to a previous version. It's generally assumed this trumps any +other operation. Here are the operations that conflict with Restore: + +- UpdateMemWalState + ### ReserveFragments Pre-allocates fragment IDs for use in future `Rewrite` operations. @@ -178,6 +345,14 @@ This allows rewrite operations to reference fragment IDs before the rewrite tran +#### ReserveFragments Compatibility + +The ReserveFragments operation is fairly trivial. The only thing it changes is the max fragment id. So this +only conflicts with operations that modify the max fragment id. Here are the operations that conflict with ReserveFragments: + +- Overwrite +- Restore + ### Clone Creates a shallow or deep copy of the table. @@ -193,6 +368,11 @@ Deep clones are full copies using object storage native copy operations (e.g., S +#### Clone Compatibility + +The Clone operation can only be the first operation in a dataset. If there is an existing dataset, then the Clone operation will fail. +As a result, there is no such thing as a conflict with Clone. + ### Update Modifies row values without adding or removing rows. @@ -207,6 +387,27 @@ Supports two execution modes: REWRITE_ROWS deletes rows in current fragments and +#### Update Compatibility + +Here are the operations that conflict with Update: + +- Overwrite +- Restore + +An update operation is both a delete and an append operation. Like a Delete operation, it will modify fragments to change +the deletion mask. As a result, there will be a retryable conflict with other operations that modify the same fragments. +Here are the operations that are retryable conflicts with Update: + +- Rewrite (only if overlapping fragments) +- DataReplacement (only if overlapping fragments) +- Merge (always) + +Similar to Delete, the Update operation can rebase other modifications to the deletion mask. Here are the operations that +are rebaseable conflicts with Update: + +- Delete +- Update + ### UpdateConfig Modifies table configuration, table metadata, schema metadata, or field metadata without changing data. @@ -220,6 +421,14 @@ Modifies table configuration, table metadata, schema metadata, or field metadata +#### UpdateConfig Compatibility + +An UpdateConfig operation only modifies table config and tends to be compatible with other operations. Here +are the operations that conflict with UpdateConfig: + +- Overwrite +- UpdateConfig (only if the two operations modify the same config) + ### DataReplacement Replaces data in specific column regions with new data files. @@ -233,6 +442,21 @@ Replaces data in specific column regions with new data files. +#### DataReplacement Compatibility + +A DataReplacement operation only replaces a single column's worth of data. As a result, it can be safer and simpler than Merge +or Update operations. Here are the operations that conflict with DataReplacement: + +- Overwrite +- Restore +- UpdateMemWalState + +The following operations are retryable conflicts with DataReplacement: + +- DataReplacement (only if same field and overlapping fragments) +- CreateIndex (only if the field being replaced is being indexed) +- Rewrite (only if overlapping fragments) + ### UpdateMemWalState Updates the state of MemWal indices (write-ahead log based indices). @@ -259,6 +483,12 @@ Adds new base paths to the table, enabling reference to data files in additional +#### UpdateBases Compatibility + +An UpdateBases operation only modifies the base paths. As a result, it only conflicts with other +UpdateBases operations and even then only conflicts if the two operations have base paths with the +same id, name, or path. + ## Conflict Resolution ### Terminology @@ -383,7 +613,6 @@ In this scenario: - The commit fails with a non-retryable error - If the caller retries the deletion operation against version 4, it would either delete nothing (if those rows don't exist in v1) or delete different rows (if similar row IDs exist in v1), producing semantically different results than originally intended - ## External Manifest Store If the backing object store does not support atomic operations (rename-if-not-exists or put-if-not-exists), an external manifest store can be used to enable concurrent writers. @@ -399,21 +628,21 @@ The commit process follows a four-step protocol: ![External Store Commit Process](../../images/external_store_commit.gif) 1. **Stage manifest**: `PUT_OBJECT_STORE {dataset}/_versions/{version}.manifest-{uuid}` - - Write the new manifest to object storage under a unique path determined by a new UUID - - This staged manifest is not yet visible to readers + - Write the new manifest to object storage under a unique path determined by a new UUID + - This staged manifest is not yet visible to readers 2. **Commit to external store**: `PUT_EXTERNAL_STORE base_uri, version, {dataset}/_versions/{version}.manifest-{uuid}` - - Atomically commit the path of the staged manifest to the external store using put-if-not-exists - - The commit is effectively complete after this step - - If this operation fails due to conflict, another writer has committed this version + - Atomically commit the path of the staged manifest to the external store using put-if-not-exists + - The commit is effectively complete after this step + - If this operation fails due to conflict, another writer has committed this version 3. **Finalize in object store**: `COPY_OBJECT_STORE {dataset}/_versions/{version}.manifest-{uuid} → {dataset}/_versions/{version}.manifest` - - Copy the staged manifest to the final path - - This makes the manifest discoverable by readers unaware of the external store + - Copy the staged manifest to the final path + - This makes the manifest discoverable by readers unaware of the external store 4. **Update external store pointer**: `PUT_EXTERNAL_STORE base_uri, version, {dataset}/_versions/{version}.manifest` - - Update the external store to point to the finalized manifest path - - Completes the synchronization between external store and object storage + - Update the external store to point to the finalized manifest path + - Completes the synchronization between external store and object storage **Fault Tolerance:** @@ -428,20 +657,20 @@ The reader follows a validation and synchronization protocol: ![External Store Reader Process](../../images/external_store_reader.gif) 1. **Query external store**: `GET_EXTERNAL_STORE base_uri, version` → `path` - - Retrieve the manifest path for the requested version - - If the path does not end with a UUID, return it directly (synchronization complete) - - If the path ends with a UUID, synchronization is required + - Retrieve the manifest path for the requested version + - If the path does not end with a UUID, return it directly (synchronization complete) + - If the path ends with a UUID, synchronization is required 2. **Synchronize to object store**: `COPY_OBJECT_STORE {dataset}/_versions/{version}.manifest-{uuid} → {dataset}/_versions/{version}.manifest` - - Attempt to finalize the staged manifest - - This operation is idempotent + - Attempt to finalize the staged manifest + - This operation is idempotent 3. **Update external store**: `PUT_EXTERNAL_STORE base_uri, version, {dataset}/_versions/{version}.manifest` - - Update the external store to reflect the finalized path - - Future readers will see the synchronized state + - Update the external store to reflect the finalized path + - Future readers will see the synchronized state 4. **Return finalized path**: Return `{dataset}/_versions/{version}.manifest` - - Always return the finalized path - - If synchronization fails, return an error to prevent reading inconsistent state + - Always return the finalized path + - If synchronization fails, return an error to prevent reading inconsistent state This protocol ensures that datasets using external manifest stores remain portable: copying the dataset directory preserves all data without requiring the external store. diff --git a/rust/lance/src/dataset/transaction.rs b/rust/lance/src/dataset/transaction.rs index fc0d4f4dc19..c94e4e39364 100644 --- a/rust/lance/src/dataset/transaction.rs +++ b/rust/lance/src/dataset/transaction.rs @@ -9,41 +9,8 @@ //! one another. We can also rebuild manifests when retrying committing a //! manifest. //! -//! ## Conflict Resolution -//! -//! Transactions are compatible with one another if they don't conflict. -//! Currently, conflict resolution always assumes a Serializable isolation -//! level. -//! -//! Below are the compatibilities between conflicting transactions. The columns -//! represent the operation that has been applied, while the rows represent the -//! operation that is being checked for compatibility to see if it can retry. -//! ✅ indicates that the operation is compatible, while ❌ indicates that it is -//! a conflict. Some operations have additional conditions that must be met for -//! them to be compatible. -//! -//! NOTE/TODO(rmeng): DataReplacement conflict resolution is not fully implemented -//! -//! | | Append | Delete / Update | Overwrite/Create | Create Index | Rewrite | Merge | Project | UpdateConfig | DataReplacement | -//! |------------------|--------|-----------------|------------------|--------------|---------|-------|---------|--------------|-----------------| -//! | Append | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ -//! | Delete / Update | ✅ | 1️⃣ | ❌ | ✅ | 1️⃣ | ❌ | ❌ | ✅ | ✅ -//! | Overwrite/Create | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 2️⃣ | ✅ -//! | Create index | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | 3️⃣ -//! | Rewrite | ✅ | 1️⃣ | ❌ | ❌ | 1️⃣ | ❌ | ❌ | ✅ | 3️⃣ -//! | Merge | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ -//! | Project | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ -//! | UpdateConfig | ✅ | ✅ | 2️⃣ | ✅ | ✅ | ✅ | ✅ | 2️⃣ | ✅ -//! | DataReplacement | ✅ | ✅ | ❌ | 3️⃣ | 1️⃣ | ✅ | 3️⃣ | ✅ | 3️⃣ -//! -//! 1️⃣ Delete, update, and rewrite are compatible with each other and themselves only if -//! they affect distinct fragments. Otherwise, they conflict. -//! 2️⃣ Operations that mutate the config conflict if one of the operations upserts a key -//! that if referenced by another concurrent operation or if both operations modify the schema -//! metadata or the same field metadata. -//! 3️⃣ DataReplacement on a column without index is compatible with any operation AS LONG AS -//! the operation does not modify the region of the column being replaced. -//! +//! For more details please refer to the +//! [Transaction Specification](https://lance.org/format/table/transaction/#transaction-types). use super::ManifestWriteConfig; use super::write::merge_insert::inserted_rows::KeyExistenceFilter; From 3ccd60269f3e65cccf25fad99b767f91d2988c5c Mon Sep 17 00:00:00 2001 From: Will Jones Date: Tue, 10 Mar 2026 17:16:40 -0700 Subject: [PATCH 007/206] fix: handle DataType::Null in adjust_child_validity to prevent panic (#6160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, `adjust_child_validity` would call `ArrayData::try_new` with a null bitmap on a `DataType::Null` array, causing an `.unwrap()` panic with `InvalidArgumentError("Arrays of type Null cannot contain a null bitmask")`. The trigger: when a user inserts rows where a struct sub-field has only null values, Arrow infers `DataType::Null` for that column. If a subsequent fragment omits that nullable sub-field, Lance inserts a `NullReader` to fill it in. `MergeStream` then merges the real batch (with null struct rows) and the `NullReader` batch (all-null struct), recursing into the struct where `adjust_child_validity` is called with the `Null`-typed child and a non-empty parent validity — triggering the panic. Fix: skip the bitmask operation when `child.data_type() == DataType::Null`. A `Null` array is always entirely null by definition and needs no validity adjustment. Closes https://github.com/lance-format/lance/issues/6159 --------- Co-authored-by: Claude Sonnet 4.6 --- rust/lance-arrow/src/lib.rs | 33 ++++++- .../dataset/tests/dataset_schema_evolution.rs | 92 ++++++++++++++++++- 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/rust/lance-arrow/src/lib.rs b/rust/lance-arrow/src/lib.rs index 3bb77076cb3..a3570b53de6 100644 --- a/rust/lance-arrow/src/lib.rs +++ b/rust/lance-arrow/src/lib.rs @@ -1154,6 +1154,12 @@ fn adjust_child_validity( Some(p) => p, }; + // Fast path: DataType::Null arrays are always entirely null by definition and cannot + // carry an explicit null bitmap (Arrow rejects it). No adjustment is needed. + if child.data_type() == &DataType::Null { + return child.clone(); + } + let child_validity = child.nulls(); // Compute the new validity: child_validity AND parent_validity @@ -1559,7 +1565,7 @@ impl BufferExt for arrow_buffer::Buffer { #[cfg(test)] mod tests { use super::*; - use arrow_array::{Float32Array, Int32Array, StructArray}; + use arrow_array::{Float32Array, Int32Array, NullArray, StructArray}; use arrow_array::{ListArray, StringArray, new_empty_array, new_null_array}; use arrow_buffer::OffsetBuffer; @@ -1986,6 +1992,31 @@ mod tests { assert!(width_values.is_null(2)); // width is null when right struct was null } + #[test] + fn test_merge_null_typed_column_with_parent_validity() { + // Reproduces ENT-990: panic in adjust_child_validity when a Null-typed column + // exists on one side and the parent struct has null rows. + // Arrow's Null type has no null bitmap, so passing one to ArrayData::try_new panics. + let left_struct = StructArray::new( + Fields::from(vec![Field::new("a", DataType::Int32, true)]), + vec![Arc::new(Int32Array::from(vec![Some(1), None])) as ArrayRef], + Some(vec![true, false].into()), + ); + let right_struct = StructArray::new( + Fields::from(vec![Field::new("b", DataType::Null, true)]), + vec![Arc::new(NullArray::new(2)) as ArrayRef], + Some(vec![true, false].into()), + ); + + // Previously panicked: "Arrays of type Null cannot contain a null bitmask" + let merged = merge(&left_struct, &right_struct); + assert_eq!(merged.len(), 2); + let b_col = merged.column_by_name("b").unwrap(); + // DataType::Null implies all-null by definition; no null bitmap is stored. + assert_eq!(b_col.data_type(), &DataType::Null); + assert_eq!(b_col.len(), 2); + } + #[test] fn test_merge_with_schema_with_nullable_struct_list_schema_mismatch() { // left_list setup diff --git a/rust/lance/src/dataset/tests/dataset_schema_evolution.rs b/rust/lance/src/dataset/tests/dataset_schema_evolution.rs index e8e81acf609..d311d62f555 100644 --- a/rust/lance/src/dataset/tests/dataset_schema_evolution.rs +++ b/rust/lance/src/dataset/tests/dataset_schema_evolution.rs @@ -4,8 +4,8 @@ use crate::Dataset; use crate::dataset::{NewColumnTransform, WriteMode, WriteParams}; use arrow_array::{ - Array, ArrayRef, FixedSizeListArray, Int32Array, ListArray, RecordBatch, RecordBatchIterator, - StringArray, StructArray, + Array, ArrayRef, FixedSizeListArray, Int32Array, ListArray, NullArray, RecordBatch, + RecordBatchIterator, StringArray, StructArray, }; use arrow_schema::{ DataType, Field as ArrowField, Field, Fields as ArrowFields, Fields, Schema as ArrowSchema, @@ -549,3 +549,91 @@ async fn prepare_initial_dataset_with_list_struct_col(version: LanceFileVersion) dataset } + +/// Reproduces ENT-990: panic in `adjust_child_validity` when reading a dataset where: +/// - Fragment 0 has `meta.extra: Null` (Arrow infers DataType::Null when the user inserts +/// rows where every value in `extra` is null, e.g. from Python/pandas with an all-None column) +/// - Fragment 1 (appended later) has a nullable `meta` struct with null rows, but no `extra` +/// sub-field (Lance allows this because `extra: Null` is nullable in the dataset schema) +/// +/// When Fragment 1 is read, Lance adds a `NullReader` for the missing `meta.extra: Null` +/// sub-field. `MergeStream` calls `RecordBatchExt::merge` on the real batch (with null `meta` +/// rows) and the `NullReader` batch (all-null `meta` struct). The recursive merge descends into +/// `meta`, where the parent's null validity is non-empty and the child column has `DataType::Null` +/// — causing `ArrayData::try_new` to panic. +#[tokio::test] +async fn test_scan_with_null_typed_struct_subfield_across_fragments() { + // Fragment 0: struct column with an `extra` sub-field of DataType::Null. + // This simulates a user inserting rows from Python/pandas where `extra` is all None. + let meta0 = StructArray::new( + ArrowFields::from(vec![ + ArrowField::new("name", DataType::Utf8, true), + ArrowField::new("extra", DataType::Null, true), + ]), + vec![ + Arc::new(StringArray::from(vec![Some("alice"), Some("bob")])) as ArrayRef, + Arc::new(NullArray::new(2)) as ArrayRef, + ], + None, + ); + let schema0 = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, false), + ArrowField::new("meta", meta0.data_type().clone(), true), + ])); + let batch0 = RecordBatch::try_new( + schema0.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef, + Arc::new(meta0) as ArrayRef, + ], + ) + .unwrap(); + + let mut ds = Dataset::write( + RecordBatchIterator::new(vec![Ok(batch0)], schema0), + "memory://", + Some(WriteParams::default()), + ) + .await + .unwrap(); + + // Fragment 1: same struct column but WITHOUT `extra`. Lance's `allow_missing_if_nullable` + // permits omitting `extra` (it's nullable), so a NullReader will fill it when reading + // Fragment 1. The struct has null rows, which is what exposes the bug. + let meta1 = StructArray::new( + ArrowFields::from(vec![ArrowField::new("name", DataType::Utf8, true)]), + vec![Arc::new(StringArray::from(vec![Some("charlie"), None])) as ArrayRef], + Some(vec![true, false].into()), // row 1 is a null struct + ); + let schema1 = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, false), + ArrowField::new("meta", meta1.data_type().clone(), true), + ])); + let batch1 = RecordBatch::try_new( + schema1.clone(), + vec![ + Arc::new(Int32Array::from(vec![3, 4])) as ArrayRef, + Arc::new(meta1) as ArrayRef, + ], + ) + .unwrap(); + + ds.append( + RecordBatchIterator::new(vec![Ok(batch1)], schema1), + Some(WriteParams { + mode: WriteMode::Append, + ..Default::default() + }), + ) + .await + .unwrap(); + + // Scanning reads both fragments. Fragment 1 is missing `meta.extra: Null`, so Lance adds + // a NullReader for it. MergeStream merges the real batch (with null struct rows) and the + // NullReader batch (all-null `meta` struct). The recursive merge in `merge()` descends into + // `meta`, where `right_validity` (from the all-null NullReader struct) has non-zero null + // count and the child column has DataType::Null — previously panicked: + // "Arrays of type Null cannot contain a null bitmask". + let result = ds.scan().try_into_batch().await.unwrap(); + assert_eq!(result.num_rows(), 4); +} From be5f4390995fff952f8c89163e3c36e1276ec8bc Mon Sep 17 00:00:00 2001 From: Will Jones Date: Wed, 11 Mar 2026 11:37:40 -0700 Subject: [PATCH 008/206] fix: persist frag reuse index external file on local filesystem (#6163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when `FragReuseIndexDetails` exceeded 204800 bytes (triggered by large compactions with many fragments), the code wrote the details to an external file (`details.binpb`). On local filesystems, `ObjectStore::create` returns a `LocalWriter` that atomically renames a temp file to the final path in `Writer::shutdown`. However, `frag_reuse.rs` imported `tokio::io::AsyncWriteExt` but not `lance_io::traits::Writer`, so `writer.shutdown()` resolved to `AsyncWriteExt::shutdown` (flush/close only) — the temp file was deleted on drop without being persisted. Any subsequent `load_indices` call would fail with `Not found: .../details.binpb`. Fixed by using UFCS `Writer::shutdown(writer.as_mut()).await?` to explicitly call the lance trait method, matching the existing pattern in `ivf.rs` and `blob.rs`. Fixes #6161 --------- Co-authored-by: Claude Sonnet 4.6 --- rust/lance-io/src/object_writer.rs | 170 ++++++++++++++++++++++------- rust/lance/src/dataset/optimize.rs | 65 +++++++++++ 2 files changed, 193 insertions(+), 42 deletions(-) diff --git a/rust/lance-io/src/object_writer.rs b/rust/lance-io/src/object_writer.rs index f6c9d8943e6..24b0e589d4e 100644 --- a/rust/lance-io/src/object_writer.rs +++ b/rust/lance-io/src/object_writer.rs @@ -500,11 +500,27 @@ impl Writer for ObjectWriter { } pub struct LocalWriter { - inner: tokio::io::BufWriter, - cursor: usize, path: Path, + state: LocalWriteState, +} + +#[derive(Default)] +enum LocalWriteState { + Writing(WritingState), + Finishing { + size: usize, + future: BoxFuture<'static, Result>, + }, + Done(WriteResult), + #[default] + Poisoned, +} + +struct WritingState { + writer: tokio::io::BufWriter, + cursor: usize, /// Temp path that auto-deletes on drop. Set to `None` after `persist()`. - temp_path: Option, + temp_path: tempfile::TempPath, io_tracker: Arc, } @@ -516,13 +532,57 @@ impl LocalWriter { io_tracker: Arc, ) -> Self { Self { - inner: tokio::io::BufWriter::new(file), - cursor: 0, path, - temp_path: Some(temp_path), - io_tracker, + state: LocalWriteState::Writing(WritingState { + writer: tokio::io::BufWriter::new(file), + cursor: 0, + temp_path, + io_tracker, + }), } } + + fn already_closed_err(path: &Path) -> io::Error { + io::Error::other(format!( + "cannot write to LocalWriter for {} after shutdown", + path + )) + } + + fn poisoned_err(path: &Path) -> io::Error { + io::Error::other(format!("LocalWriter for {} is in poisoned state", path)) + } + + async fn persist( + temp_path: tempfile::TempPath, + final_path: Path, + size: usize, + io_tracker: Arc, + ) -> Result { + let local_path = crate::local::to_local_path(&final_path); + let e_tag = tokio::task::spawn_blocking(move || -> Result { + temp_path.persist(&local_path).map_err(|e| { + Error::io(format!( + "failed to persist temp file to {}: {}", + local_path, e.error + )) + })?; + + let metadata = std::fs::metadata(&local_path).map_err(|e| { + Error::io(format!("failed to read metadata for {}: {}", local_path, e)) + })?; + Ok(get_etag(&metadata)) + }) + .await + .map_err(|e| Error::io(format!("spawn_blocking failed: {}", e)))??; + + io_tracker.record_write("put", final_path, size as u64); + + Ok(WriteResult { + size, + e_tag: Some(e_tag), + }) + } } impl AsyncWrite for LocalWriter { @@ -531,32 +591,82 @@ impl AsyncWrite for LocalWriter { cx: &mut std::task::Context<'_>, buf: &[u8], ) -> Poll> { - let poll = Pin::new(&mut self.inner).poll_write(cx, buf); - if let Poll::Ready(Ok(n)) = &poll { - self.cursor += *n; + if let LocalWriteState::Writing(state) = &mut self.state { + let poll = Pin::new(&mut state.writer).poll_write(cx, buf); + if let Poll::Ready(Ok(n)) = &poll { + state.cursor += *n; + } + poll + } else { + Poll::Ready(Err(Self::already_closed_err(&self.path))) } - poll } fn poll_flush( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) + if let LocalWriteState::Writing(state) = &mut self.state { + Pin::new(&mut state.writer).poll_flush(cx) + } else { + Poll::Ready(Err(Self::already_closed_err(&self.path))) + } } fn poll_shutdown( mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) + let mut_self = &mut *self; + loop { + match &mut mut_self.state { + LocalWriteState::Writing(state) => { + if Pin::new(&mut state.writer).poll_shutdown(cx).is_pending() { + return Poll::Pending; + } + + // Write is complete, we can transition to persisting. + let LocalWriteState::Writing(state) = + std::mem::replace(&mut mut_self.state, LocalWriteState::Poisoned) + else { + unreachable!() + }; + let size = state.cursor; + mut_self.state = LocalWriteState::Finishing { + size, + future: Box::pin(Self::persist( + state.temp_path, + mut_self.path.clone(), + size, + state.io_tracker, + )), + }; + } + LocalWriteState::Finishing { future, .. } => match future.poll_unpin(cx) { + Poll::Ready(Ok(result)) => mut_self.state = LocalWriteState::Done(result), + Poll::Ready(Err(e)) => { + return Poll::Ready(Err(io::Error::other(e))); + } + Poll::Pending => return Poll::Pending, + }, + LocalWriteState::Done(_) => return Poll::Ready(Ok(())), + LocalWriteState::Poisoned => { + return Poll::Ready(Err(Self::poisoned_err(&self.path))); + } + } + } } } #[async_trait] impl Writer for LocalWriter { async fn tell(&mut self) -> Result { - Ok(self.cursor) + match &mut self.state { + LocalWriteState::Writing(state) => Ok(state.cursor), + LocalWriteState::Finishing { size, .. } => Ok(*size), + LocalWriteState::Done(result) => Ok(result.size), + LocalWriteState::Poisoned => Err(Self::poisoned_err(&self.path).into()), + } } async fn shutdown(&mut self) -> Result { @@ -567,34 +677,10 @@ impl Writer for LocalWriter { )) })?; - let final_path = crate::local::to_local_path(&self.path); - let temp_path = self.temp_path.take().ok_or_else(|| { - Error::io(format!("local writer for {} already shut down", self.path)) - })?; - let path_clone = self.path.clone(); - let e_tag = tokio::task::spawn_blocking(move || -> Result { - temp_path.persist(&final_path).map_err(|e| { - Error::io(format!( - "failed to persist temp file to {}: {}", - final_path, e.error - )) - })?; - - let metadata = std::fs::metadata(&final_path).map_err(|e| { - Error::io(format!("failed to read metadata for {}: {}", path_clone, e)) - })?; - Ok(get_etag(&metadata)) - }) - .await - .map_err(|e| Error::io(format!("spawn_blocking failed: {}", e)))??; - - self.io_tracker - .record_write("put", self.path.clone(), self.cursor as u64); - - Ok(WriteResult { - size: self.cursor, - e_tag: Some(e_tag), - }) + match &self.state { + LocalWriteState::Done(result) => Ok(result.clone()), + _ => unreachable!(), + } } } diff --git a/rust/lance/src/dataset/optimize.rs b/rust/lance/src/dataset/optimize.rs index c3e1149a2d8..b61fd425860 100644 --- a/rust/lance/src/dataset/optimize.rs +++ b/rust/lance/src/dataset/optimize.rs @@ -2291,6 +2291,71 @@ mod tests { assert_eq!(before_scalar_result, after_scalar_result); } + // Regression test for https://github.com/lancedb/lance/issues/6161 + // When FragReuseIndexDetails exceeds 204800 bytes it is written to an external + // file. Previously the file was silently dropped (temp file deleted) because + // tokio::io::AsyncWriteExt::shutdown was called instead of + // lance_io::traits::Writer::shutdown, which persists the temp file. + #[tokio::test] + async fn test_defer_index_remap_large_external_file() { + let test_dir = TempStrDir::default(); + let test_uri = &test_dir; + + // Create ~150 fragments × 1000 rows to produce a FragReuseIndexDetails + // that exceeds the 204800-byte inline threshold (~302 KB serialized). + let num_fragments = 150usize; + let rows_per_fragment = 1000usize; + let total_rows = num_fragments * rows_per_fragment; + + let schema = Arc::new(Schema::new(vec![Field::new("i", DataType::Int32, false)])); + + let mut dataset = Dataset::write( + RecordBatchIterator::new( + vec![Ok(RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from_iter_values(0..total_rows as i32)) as ArrayRef], + ) + .unwrap())], + schema.clone(), + ), + test_uri, + Some(WriteParams { + max_rows_per_file: rows_per_fragment, + ..Default::default() + }), + ) + .await + .unwrap(); + + assert_eq!(dataset.get_fragments().len(), num_fragments); + + // Delete a few rows from each fragment so compaction has something to do. + dataset.delete("i % 1000 = 0").await.unwrap(); + + compact_files( + &mut dataset, + CompactionOptions { + defer_index_remap: true, + ..Default::default() + }, + None, + ) + .await + .unwrap(); + + // Loading the FragReuseIndex details must succeed even when the details + // were written to an external file. + let frag_reuse_meta = dataset + .load_index_by_name(FRAG_REUSE_INDEX_NAME) + .await + .unwrap() + .expect("fragment reuse index must exist after compaction"); + + load_frag_reuse_index_details(&dataset, &frag_reuse_meta) + .await + .expect("loading large frag reuse index details must not fail"); + } + #[tokio::test] async fn test_defer_index_remap() { let mut data_gen = BatchGenerator::new() From fa64837ed8e2796194ed44014a4b0d7cefe71e72 Mon Sep 17 00:00:00 2001 From: Wyatt Alt Date: Wed, 11 Mar 2026 11:50:21 -0700 Subject: [PATCH 009/206] feat: clearer progress reporting for IVF (#6126) This breaks the "build_partitions" stage into "build_partitions" and "merge_partitions", and also updates the progress reporting on the shuffle phase to be in terms of rows instead of batches. --- rust/lance-index/src/progress.rs | 13 ++++---- rust/lance-index/src/vector/v3/shuffler.rs | 9 +++--- rust/lance/src/index/vector/builder.rs | 35 +++++++++++++++++----- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/rust/lance-index/src/progress.rs b/rust/lance-index/src/progress.rs index 4ac664c7623..b636cd1d765 100644 --- a/rust/lance-index/src/progress.rs +++ b/rust/lance-index/src/progress.rs @@ -7,14 +7,13 @@ use std::sync::Arc; /// Progress callback for index building. /// -/// Called at stage boundaries during index construction. Stages are sequential: -/// `stage_complete` is always called before the next `stage_start`, so only one -/// stage is active at a time. Stage names are index-type-specific (e.g. -/// "train_ivf", "shuffle", "build_partitions" for vector indices; "load_data", -/// "build_pages" for scalar indices). +/// Called at stage boundaries during index construction. Stages are sequential: `stage_complete` +/// is always called before the next `stage_start`, so only one stage is active at a time. Stage +/// names are index-type-specific (e.g. "train_ivf", "shuffle", "merge_partitions" for vector +/// indices; "load_data", "build_pages" for scalar indices). /// -/// Methods take `&self` to allow concurrent calls from within a single stage. -/// Implementations must be thread-safe. +/// Methods take `&self` to allow concurrent calls from within a single stage. Implementations +/// must be thread-safe. #[async_trait] pub trait IndexBuildProgress: std::fmt::Debug + Sync + Send { /// A named stage has started. diff --git a/rust/lance-index/src/vector/v3/shuffler.rs b/rust/lance-index/src/vector/v3/shuffler.rs index 9e37ec1ad59..7f92670f858 100644 --- a/rust/lance-index/src/vector/v3/shuffler.rs +++ b/rust/lance-index/src/vector/v3/shuffler.rs @@ -166,7 +166,7 @@ impl Shuffler for IvfShuffler { .buffered(get_num_compute_intensive_cpus()); let mut total_loss = 0.0; - let mut counter: u64 = 0; + let mut num_rows = 0u64; while let Some(shuffled) = parallel_sort_stream.next().await { let (shuffled, loss) = shuffled?; total_loss += loss; @@ -175,14 +175,15 @@ impl Shuffler for IvfShuffler { for (part_id, (writer, batches)) in writers.iter_mut().zip(shuffled.iter()).enumerate() { if !batches.is_empty() { - partition_sizes[part_id] += batches.iter().map(|b| b.num_rows()).sum::(); + let rows = batches.iter().map(|b| b.num_rows()).sum::(); + partition_sizes[part_id] += rows; + num_rows += rows as u64; futs.push(writer.write_batches(batches.iter())); } } try_join_all(futs).await?; - counter += 1; - self.progress.stage_progress("shuffle", counter).await?; + self.progress.stage_progress("shuffle", num_rows).await?; } // finish all writers diff --git a/rust/lance/src/index/vector/builder.rs b/rust/lance/src/index/vector/builder.rs index 14b134092cc..cf659df4499 100644 --- a/rust/lance/src/index/vector/builder.rs +++ b/rust/lance/src/index/vector/builder.rs @@ -279,21 +279,20 @@ impl IvfIndexBuilder // step 2. shuffle the dataset if self.shuffle_reader.is_none() { - progress.stage_start("shuffle", None, "batches").await?; + let num_rows = self.num_rows_to_shuffle().await?; + progress.stage_start("shuffle", num_rows, "rows").await?; self.shuffle_dataset().await?; progress.stage_complete("shuffle").await?; } - // step 3. build partitions + // step 3. build and merge partitions let num_partitions = self.ivf.as_ref().map(|ivf| ivf.num_partitions() as u64); progress - .stage_start("build_partitions", num_partitions, "partitions") + .stage_start("merge_partitions", num_partitions, "partitions") .await?; let build_idx_stream = self.build_partitions().boxed().await?; - - // step 4. merge all partitions self.merge_partitions(build_idx_stream).await?; - progress.stage_complete("build_partitions").await?; + progress.stage_complete("merge_partitions").await?; Ok(self.merged_num) } @@ -506,6 +505,28 @@ impl IvfIndexBuilder ) } + async fn num_rows_to_shuffle(&self) -> Result> { + let Some(dataset) = self.dataset.as_ref() else { + return Ok(None); + }; + match &self.fragment_filter { + Some(fragment_ids) => { + let fragments: Vec<_> = dataset + .get_fragments() + .into_iter() + .filter(|f| fragment_ids.contains(&(f.id() as u32))) + .collect(); + let counts = futures::stream::iter(fragments) + .map(|f| async move { f.count_rows(None).await }) + .buffer_unordered(16) // ref: Dataset::count_all_rows() + .try_collect::>() + .await?; + Ok(Some(counts.iter().sum::() as u64)) + } + None => Ok(Some(dataset.count_rows(None).await? as u64)), + } + } + async fn shuffle_dataset(&mut self) -> Result<()> { let Some(dataset) = self.dataset.as_ref() else { return Err(Error::invalid_input("dataset not set before shuffling")); @@ -1047,7 +1068,7 @@ impl IvfIndexBuilder log::info!("merging {} partitions", ivf.num_partitions()); while let Some(part) = build_stream.try_next().await? { part_id += 1; - progress.stage_progress("build_partitions", part_id).await?; + progress.stage_progress("merge_partitions", part_id).await?; let Some((storage, index, loss)) = part else { log::warn!("partition {} is empty, skipping", part_id); From e133d8210b82e7dd43234bc6b38e5ad1863a4665 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Wed, 11 Mar 2026 22:10:14 +0000 Subject: [PATCH 010/206] chore: release beta version 4.0.0-beta.9 --- .bumpversion.toml | 2 +- Cargo.lock | 50 +++++++++++++++++++-------------------- Cargo.toml | 40 +++++++++++++++---------------- java/lance-jni/Cargo.lock | 42 ++++++++++++++++---------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 42 ++++++++++++++++---------------- python/Cargo.toml | 2 +- 8 files changed, 91 insertions(+), 91 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index cb43fdac547..43c4b15314f 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.0.0-beta.8" +current_version = "4.0.0-beta.9" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 409c2ed915e..2feb5add054 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3443,7 +3443,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "lance-datagen", @@ -4735,7 +4735,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "all_asserts", "approx", @@ -4830,7 +4830,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "arrow-buffer", @@ -4850,7 +4850,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrayref", "paste", @@ -4859,7 +4859,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "arrow-buffer", @@ -4899,7 +4899,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -4933,7 +4933,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -4953,7 +4953,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-arith", "arrow-array", @@ -5000,7 +5000,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "all_asserts", "arrow", @@ -5026,7 +5026,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-arith", "arrow-array", @@ -5069,7 +5069,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "datafusion", "geo-traits", @@ -5083,7 +5083,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "approx", "arrow", @@ -5163,7 +5163,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-arith", @@ -5211,7 +5211,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "approx", "arrow-array", @@ -5232,7 +5232,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "async-trait", @@ -5246,7 +5246,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -5264,7 +5264,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-ipc", @@ -5322,7 +5322,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -5368,7 +5368,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "proc-macro2", "quote", @@ -5377,7 +5377,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "arrow-schema", @@ -5388,7 +5388,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "clap", "lance-core", @@ -7878,9 +7878,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -8742,9 +8742,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand 2.3.0", "getrandom 0.4.2", diff --git a/Cargo.toml b/Cargo.toml index 52ec2d42076..a332b11ff6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.0.0-beta.8", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.0.0-beta.8", path = "./rust/lance-arrow" } -lance-core = { version = "=4.0.0-beta.8", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.0.0-beta.8", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.0.0-beta.8", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.0.0-beta.8", path = "./rust/lance-encoding" } -lance-file = { version = "=4.0.0-beta.8", path = "./rust/lance-file" } -lance-geo = { version = "=4.0.0-beta.8", path = "./rust/lance-geo" } -lance-index = { version = "=4.0.0-beta.8", path = "./rust/lance-index" } -lance-io = { version = "=4.0.0-beta.8", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.0.0-beta.8", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.0.0-beta.8", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.0.0-beta.8", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.0.0-beta.8", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=4.0.0-beta.9", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=4.0.0-beta.9", path = "./rust/lance-arrow" } +lance-core = { version = "=4.0.0-beta.9", path = "./rust/lance-core" } +lance-datafusion = { version = "=4.0.0-beta.9", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=4.0.0-beta.9", path = "./rust/lance-datagen" } +lance-encoding = { version = "=4.0.0-beta.9", path = "./rust/lance-encoding" } +lance-file = { version = "=4.0.0-beta.9", path = "./rust/lance-file" } +lance-geo = { version = "=4.0.0-beta.9", path = "./rust/lance-geo" } +lance-index = { version = "=4.0.0-beta.9", path = "./rust/lance-index" } +lance-io = { version = "=4.0.0-beta.9", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=4.0.0-beta.9", path = "./rust/lance-linalg" } +lance-namespace = { version = "=4.0.0-beta.9", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=4.0.0-beta.9", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=4.0.0-beta.9", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.5.2" -lance-table = { version = "=4.0.0-beta.8", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.0.0-beta.8", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.0.0-beta.8", path = "./rust/lance-testing" } +lance-table = { version = "=4.0.0-beta.9", path = "./rust/lance-table" } +lance-test-macros = { version = "=4.0.0-beta.9", path = "./rust/lance-test-macros" } +lance-testing = { version = "=4.0.0-beta.9", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.0.0-beta.8", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=4.0.0-beta.9", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.0.0-beta.8", path = "./rust/compression/fsst" } +fsst = { version = "=4.0.0-beta.9", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 0a1eb4f5dc8..2524961a2b0 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3353,7 +3353,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-arith", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "arrow-buffer", @@ -3439,7 +3439,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrayref", "paste", @@ -3448,7 +3448,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "arrow-buffer", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -3518,7 +3518,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -3536,7 +3536,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-arith", "arrow-array", @@ -3573,7 +3573,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-arith", "arrow-array", @@ -3605,7 +3605,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "datafusion", "geo-traits", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-arith", @@ -3686,7 +3686,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-arith", @@ -3727,7 +3727,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-schema", @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "arrow-buffer", @@ -3777,7 +3777,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "async-trait", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-ipc", @@ -3833,7 +3833,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -5485,9 +5485,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -6164,9 +6164,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 8bc75c17cf1..03477d86acf 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 609df9fd839..08f53df6a51 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.0.0-beta.8 + 4.0.0-beta.9 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index d4649565a9e..eaae4d908fa 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2824,7 +2824,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3911,7 +3911,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-arith", @@ -3978,7 +3978,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "arrow-buffer", @@ -3998,7 +3998,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrayref", "paste", @@ -4007,7 +4007,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "arrow-buffer", @@ -4044,7 +4044,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -4077,7 +4077,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-arith", "arrow-array", @@ -4132,7 +4132,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-arith", "arrow-array", @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "datafusion", "geo-traits", @@ -4178,7 +4178,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-arith", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-arith", @@ -4289,7 +4289,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow-array", "arrow-buffer", @@ -4305,7 +4305,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "async-trait", @@ -4317,7 +4317,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-ipc", @@ -4361,7 +4361,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" dependencies = [ "arrow", "arrow-array", @@ -6505,9 +6505,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -7256,9 +7256,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", diff --git a/python/Cargo.toml b/python/Cargo.toml index 9e28d3d3b0d..e901cd5c225 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.0.0-beta.8" +version = "4.0.0-beta.9" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 31cd8d344cb9b23328c90629bb90b1c12756db24 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 12 Mar 2026 15:34:17 +0800 Subject: [PATCH 011/206] chore: clippy cleanups (#6172) This PR moves a few unrelated clippy cleanups out of #6168 so the blob empty-range fix can stay focused on the regression it addresses. The changes here are all mechanical simplifications with no intended behavior change. --- rust/lance/src/dataset/fragment.rs | 16 ++- rust/lance/src/dataset/mem_wal/index/fts.rs | 10 +- rust/lance/src/dataset/write/commit.rs | 5 +- rust/lance/src/index/create.rs | 4 +- rust/lance/src/index/vector/ivf.rs | 50 ++++---- rust/lance/src/io/exec/filtered_read.rs | 130 ++++++++++---------- 6 files changed, 107 insertions(+), 108 deletions(-) diff --git a/rust/lance/src/dataset/fragment.rs b/rust/lance/src/dataset/fragment.rs index b4d22d6511e..5be98a9b23d 100644 --- a/rust/lance/src/dataset/fragment.rs +++ b/rust/lance/src/dataset/fragment.rs @@ -1113,8 +1113,14 @@ impl FileFragment { /// Fails if the fragment does not have the physical row count in the metadata. This method should /// only be called in new workflows which are not run on old versions of Lance. pub fn fast_physical_rows(&self) -> Result { - if self.dataset.manifest.writer_version.is_some() && self.metadata.physical_rows.is_some() { - Ok(self.metadata.physical_rows.unwrap()) + if self.dataset.manifest.writer_version.is_some() { + let Some(physical_rows) = self.metadata.physical_rows else { + return Err(Error::internal(format!( + "The method fast_physical_rows was called on a fragment that does not have the physical row count in the metadata. Fragment id: {}", + self.id() + ))); + }; + Ok(physical_rows) } else { Err(Error::internal(format!( "The method fast_physical_rows was called on a fragment that does not have the physical row count in the metadata. Fragment id: {}", @@ -1168,8 +1174,10 @@ impl FileFragment { // we should not used the cached value. On write, we update the values // in the manifest, fixing the issue for future reads. // See: https://github.com/lance-format/lance/issues/1531 - if self.dataset.manifest.writer_version.is_some() && self.metadata.physical_rows.is_some() { - return Ok(self.metadata.physical_rows.unwrap()); + if self.dataset.manifest.writer_version.is_some() + && let Some(physical_rows) = self.metadata.physical_rows + { + return Ok(physical_rows); } // Just open any file. All of them should have same size. diff --git a/rust/lance/src/dataset/mem_wal/index/fts.rs b/rust/lance/src/dataset/mem_wal/index/fts.rs index 3130f6474ce..59b9817a4f0 100644 --- a/rust/lance/src/dataset/mem_wal/index/fts.rs +++ b/rust/lance/src/dataset/mem_wal/index/fts.rs @@ -96,11 +96,11 @@ pub enum FtsQueryExpr { /// Boolean combination of queries. Boolean { /// All MUST clauses must match for a document to be included. - must: Vec, + must: Vec, /// At least one SHOULD clause should match (adds to score). - should: Vec, + should: Vec, /// No MUST_NOT clause may match (excludes documents). - must_not: Vec, + must_not: Vec, }, /// Boosting query with positive and optional negative components. /// @@ -109,9 +109,9 @@ pub enum FtsQueryExpr { /// and negative have their scores reduced by `negative_boost`. Boost { /// The primary query (documents must match this). - positive: Box, + positive: Box, /// Optional query to demote matching documents. - negative: Option>, + negative: Option>, /// Boost factor for documents matching negative query (typically < 1.0). /// Score becomes: original_score * negative_boost for docs matching negative. negative_boost: f32, diff --git a/rust/lance/src/dataset/write/commit.rs b/rust/lance/src/dataset/write/commit.rs index 0a85d27ce99..ddf1b769425 100644 --- a/rust/lance/src/dataset/write/commit.rs +++ b/rust/lance/src/dataset/write/commit.rs @@ -193,9 +193,10 @@ impl<'a> CommitBuilder<'a> { dataset.commit_handler.clone(), ), WriteDestination::Uri(uri) => { - let commit_handler = if self.commit_handler.is_some() && self.object_store.is_some() + let commit_handler = if let (Some(_), Some(commit_handler)) = + (&self.object_store, &self.commit_handler) { - self.commit_handler.as_ref().unwrap().clone() + commit_handler.clone() } else { resolve_commit_handler(uri, self.commit_handler.clone(), &self.store_params) .await? diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index 50027833bfc..b8066d68953 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -315,7 +315,7 @@ impl<'a> CreateIndexBuilder<'a> { if train { // Check if this is distributed indexing (fragment-level) - if self.fragments.is_some() { + if let Some(fragments) = &self.fragments { // For distributed indexing, build only on specified fragments // This creates temporary index metadata without committing Box::pin(build_distributed_vector_index( @@ -325,7 +325,7 @@ impl<'a> CreateIndexBuilder<'a> { &index_id.to_string(), vec_params, fri, - self.fragments.as_ref().unwrap(), + fragments, self.progress.clone(), )) .await?; diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 67906bd742b..3d73d52fb2a 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -2300,28 +2300,19 @@ mod tests { } } - fn distance_between_points(&self) -> f32 { - (self.dim as f32).sqrt() - } - - fn generate_centroids(&self) -> Float32Array { + fn generate_centroids(dim: u32, num_centroids: u32) -> Float32Array { const MAX_ATTEMPTS: u32 = 10; - let distance_needed = - self.distance_between_points() * Self::VALS_PER_CODE as f32 * 2_f32; + let distance_needed = (dim as f32).sqrt() * Self::VALS_PER_CODE as f32 * 2_f32; let mut attempts_remaining = MAX_ATTEMPTS; - let num_values = self.dim * self.num_centroids; + let num_values = dim * num_centroids; while attempts_remaining > 0 { // Use some biggish numbers to ensure we get the distance we want but make them positive // and not too big for easier debugging. let centroids: Float32Array = generate_scaled_random_array(num_values as usize, 0_f32, 1000_f32); let mut broken = false; - for (index, centroid) in centroids - .values() - .chunks_exact(self.dim as usize) - .enumerate() - { - let offset = (index + 1) * self.dim as usize; + for (index, centroid) in centroids.values().chunks_exact(dim as usize).enumerate() { + let offset = (index + 1) * dim as usize; let length = centroids.len() - offset; if length == 0 { // This will be true for the last item since we ignore comparison with self @@ -2330,7 +2321,7 @@ mod tests { let distances = l2_distance_batch( centroid, ¢roids.values()[offset..offset + length], - self.dim as usize, + dim as usize, ); let min_distance = distances.min_by(|a, b| a.total_cmp(b)).unwrap(); // In theory we could just replace this one vector but, out of laziness, we just retry all of them @@ -2351,11 +2342,10 @@ mod tests { } fn get_centroids(&mut self) -> &Float32Array { - if self.centroids.is_some() { - return self.centroids.as_ref().unwrap(); - } - self.centroids = Some(self.generate_centroids()); - self.centroids.as_ref().unwrap() + let dim = self.dim; + let num_centroids = self.num_centroids; + self.centroids + .get_or_insert_with(|| Self::generate_centroids(dim, num_centroids)) } fn get_centroids_as_list_arr(&mut self) -> Arc { @@ -2368,10 +2358,12 @@ mod tests { ) } - fn generate_vectors(&mut self) -> Float32Array { - let dim = self.dim as usize; - let num_centroids = self.num_centroids; - let centroids = self.get_centroids(); + fn generate_vectors( + dim: u32, + num_centroids: u32, + centroids: &Float32Array, + ) -> Float32Array { + let dim = dim as usize; let mut vectors: Vec = vec![0_f32; Self::VALS_PER_CODE as usize * dim * num_centroids as usize]; for (centroid, dst_batch) in centroids @@ -2389,11 +2381,11 @@ mod tests { } fn get_vectors(&mut self) -> &Float32Array { - if self.vectors.is_some() { - return self.vectors.as_ref().unwrap(); - } - self.vectors = Some(self.generate_vectors()); - self.vectors.as_ref().unwrap() + let dim = self.dim; + let num_centroids = self.num_centroids; + let centroids = self.get_centroids().clone(); + self.vectors + .get_or_insert_with(|| Self::generate_vectors(dim, num_centroids, ¢roids)) } fn get_vector(&mut self, idx: u32) -> Float32Array { diff --git a/rust/lance/src/io/exec/filtered_read.rs b/rust/lance/src/io/exec/filtered_read.rs index 7d22120c3c9..35fd486a283 100644 --- a/rust/lance/src/io/exec/filtered_read.rs +++ b/rust/lance/src/io/exec/filtered_read.rs @@ -1854,7 +1854,7 @@ impl ExecutionPlan for FilteredReadExec { let total_rows: u64 = fragments.iter().map(|f| f.num_rows().unwrap() as u64).sum(); - if self.options.full_filter.is_none() { + let Some(filter) = self.options.full_filter.as_ref() else { // If there is no filter, we just return the total number of rows (sans any before-filter range) // divided by the number of partitions. let total_rows = @@ -1876,83 +1876,81 @@ impl ExecutionPlan for FilteredReadExec { total_rows }; - Ok(Statistics { + return Ok(Statistics { num_rows: Precision::Exact(total_rows as usize), ..datafusion::physical_plan::Statistics::new_unknown(self.schema().as_ref()) - }) - } else { - // We could evaluate the indexed filter here but this is still during the planning - // phase so we want to avoid that. - // - // Instead, we create a mock input which is the filtered read (without the filter) - // and then use DF's FilterExec logic to calculate the statistics (which uses column - // stats and basic filter shape) - let filter = self.options.full_filter.as_ref().unwrap(); - - // Need to add in filter columns even though they aren't part of the projection - let filter_columns = Planner::column_names_in_expr(filter); - let read_projection = self - .options - .projection - .clone() - .union_columns(filter_columns, OnMissing::Error)?; - - let read_schema = Arc::new(read_projection.to_arrow_schema()); - - let planner = Arc::new(Planner::new(read_schema.clone())); - let physical_filter = planner.create_physical_expr(filter)?; - - let mock_input = Arc::new(Self::try_new( - self.dataset.clone(), - FilteredReadOptions { - scan_range_after_filter: None, - refine_filter: None, - full_filter: None, - projection: read_projection, - ..self.options.clone() - }, - None, - )?); - let df_filter_exec = FilterExec::try_new(physical_filter, mock_input)?; - let mut df_stats = df_filter_exec.partition_statistics(partition)?; - - // If we have an after-filter range, we should apply it to the stats (the before-filter range - // is applied in the mock input) - let total_rows = if let Some(scan_range_after_filter) = - &self.options.scan_range_after_filter - { + }); + }; + + // We could evaluate the indexed filter here but this is still during the planning + // phase so we want to avoid that. + // + // Instead, we create a mock input which is the filtered read (without the filter) + // and then use DF's FilterExec logic to calculate the statistics (which uses column + // stats and basic filter shape) + + // Need to add in filter columns even though they aren't part of the projection + let filter_columns = Planner::column_names_in_expr(filter); + let read_projection = self + .options + .projection + .clone() + .union_columns(filter_columns, OnMissing::Error)?; + + let read_schema = Arc::new(read_projection.to_arrow_schema()); + + let planner = Arc::new(Planner::new(read_schema.clone())); + let physical_filter = planner.create_physical_expr(filter)?; + + let mock_input = Arc::new(Self::try_new( + self.dataset.clone(), + FilteredReadOptions { + scan_range_after_filter: None, + refine_filter: None, + full_filter: None, + projection: read_projection, + ..self.options.clone() + }, + None, + )?); + let df_filter_exec = FilterExec::try_new(physical_filter, mock_input)?; + let mut df_stats = df_filter_exec.partition_statistics(partition)?; + + // If we have an after-filter range, we should apply it to the stats (the before-filter range + // is applied in the mock input) + let total_rows = + if let Some(scan_range_after_filter) = &self.options.scan_range_after_filter { df_stats.num_rows.min(&Precision::Exact( scan_range_after_filter.end as usize - scan_range_after_filter.start as usize, )) } else { df_stats.num_rows }; - df_stats.num_rows = total_rows; - - let schema = self.schema(); - - // We might have added some columns to the schema so the filter compiles but we drop this - // columns during the filtered read and they aren't part of the output. So we need to make - // sure and drop them from the column stats as well. - assert_eq!(read_schema.fields.len(), df_stats.column_statistics.len()); - let mut proj_iter = schema.fields.iter().peekable(); - let mut stats_iter = read_schema.fields.iter(); - df_stats.column_statistics.retain(|_| { - let stats_field = stats_iter.next().unwrap(); - if let Some(proj_field) = proj_iter.peek() { - if proj_field.name() == stats_field.name() { - proj_iter.next(); - true - } else { - false - } + df_stats.num_rows = total_rows; + + let schema = self.schema(); + + // We might have added some columns to the schema so the filter compiles but we drop this + // columns during the filtered read and they aren't part of the output. So we need to make + // sure and drop them from the column stats as well. + assert_eq!(read_schema.fields.len(), df_stats.column_statistics.len()); + let mut proj_iter = schema.fields.iter().peekable(); + let mut stats_iter = read_schema.fields.iter(); + df_stats.column_statistics.retain(|_| { + let stats_field = stats_iter.next().unwrap(); + if let Some(proj_field) = proj_iter.peek() { + if proj_field.name() == stats_field.name() { + proj_iter.next(); + true } else { false } - }); + } else { + false + } + }); - Ok(df_stats) - } + Ok(df_stats) } fn with_new_children( From 98bb07380ae594bf522946f41ad104940a37f553 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 12 Mar 2026 17:46:13 +0800 Subject: [PATCH 012/206] ci: move Linux and Windows jobs to GitHub-hosted runners (#6175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR moves the Linux and Windows workflows that currently run on Warp onto GitHub-hosted runners. The goal is to reduce reliance on custom runners and take advantage of the sponsored larger GitHub-hosted machines for the slowest CI paths. This is focused on the current CI bottlenecks we observed in recent successful PR runs, especially Rust ARM and Python Windows jobs, while keeping the existing macOS and benchmark-specific runners unchanged until we verify equivalent GitHub-hosted options for them. Context: - Recent PR history shows Rust `linux-arm` and Python `windows` as the dominant critical-path jobs. - This change upgrades those jobs to larger GitHub-hosted runners where available (`ubuntu-24.04-8x`, `ubuntu-24.04-arm64-8x`, `windows-latest-4x`) and aligns the remaining Linux/Windows workflows with the same runner family. - I validated the workflow YAML locally after the runner migration; no product code or test logic changed. --- Updates: - Rust linux-arm:40.7 -> 19.4,about -52% - Rust windows-build:27.7 -> 21.0,about -24% - Python windows:36.5 -> 23.1,about -37% - Python Linux 3.13 ARM:26.9 -> 20.7,about -23% - Python Linux 3.13 x86_64:26.8 -> 19.1,about -29% - Python Linux 3.9 x86_64:25.9 -> 19.2,about -26% --- .github/workflows/cargo-publish.yml | 2 +- .github/workflows/codex-backport-pr.yml | 2 +- .github/workflows/codex-fix-ci.yml | 2 +- .github/workflows/file_verification.yml | 2 +- .github/workflows/java-publish.yml | 2 +- .github/workflows/java.yml | 2 +- .github/workflows/nightly_run.yml | 2 +- .github/workflows/pypi-publish.yml | 6 +++--- .github/workflows/python.yml | 10 +++++----- .github/workflows/rust.yml | 10 +++++----- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/cargo-publish.yml b/.github/workflows/cargo-publish.yml index 2d0a5f83b28..a46f686ec34 100644 --- a/.github/workflows/cargo-publish.yml +++ b/.github/workflows/cargo-publish.yml @@ -27,7 +27,7 @@ env: jobs: build: # Needs additional disk space for the full build. - runs-on: warp-ubuntu-latest-x64-8x + runs-on: ubuntu-24.04-8x permissions: id-token: write timeout-minutes: 60 diff --git a/.github/workflows/codex-backport-pr.yml b/.github/workflows/codex-backport-pr.yml index 2730d61445c..0b43cca2460 100644 --- a/.github/workflows/codex-backport-pr.yml +++ b/.github/workflows/codex-backport-pr.yml @@ -35,7 +35,7 @@ permissions: jobs: backport: - runs-on: warp-ubuntu-latest-x64-4x + runs-on: ubuntu-24.04-4x timeout-minutes: 60 env: CC: clang diff --git a/.github/workflows/codex-fix-ci.yml b/.github/workflows/codex-fix-ci.yml index 5298096caf6..b15bd367c7b 100644 --- a/.github/workflows/codex-fix-ci.yml +++ b/.github/workflows/codex-fix-ci.yml @@ -35,7 +35,7 @@ permissions: jobs: fix-ci: - runs-on: warp-ubuntu-latest-x64-4x + runs-on: ubuntu-24.04-4x timeout-minutes: 60 env: CC: clang diff --git a/.github/workflows/file_verification.yml b/.github/workflows/file_verification.yml index cf55982a0cd..cf06ad83d0b 100644 --- a/.github/workflows/file_verification.yml +++ b/.github/workflows/file_verification.yml @@ -8,7 +8,7 @@ on: jobs: run: timeout-minutes: 45 - runs-on: warp-ubuntu-latest-x64-8x + runs-on: ubuntu-24.04-8x steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 diff --git a/.github/workflows/java-publish.yml b/.github/workflows/java-publish.yml index aa5ca1ff77c..38dedf098ff 100644 --- a/.github/workflows/java-publish.yml +++ b/.github/workflows/java-publish.yml @@ -27,7 +27,7 @@ on: jobs: linux-arm64: name: Build on Linux Arm64 - runs-on: warp-ubuntu-latest-arm64-8x + runs-on: ubuntu-24.04-arm64-8x timeout-minutes: 60 steps: - name: Checkout repository diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 22f73c71125..d6af4f6dff9 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -47,7 +47,7 @@ jobs: run: cargo clippy --all-targets -- -D warnings build-and-test-java: - runs-on: warp-ubuntu-latest-x64-4x + runs-on: ubuntu-24.04-4x timeout-minutes: 60 strategy: matrix: diff --git a/.github/workflows/nightly_run.yml b/.github/workflows/nightly_run.yml index 79e9899c7e7..6806962a387 100644 --- a/.github/workflows/nightly_run.yml +++ b/.github/workflows/nightly_run.yml @@ -19,7 +19,7 @@ jobs: jumbo-tests: # jumbo tests need more resources - runs-on: warp-ubuntu-latest-x64-8x + runs-on: ubuntu-24.04-8x if: github.repository == 'lancedb/lance' timeout-minutes: 60 permissions: diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 94651d00ffd..51c20a45bed 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -45,11 +45,11 @@ jobs: - platform: aarch64 manylinux: "2_17" extra_args: "" - runner: warp-ubuntu-latest-arm64-4x + runner: ubuntu-24.04-arm64-4x - platform: aarch64 manylinux: "2_28" extra_args: "--features fp16kernels" - runner: warp-ubuntu-latest-arm64-4x + runner: ubuntu-24.04-arm64-4x runs-on: ${{ matrix.config.runner }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -146,7 +146,7 @@ jobs: repo: ${{ steps.handle_tag.outputs.repo }} windows: timeout-minutes: 60 - runs-on: windows-latest + runs-on: windows-latest-4x strategy: matrix: python-minor-version: ["9"] diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 95811836667..1808fa1b995 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -39,7 +39,7 @@ env: jobs: lint: timeout-minutes: 45 - runs-on: "warp-ubuntu-latest-x64-4x" + runs-on: "ubuntu-24.04-4x" defaults: run: shell: bash @@ -96,7 +96,7 @@ jobs: matrix: python-minor-version: ["9", "13"] name: "Python Linux 3.${{ matrix.python-minor-version }} x86_64" - runs-on: "warp-ubuntu-latest-x64-4x" + runs-on: "ubuntu-24.04-4x" defaults: run: shell: bash @@ -161,7 +161,7 @@ jobs: linux-arm: timeout-minutes: 45 - runs-on: warp-ubuntu-latest-arm64-4x + runs-on: ubuntu-24.04-arm64-4x name: Python Linux 3.13 ARM defaults: run: @@ -219,7 +219,7 @@ jobs: skip-torch: "true" windows: - runs-on: windows-latest + runs-on: windows-latest-4x timeout-minutes: 90 defaults: run: @@ -240,7 +240,7 @@ jobs: - uses: ./.github/workflows/run_tests aws-integtest: timeout-minutes: 45 - runs-on: "warp-ubuntu-latest-x64-4x" + runs-on: "ubuntu-24.04-4x" defaults: run: shell: bash diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e4bde42c48b..fb495a68f94 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -80,7 +80,7 @@ jobs: command: check linux-build: - runs-on: "warp-ubuntu-latest-x64-4x" + runs-on: "ubuntu-24.04-8x" timeout-minutes: 60 env: # Need up-to-date compilers for kernels @@ -118,7 +118,7 @@ jobs: flags: unittests fail_ci_if_error: false linux-arm: - runs-on: warp-ubuntu-latest-arm64-4x + runs-on: ubuntu-24.04-arm64-8x timeout-minutes: 75 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -143,7 +143,7 @@ jobs: ALL_FEATURES=`cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v -e protoc -e slow_tests | sort | uniq | paste -s -d "," -` cargo test --profile ci --locked --features ${ALL_FEATURES} query-integration-tests: - runs-on: warp-ubuntu-latest-x64-4x + runs-on: ubuntu-24.04-4x timeout-minutes: 75 env: # We use opt-level 1 which makes some tests 5x faster to run. @@ -170,7 +170,7 @@ jobs: run: | cargo test --locked -p lance --no-default-features --features fp16kernels,slow_tests --test integration_tests build-no-lock: - runs-on: warp-ubuntu-latest-x64-8x + runs-on: ubuntu-24.04-8x timeout-minutes: 30 env: # Need up-to-date compilers for kernels @@ -227,7 +227,7 @@ jobs: run: | cargo check --profile ci --benches --features fp16kernels,cli,dynamodb,substrait windows-build: - runs-on: warp-windows-latest-x64-4x + runs-on: windows-latest-4x defaults: run: working-directory: rust From f3e50d7698491466fe871fcd8ef6f4bb4a127c67 Mon Sep 17 00:00:00 2001 From: "Farmer.Chillax" <48387781+FarmerChillax@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:11:04 +0800 Subject: [PATCH 013/206] docs: add alicloud oss configuration (#6167) Improvements https://github.com/lance-format/lance/pull/4247 alicloud storage config doc. Signed-off-by: FarmerChillax --- docs/src/guide/object_store.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/src/guide/object_store.md b/docs/src/guide/object_store.md index 21c1addcc62..a1f0c2341ef 100644 --- a/docs/src/guide/object_store.md +++ b/docs/src/guide/object_store.md @@ -189,4 +189,32 @@ These keys can be used as both environment variables or keys in the `storage_opt | `azure_msi_resource_id` / `msi_resource_id` | Msi resource id for use with managed identity authentication. | | `azure_federated_token_file` / `federated_token_file` | File containing token for Azure AD workload identity federation. | | `azure_use_azure_cli` / `use_azure_cli` | Use azure cli for acquiring access token. | -| `azure_disable_tagging` / `disable_tagging` | Disables tagging objects. This can be desirable if not supported by the backing store. | \ No newline at end of file +| `azure_disable_tagging` / `disable_tagging` | Disables tagging objects. This can be desirable if not supported by the backing store. | + +## AliCloud Object Storage Service Configuration + +OSS credentials can be set in the environment variables `OSS_ACCESS_KEY_ID`, +`OSS_ACCESS_KEY_SECRET`, `OSS_REGION`, and `OSS_SECURITY_TOKEN`. Alternatively, they can be +passed as parameters to the `storage_options` parameter: + +```python +import lance +ds = lance.dataset( + "oss://bucket/path", + storage_options={ + "oss_region": "oss-region", + "oss_endpoint": "oss-endpoint", + "oss_access_key_id": "my-access-key", + "oss_secret_access_key": "my-secret-key", + "oss_security_token": "my-session-token", + } +) +``` + +| Key | Description | +|-----|-------------| +| `oss_endpoint` | OSS endpoint. Required (for example, `https://oss-cn-hangzhou.aliyuncs.com`). | +| `oss_access_key_id` | Access key ID used for OSS authentication. Optional if credentials are provided by environment. | +| `oss_secret_access_key` | Access key secret used for OSS authentication. Optional if credentials are provided by environment. | +| `oss_region` | OSS region (for example, `cn-hangzhou`). Optional. | +| `oss_security_token` | Security token for temporary credentials (STS). Optional. | From 58f3b844b1f5a8aae369b954782db623030f9c6d Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 12 Mar 2026 18:29:36 +0800 Subject: [PATCH 014/206] fix: avoid empty range reads for zero-length blobs (#6168) Blob reads should return empty bytes when the logical blob is empty or the cursor is already at EOF. Today `BlobFile::read` / `read_up_to` can still issue a `get_range(start..end)` request with `start == end`, which is tolerated by local readers but rejected by cloud object stores. This showed up while investigating `random_blob` failures on the original-scale `laion10m-full` dataset, where legacy blob reads on S3 failed with errors like `Range started at 1 and ended at 1`. The fix short-circuits empty reads and restores the cursor to blob-relative semantics after `read()`, and adds regression coverage for both the empty-range case and packed-blob cursor behavior. --- rust/lance/src/dataset/blob.rs | 155 ++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 2 deletions(-) diff --git a/rust/lance/src/dataset/blob.rs b/rust/lance/src/dataset/blob.rs index 428c71a3458..b77a21dc302 100644 --- a/rust/lance/src/dataset/blob.rs +++ b/rust/lance/src/dataset/blob.rs @@ -692,9 +692,12 @@ impl BlobFile { let position = self.position; let size = self.size; self.do_with_reader(|cursor, reader| async move { + if cursor >= size { + return Ok((size, bytes::Bytes::new())); + } let start = position as usize + cursor as usize; let end = (position + size) as usize; - Ok((end as u64, reader.get_range(start..end).await?)) + Ok((size, reader.get_range(start..end).await?)) }) .await } @@ -707,6 +710,9 @@ impl BlobFile { let position = self.position; let size = self.size; self.do_with_reader(|cursor, reader| async move { + if cursor >= size || len == 0 { + return Ok((size.min(cursor), bytes::Bytes::new())); + } let start = position as usize + cursor as usize; let read_size = len.min((size - cursor) as usize); let end = start + read_size; @@ -1120,12 +1126,18 @@ mod tests { use arrow_array::RecordBatch; use arrow_array::{RecordBatchIterator, UInt32Array}; use arrow_schema::{DataType, Field, Schema}; + use async_trait::async_trait; use futures::TryStreamExt; use lance_arrow::{BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY, DataTypeExt}; use lance_core::datatypes::BlobKind; use lance_io::object_store::{ObjectStore, ObjectStoreParams, ObjectStoreRegistry}; use lance_io::stream::RecordBatchStream; use lance_table::format::BasePath; + use object_store::{ + GetOptions, GetRange, GetResult, ListResult, MultipartUpload, ObjectMeta, + PutMultipartOptions, PutOptions, PutPayload, PutResult, path::Path, + }; + use url::Url; use lance_core::{ Error, Result, @@ -1134,7 +1146,7 @@ mod tests { use lance_datagen::{BatchCount, RowCount, array}; use lance_file::version::LanceFileVersion; - use super::data_file_key_from_path; + use super::{BlobFile, data_file_key_from_path}; use crate::{ Dataset, blob::{BlobArrayBuilder, blob_field}, @@ -1154,6 +1166,119 @@ mod tests { expected: Vec, } + #[derive(Debug)] + struct RejectEmptyRangeObjectStore; + + impl std::fmt::Display for RejectEmptyRangeObjectStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RejectEmptyRangeObjectStore") + } + } + + #[async_trait] + impl object_store::ObjectStore for RejectEmptyRangeObjectStore { + async fn put( + &self, + _location: &Path, + _bytes: PutPayload, + ) -> object_store::Result { + unimplemented!("put is not used by these tests") + } + + async fn put_opts( + &self, + _location: &Path, + _bytes: PutPayload, + _opts: PutOptions, + ) -> object_store::Result { + unimplemented!("put_opts is not used by these tests") + } + + async fn put_multipart( + &self, + _location: &Path, + ) -> object_store::Result> { + unimplemented!("put_multipart is not used by these tests") + } + + async fn put_multipart_opts( + &self, + _location: &Path, + _opts: PutMultipartOptions, + ) -> object_store::Result> { + unimplemented!("put_multipart_opts is not used by these tests") + } + + async fn get(&self, _location: &Path) -> object_store::Result { + Err(object_store::Error::NotSupported { + source: "get is not used by these tests".into(), + }) + } + + async fn get_opts( + &self, + location: &Path, + options: GetOptions, + ) -> object_store::Result { + let Some(GetRange::Bounded(range)) = options.range else { + unreachable!("blob reads should always request a bounded range") + }; + if range.start == range.end { + return Err(object_store::Error::Generic { + store: "RejectEmptyRangeObjectStore", + source: format!( + "Range started at {} and ended at {}", + range.start, range.end + ) + .into(), + }); + } + Err(object_store::Error::NotSupported { + source: format!("unexpected non-empty range {range:?} for {location}").into(), + }) + } + + async fn delete(&self, _location: &Path) -> object_store::Result<()> { + unimplemented!("delete is not used by these tests") + } + + fn list( + &self, + _prefix: Option<&Path>, + ) -> futures::stream::BoxStream<'static, object_store::Result> { + unimplemented!("list is not used by these tests") + } + + async fn list_with_delimiter( + &self, + _prefix: Option<&Path>, + ) -> object_store::Result { + unimplemented!("list_with_delimiter is not used by these tests") + } + + async fn copy(&self, _from: &Path, _to: &Path) -> object_store::Result<()> { + unimplemented!("copy is not used by these tests") + } + + async fn copy_if_not_exists(&self, _from: &Path, _to: &Path) -> object_store::Result<()> { + unimplemented!("copy_if_not_exists is not used by these tests") + } + } + + fn reject_empty_range_store() -> Arc { + Arc::new(ObjectStore::new( + Arc::new(RejectEmptyRangeObjectStore) as Arc, + Url::parse("mock:///blob-tests").unwrap(), + None, + None, + false, + true, + lance_io::object_store::DEFAULT_LOCAL_IO_PARALLELISM, + lance_io::object_store::DEFAULT_DOWNLOAD_RETRY_COUNT, + None, + )) + } + impl BlobTestFixture { async fn new() -> Self { let test_dir = TempStrDir::default(); @@ -1518,6 +1643,32 @@ mod tests { assert_eq!(second.as_ref(), b"world"); } + #[tokio::test] + async fn test_blob_file_read_empty_range_returns_empty_bytes() { + let store = reject_empty_range_store(); + let path = Path::from("blobs/test.bin"); + + let empty_blob = BlobFile::new_packed(store.clone(), path.clone(), 1, 0); + assert!(empty_blob.read().await.unwrap().is_empty()); + assert!(empty_blob.read_up_to(16).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn test_blob_file_read_tracks_relative_cursor() { + let test_dir = TempDir::default(); + let file_path = test_dir.std_path().join("blob.bin"); + std::fs::write(&file_path, b"abcd").unwrap(); + + let path = Path::from_absolute_path(file_path).unwrap(); + let blob = BlobFile::new_packed(Arc::new(ObjectStore::local()), path, 1, 2); + + assert_eq!(blob.read().await.unwrap().as_ref(), b"bc"); + assert_eq!(blob.tell().await.unwrap(), 2); + assert!(blob.read().await.unwrap().is_empty()); + assert!(blob.read_up_to(1).await.unwrap().is_empty()); + assert_eq!(blob.tell().await.unwrap(), 2); + } + #[tokio::test] async fn test_take_blob_v2_from_non_default_base_inline() { let fixture = create_multi_base_blob_v2_fixture(b"inline".to_vec(), None, true).await; From f4adbc0c2e50e0c3ad6942f57d97166095014332 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 13 Mar 2026 03:02:06 +0800 Subject: [PATCH 015/206] perf: remove shard content key sorting from distributed merge (#6179) --- .../src/vector/distributed/index_merger.rs | 270 +----------------- 1 file changed, 2 insertions(+), 268 deletions(-) diff --git a/rust/lance-index/src/vector/distributed/index_merger.rs b/rust/lance-index/src/vector/distributed/index_merger.rs index 378d5bb1bb1..59add308476 100755 --- a/rust/lance-index/src/vector/distributed/index_merger.rs +++ b/rust/lance-index/src/vector/distributed/index_merger.rs @@ -9,10 +9,9 @@ use crate::vector::shared::partition_merger::{ use arrow::{compute::concat_batches, datatypes::Float32Type}; use arrow_array::cast::AsArray; use arrow_array::types::UInt8Type; -use arrow_array::{Array, FixedSizeListArray, RecordBatch, UInt64Array}; +use arrow_array::{Array, FixedSizeListArray, RecordBatch}; use futures::StreamExt as _; use lance_arrow::{FixedSizeListArrayExt, RecordBatchExt}; -use lance_core::utils::address::RowAddress; use lance_core::{Error, ROW_ID_FIELD, Result}; use std::ops::Range; use std::sync::Arc; @@ -350,129 +349,12 @@ fn detect_supported_index_type( SupportedIvfIndexType::detect_from_reader_and_schema(reader, schema) } -/// Decode the fragment id from an encoded row id. -/// -/// Row ids are stored as a 64-bit [RowAddress] where the upper 32 bits encode -/// the fragment id and the lower 32 bits encode the row offset. -fn decode_fragment_id_from_row_id(row_id_u64: u64) -> u32 { - let addr = RowAddress::new_from_u64(row_id_u64); - addr.fragment_id() -} - -/// Compute a content-derived shard sort key for a partial auxiliary file. -/// -/// The key is `(min_fragment_id, min_row_id, parent_dir_name)` where: -/// - `min_fragment_id` is the minimum fragment id observed among the first row -/// of each non-empty IVF partition. -/// - `min_row_id` is the minimum encoded row id (as `u64`) among the same -/// representative rows. -/// - `parent_dir_name` is the `partial_*` directory name extracted from -/// `aux_path` and used only as a final lexicographic tie-breaker. -/// -/// This helper reads exactly one row per non-empty partition (the first row in -/// that partition) and never scans entire shards. -async fn compute_shard_content_key( - sched: &std::sync::Arc, - _store: &lance_io::object_store::ObjectStore, - aux_path: &object_store::path::Path, -) -> Result<(u32, u64, String)> { - let fh = sched - .open_file(aux_path, &CachedFileSize::unknown()) - .await?; - let reader = V2Reader::try_open( - fh, - None, - Arc::default(), - &lance_core::cache::LanceCache::no_cache(), - V2ReaderOptions::default(), - ) - .await?; - - // Locate the ROW_ID_FIELD column to decode fragment / row ids. - let schema_arrow: ArrowSchema = reader.schema().as_ref().into(); - let row_id_idx = schema_arrow - .fields - .iter() - .position(|f| f.name() == ROW_ID_FIELD.name()) - .ok_or_else(|| Error::index("ROW_ID_FIELD missing in auxiliary shard".to_string()))?; - - // Read IVF lengths from the global buffer. - let ivf_idx: u32 = reader - .metadata() - .file_schema - .metadata - .get(IVF_METADATA_KEY) - .ok_or_else(|| Error::index("IVF meta missing".to_string()))? - .parse() - .map_err(|_| Error::index("IVF index parse error".to_string()))?; - let bytes = reader.read_global_buffer(ivf_idx).await?; - let pb_ivf: pb::Ivf = prost::Message::decode(bytes)?; - let lengths = pb_ivf.lengths; - - let mut min_fragment_id: Option = None; - let mut min_row_id: Option = None; - - let mut offset: usize = 0; - for len in &lengths { - let part_len = *len as usize; - if part_len > 0 { - let mut stream = reader.read_stream( - lance_io::ReadBatchParams::Range(offset..offset + 1), - u32::MAX, - 4, - lance_encoding::decoder::FilterExpression::no_filter(), - )?; - if let Some(batch_res) = stream.next().await { - let batch = batch_res?; - if batch.num_rows() > 0 { - let arr = batch - .column(row_id_idx) - .as_any() - .downcast_ref::() - .ok_or_else(|| { - Error::index( - "ROW_ID_FIELD must be a UInt64 column in auxiliary shard" - .to_string(), - ) - })?; - let row_id_val = arr.value(0); - let frag_id = decode_fragment_id_from_row_id(row_id_val); - min_fragment_id = Some(match min_fragment_id { - Some(cur) => cur.min(frag_id), - None => frag_id, - }); - min_row_id = Some(match min_row_id { - Some(cur) => cur.min(row_id_val), - None => row_id_val, - }); - } - } - } - offset += part_len; - } - - let min_fragment_id = min_fragment_id.unwrap_or(RowAddress::TOMBSTONE_FRAG); - let min_row_id = min_row_id.unwrap_or(RowAddress::TOMBSTONE_ROW); - - let parent_name = { - let parts: Vec<_> = aux_path.parts().collect(); - if parts.len() >= 2 { - parts[parts.len() - 2].as_ref().to_string() - } else { - String::new() - } - }; - - Ok((min_fragment_id, min_row_id, parent_name)) -} - #[derive(Debug)] struct ShardInfo { reader: Arc, lengths: Vec, partition_offsets: Vec, total_rows: usize, - sort_key: (u32, u64, String), } #[derive(Debug)] @@ -775,20 +657,6 @@ pub async fn merge_partial_vector_auxiliary_files( SchedulerConfig::max_bandwidth(object_store), ); - // Compute content-derived sort keys for each shard once while opening the - // auxiliary readers. These keys will be reused both for ordering the - // enumeration of shards and for per-partition writes. - let mut shard_keys: Vec<(object_store::path::Path, (u32, u64, String))> = - Vec::with_capacity(aux_paths.len()); - for aux in aux_paths.into_iter() { - let key = compute_shard_content_key(&sched, object_store, &aux).await?; - shard_keys.push((aux, key)); - } - - // Sort shards by their content-derived keys (min_fragment_id, min_row_id, - // parent_dir_name) to detach from underlying listing order. - shard_keys.sort_by(|a, b| a.1.cmp(&b.1)); - // Track IVF partition count consistency and accumulate lengths per partition let mut nlist_opt: Option = None; let mut accumulated_lengths: Vec = Vec::new(); @@ -799,7 +667,7 @@ pub async fn merge_partial_vector_auxiliary_files( let mut shard_infos: Vec = Vec::new(); // Iterate over each shard auxiliary file and merge its metadata and collect lengths - for (aux, key) in &shard_keys { + for aux in &aux_paths { let fh = sched.open_file(aux, &CachedFileSize::unknown()).await?; let reader = V2Reader::try_open( fh, @@ -1328,14 +1196,9 @@ pub async fn merge_partial_vector_auxiliary_files( lengths, partition_offsets, total_rows: running_offset, - sort_key: key.clone(), }); } - // Re-sort shard_infos using content-derived keys to decouple per-partition - // write ordering from discovery order. - shard_infos.sort_by(|a, b| a.sort_key.cmp(&b.sort_key)); - // Write rows grouped by partition across all shards to ensure contiguous ranges per partition if v2w_opt.is_none() { @@ -1427,7 +1290,6 @@ mod tests { use futures::StreamExt; use lance_arrow::FixedSizeListArrayExt; use lance_core::ROW_ID_FIELD; - use lance_core::utils::address::RowAddress; use lance_file::writer::FileWriterOptions as V2WriterOptions; use lance_io::object_store::ObjectStore; use lance_io::scheduler::{ScanScheduler, SchedulerConfig}; @@ -2059,132 +1921,4 @@ mod tests { let first_partition_ids = &row_ids[..4]; assert_eq!(first_partition_ids, &[0, 1, 1_000, 1_001]); } - - #[tokio::test] - async fn test_merge_content_key_order_invariance() { - // Two partial directories whose content-derived keys - // (min_fragment_id, min_row_id) are identical; ordering is determined - // solely by the parent directory name as a lexicographic tie-breaker. - let object_store = ObjectStore::memory(); - let index_dir = Path::from("index/content_key"); - - let partial_a = index_dir.child("partial_content_a"); - let partial_b = index_dir.child("partial_content_b"); - let aux_a = partial_a.child(INDEX_AUXILIARY_FILE_NAME); - let aux_b = partial_b.child(INDEX_AUXILIARY_FILE_NAME); - - // Equal-length shards so per-partition lengths alone cannot disambiguate - // ordering. - let lengths = vec![2_u32, 2_u32]; - - // PQ parameters shared by both shards. - let nbits = 4_u32; - let num_sub_vectors = 2_usize; - let dimension = 8_usize; - - let num_centroids = 1_usize << nbits; - let num_codebook_vectors = num_centroids * num_sub_vectors; - let total_values = num_codebook_vectors * dimension; - let values = Float32Array::from_iter((0..total_values).map(|v| v as f32)); - let codebook = FixedSizeListArray::try_new_from_values(values, dimension as i32).unwrap(); - - // Use a RowAddress-encoded base so both shards have the same - // (fragment_id, row_offset) for their first row, hence identical - // content-derived numeric keys. - let base_addr: u64 = RowAddress::new_from_parts(1, 5).into(); - - write_pq_partial_aux( - &object_store, - &aux_a, - nbits, - num_sub_vectors, - dimension, - &lengths, - base_addr, - DistanceType::L2, - &codebook, - ) - .await - .unwrap(); - - write_pq_partial_aux( - &object_store, - &aux_b, - nbits, - num_sub_vectors, - dimension, - &lengths, - base_addr, - DistanceType::L2, - &codebook, - ) - .await - .unwrap(); - - // Merge must succeed and produce a unified auxiliary file. - merge_partial_vector_auxiliary_files(&object_store, &index_dir) - .await - .unwrap(); - - let aux_out = index_dir.child(INDEX_AUXILIARY_FILE_NAME); - assert!(object_store.exists(&aux_out).await.unwrap()); - - // Open merged auxiliary file and inspect row id layout. - let sched = ScanScheduler::new( - Arc::new(object_store.clone()), - SchedulerConfig::max_bandwidth(&object_store), - ); - let fh = sched - .open_file(&aux_out, &CachedFileSize::unknown()) - .await - .unwrap(); - let reader = V2Reader::try_open( - fh, - None, - Arc::default(), - &lance_core::cache::LanceCache::no_cache(), - V2ReaderOptions::default(), - ) - .await - .unwrap(); - - let mut stream = reader - .read_stream( - lance_io::ReadBatchParams::RangeFull, - u32::MAX, - 4, - lance_encoding::decoder::FilterExpression::no_filter(), - ) - .unwrap(); - - let mut row_ids = Vec::new(); - while let Some(batch) = stream.next().await { - let batch = batch.unwrap(); - let arr = batch - .column(0) - .as_any() - .downcast_ref::() - .unwrap(); - for i in 0..arr.len() { - row_ids.push(arr.value(i)); - } - } - - // Two shards, each contributing `sum(lengths)` rows. - let expected_total_rows: usize = lengths.iter().map(|v| *v as usize).sum::() * 2; - assert_eq!(row_ids.len(), expected_total_rows); - - let first_partition_rows = lengths[0] as usize * 2; - let (p0, p1) = row_ids.split_at(first_partition_rows); - - let base = base_addr; - // For partition 0 we expect rows from `partial_content_a` first, then - // from `partial_content_b`. - let expected_p0 = vec![base, base + 1, base, base + 1]; - assert_eq!(p0, expected_p0.as_slice()); - - // For partition 1 the pattern continues with offsets +2, +3. - let expected_p1 = vec![base + 2, base + 3, base + 2, base + 3]; - assert_eq!(p1, expected_p1.as_slice()); - } } From c70ee41d804f9a6dfa1b00620e97b8f8cefb5dff Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Fri, 13 Mar 2026 06:35:06 +0800 Subject: [PATCH 016/206] perf(inverted)!: reduce fts indexing time and memory (#6174) image - FTS indexing is ~2.5x faster, this removes merge phase, and produces large partitions directly. - memory footprint is reduced by ~60%, this compresses posting lists while building them, which can save a lot of memory, and reduces fragmented objects in memory. This also bumps the default worker memory budget from 256MiB to 1GiB because we need to produce larger partition directly, but the memory footprint is still much less. This adds a new param `memory_limit` so that users can control how the indexing should work --------- Signed-off-by: BubbleCal Co-authored-by: LuQQiu Co-authored-by: Weston Pace --- python/python/lance/dataset.py | 19 +- python/python/tests/test_scalar_index.py | 9 +- python/src/dataset.rs | 7 +- rust/lance-index/src/scalar/inverted.rs | 1 - .../src/scalar/inverted/builder.rs | 823 +++++++++++++----- .../src/scalar/inverted/encoding.rs | 138 +-- rust/lance-index/src/scalar/inverted/index.rs | 607 ++++++++++--- .../lance-index/src/scalar/inverted/merger.rs | 426 --------- rust/lance-index/src/scalar/inverted/query.rs | 4 +- .../src/scalar/inverted/tokenizer.rs | 86 +- rust/lance/src/dataset/cleanup.rs | 112 +-- 11 files changed, 1286 insertions(+), 946 deletions(-) delete mode 100644 rust/lance-index/src/scalar/inverted/merger.rs diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index fa013d6dcdd..0cb9ba9458c 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -2606,10 +2606,21 @@ def create_scalar_index( query. This will significantly increase the index size. It won't impact the performance of non-phrase queries even if it is set to True. - skip_merge: bool, default False - This is for the ``INVERTED`` index. If True, the index will skip the - partition merge stage after indexing. This can be useful for - distributed/fragment-level indexing where a later merge is desired. + memory_limit: int, optional + This is for the ``INVERTED`` index. Total build-time memory limit in MiB. + If set, Lance divides this budget evenly across the workers. If unset, + the default will be 2 GiB per worker. This parameter is only used for the + current build and is not persisted with the index. + + A larger memory limit will create an index with fewer shards which will + be easier to search so this is a trade-off between build resources and + search cost. + num_workers: int, optional + This is for the ``INVERTED`` index. Number of workers to use for + the current build. The effective worker count is clamped to + ``[1, num_compute_cpus]``. If unset, Lance uses ``num_compute_cpus`` + workers unless ``LANCE_FTS_NUM_SHARDS`` is set. This parameter is + only used for the current build and is not persisted with the index. base_tokenizer: str, default "simple" This is for the ``INVERTED`` index. The base tokenizer to use. The value can be: diff --git a/python/python/tests/test_scalar_index.py b/python/python/tests/test_scalar_index.py index 124a107363b..d00cc7f0533 100644 --- a/python/python/tests/test_scalar_index.py +++ b/python/python/tests/test_scalar_index.py @@ -849,7 +849,12 @@ def test_fts_ngram_tokenizer(tmp_path): def test_fts_stats(dataset): dataset.create_scalar_index( - "doc", index_type="INVERTED", with_position=False, remove_stop_words=True + "doc", + index_type="INVERTED", + with_position=False, + remove_stop_words=True, + memory_limit=4096, + num_workers=2, ) stats = dataset.stats.index_stats("doc_idx") assert stats["index_type"] == "Inverted" @@ -864,6 +869,8 @@ def test_fts_stats(dataset): assert params["stem"] is True assert params["remove_stop_words"] is True assert params["ascii_folding"] is True + assert "memory_limit" not in params + assert "num_workers" not in params def test_fts_score(tmp_path): diff --git a/python/src/dataset.rs b/python/src/dataset.rs index d29b54f84df..d1dcfe5c515 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -1941,8 +1941,11 @@ impl Dataset { if let Some(prefix_only) = kwargs.get_item("prefix_only")? { params = params.ngram_prefix_only(prefix_only.extract()?); } - if let Some(skip_merge) = kwargs.get_item("skip_merge")? { - params = params.skip_merge(skip_merge.extract()?); + if let Some(memory_limit) = kwargs.get_item("memory_limit")? { + params = params.memory_limit_mb(memory_limit.extract()?); + } + if let Some(num_workers) = kwargs.get_item("num_workers")? { + params = params.num_workers(num_workers.extract()?); } } Box::new(params) diff --git a/rust/lance-index/src/scalar/inverted.rs b/rust/lance-index/src/scalar/inverted.rs index 8cb5131e417..313db208039 100644 --- a/rust/lance-index/src/scalar/inverted.rs +++ b/rust/lance-index/src/scalar/inverted.rs @@ -6,7 +6,6 @@ mod encoding; mod index; mod iter; pub mod json; -mod merger; pub mod parser; pub mod query; mod scorer; diff --git a/rust/lance-index/src/scalar/inverted/builder.rs b/rust/lance-index/src/scalar/inverted/builder.rs index b52a656628e..39171d1b720 100644 --- a/rust/lance-index/src/scalar/inverted/builder.rs +++ b/rust/lance-index/src/scalar/inverted/builder.rs @@ -1,15 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use super::{ - InvertedIndexParams, - index::*, - merger::{Merger, PartitionSource, SizeBasedMerger}, -}; +use super::{InvertedIndexParams, index::*}; use crate::scalar::IndexStore; use crate::scalar::inverted::json::JsonTextStream; use crate::scalar::inverted::lance_tokenizer::DocType; use crate::scalar::inverted::tokenizer::lance_tokenizer::LanceTokenizer; +#[cfg(test)] use crate::scalar::lance_format::LanceIndexStore; use crate::vector::graph::OrderedFloat; use crate::{progress::IndexBuildProgress, progress::noop_progress}; @@ -20,13 +17,14 @@ use arrow_schema::{DataType, Field, Schema, SchemaRef}; use bitpacking::{BitPacker, BitPacker4x}; use datafusion::execution::{RecordBatchStream, SendableRecordBatchStream}; use deepsize::DeepSizeOf; +use fst::Streamer; use futures::{Stream, StreamExt, TryStreamExt}; use lance_arrow::json::JSON_EXT_NAME; use lance_arrow::{ARROW_EXT_NAME_KEY, iter_str_array}; use lance_core::cache::LanceCache; -use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu}; +use lance_core::error::LanceOptionExt; +use lance_core::utils::tokio::{IO_CORE_RESERVATION, get_num_compute_intensive_cpus, spawn_cpu}; use lance_core::{Error, ROW_ID, ROW_ID_FIELD, Result}; -use lance_core::{error::LanceOptionExt, utils::tempfile::TempDir}; use lance_io::object_store::ObjectStore; use object_store::path::Path; use smallvec::SmallVec; @@ -44,32 +42,92 @@ use tracing::instrument; // WARNING: changing this value will break the compatibility with existing indexes pub const BLOCK_SIZE: usize = BitPacker4x::BLOCK_LEN; -// the number of shards to split the indexing work, -// the indexing process would spawn `LANCE_FTS_NUM_SHARDS` workers to build FTS, -// higher for faster indexing performance, but more memory usage, -// it's `the number of compute intensive CPUs` by default +// The default number of workers to use for FTS builds. +// By default this is roughly `num_cpus / 2`, but it can be overridden +// with `LANCE_FTS_NUM_SHARDS`. pub static LANCE_FTS_NUM_SHARDS: LazyLock = LazyLock::new(|| { std::env::var("LANCE_FTS_NUM_SHARDS") - .unwrap_or_else(|_| get_num_compute_intensive_cpus().to_string()) + .unwrap_or_else(|_| default_num_workers().to_string()) .parse() .expect("failed to parse LANCE_FTS_NUM_SHARDS") }); -// the partition size limit in MiB (uncompressed format) -// higher for better indexing & query performance, but more memory usage, +// The default per-worker memory limit in MiB for FTS builds. pub static LANCE_FTS_PARTITION_SIZE: LazyLock = LazyLock::new(|| { std::env::var("LANCE_FTS_PARTITION_SIZE") - .unwrap_or_else(|_| "256".to_string()) + .unwrap_or_else(|_| "2048".to_string()) .parse() .expect("failed to parse LANCE_FTS_PARTITION_SIZE") }); -// the target size of partition after merging in MiB (uncompressed format) -pub static LANCE_FTS_TARGET_SIZE: LazyLock = LazyLock::new(|| { - std::env::var("LANCE_FTS_TARGET_SIZE") - .unwrap_or_else(|_| "4096".to_string()) +static LANCE_FTS_WRITE_QUEUE_SIZE: LazyLock = LazyLock::new(|| { + std::env::var("LANCE_FTS_WRITE_QUEUE_SIZE") + .unwrap_or_else(|_| "1".to_string()) .parse() - .expect("failed to parse LANCE_FTS_TARGET_SIZE") + .expect("failed to parse LANCE_FTS_WRITE_QUEUE_SIZE") }); +fn default_num_workers() -> usize { + let total_cpus = get_num_compute_intensive_cpus() + *IO_CORE_RESERVATION; + std::cmp::max(1, total_cpus / 2) +} + +fn resolve_num_workers(params: &InvertedIndexParams) -> usize { + let max_workers = get_num_compute_intensive_cpus().max(1); + params + .num_workers + .unwrap_or(*LANCE_FTS_NUM_SHARDS) + .clamp(1, max_workers) +} + +fn resolve_worker_memory_limit_bytes(params: &InvertedIndexParams, num_workers: usize) -> u64 { + let default_worker_memory_limit_bytes = *LANCE_FTS_PARTITION_SIZE << 20; + params + .memory_limit_mb + .map(|memory_limit_mb| (memory_limit_mb << 20) / num_workers as u64) + .unwrap_or(default_worker_memory_limit_bytes) +} + +fn group_tail_partitions( + tails: Vec, + worker_memory_limit_bytes: u64, +) -> Vec> { + let mut groups = Vec::new(); + let mut current_group = Vec::new(); + let mut current_memory_size = 0u64; + + for tail in tails { + if !current_group.is_empty() + && current_memory_size.saturating_add(tail.memory_size_bytes) + > worker_memory_limit_bytes + { + groups.push(current_group); + current_group = Vec::new(); + current_memory_size = 0; + } + current_memory_size = current_memory_size.saturating_add(tail.memory_size_bytes); + current_group.push(tail); + } + + if !current_group.is_empty() { + groups.push(current_group); + } + + groups +} + +fn merge_tail_partition_group(group: Vec) -> Result { + let mut group = group.into_iter(); + let mut merged = group + .next() + .ok_or_else(|| { + Error::invalid_input("cannot merge an empty tail partition group".to_owned()) + })? + .builder; + for tail in group { + merged.merge_from(tail.builder)?; + } + Ok(merged) +} + #[derive(Debug)] pub struct InvertedIndexBuilder { params: InvertedIndexParams, @@ -77,9 +135,7 @@ pub struct InvertedIndexBuilder { new_partitions: Vec, fragment_mask: Option, token_set_format: TokenSetFormat, - _tmpdir: TempDir, - local_store: Arc, - src_store: Arc, + src_store: Option>, progress: Arc, } @@ -111,20 +167,11 @@ impl InvertedIndexBuilder { token_set_format: TokenSetFormat, fragment_mask: Option, ) -> Self { - let tmpdir = TempDir::default(); - let local_store = Arc::new(LanceIndexStore::new( - ObjectStore::local().into(), - tmpdir.obj_path(), - Arc::new(LanceCache::no_cache()), - )); - let src_store = store.unwrap_or_else(|| local_store.clone()); Self { params, partitions, new_partitions: Vec::new(), - _tmpdir: tmpdir, - local_store, - src_store, + src_store: store, token_set_format, fragment_mask, progress: noop_progress(), @@ -157,26 +204,33 @@ impl InvertedIndexBuilder { self.progress .stage_start("tokenize_docs", None, "rows") .await?; - self.update_index(new_data).await?; + self.update_index(new_data, dest_store).await?; self.progress.stage_complete("tokenize_docs").await?; self.write(dest_store).await?; Ok(()) } #[instrument(level = "debug", skip_all)] - async fn update_index(&mut self, stream: SendableRecordBatchStream) -> Result<()> { - let num_workers = *LANCE_FTS_NUM_SHARDS; + async fn update_index( + &mut self, + stream: SendableRecordBatchStream, + dest_store: &dyn IndexStore, + ) -> Result<()> { + let num_workers = resolve_num_workers(&self.params); let tokenizer = self.params.build()?; let with_position = self.params.with_position; + let worker_memory_limit_bytes = + resolve_worker_memory_limit_bytes(&self.params, num_workers); let next_id = self.partitions.iter().map(|id| id + 1).max().unwrap_or(0); let id_alloc = Arc::new(AtomicU64::new(next_id)); let tokenized_count = Arc::new(AtomicU64::new(0)); let (sender, receiver) = async_channel::bounded(num_workers); + let (builder_tx, builder_rx) = async_channel::bounded::(1); let mut index_tasks = Vec::with_capacity(num_workers); for _ in 0..num_workers { - let store = self.local_store.clone(); let tokenizer = tokenizer.clone(); let receiver: async_channel::Receiver = receiver.clone(); + let builder_tx = builder_tx.clone(); let id_alloc = id_alloc.clone(); let progress = self.progress.clone(); let fragment_mask = self.fragment_mask; @@ -184,12 +238,13 @@ impl InvertedIndexBuilder { let tokenized_count = tokenized_count.clone(); index_tasks.push(tokio::task::spawn(async move { let mut worker = IndexWorker::new( - store, tokenizer, + builder_tx, with_position, id_alloc, fragment_mask, token_set_format, + worker_memory_limit_bytes, ) .await?; while let Ok(batch) = receiver.recv().await { @@ -205,48 +260,79 @@ impl InvertedIndexBuilder { worker.finish().await })); } - // Keep the channel lifetime tied to the worker tasks so senders observe - // worker exits instead of blocking on an orphaned receiver handle. - drop(receiver); - - let mut stream = Box::pin(stream); - log::info!("indexing FTS with {} workers", num_workers); - - let mut last_num_rows = 0; - let mut total_num_rows = 0; - let start = std::time::Instant::now(); - while let Some(batch) = stream.try_next().await? { - let num_rows = batch.num_rows(); - - if sender.send(batch).await.is_err() { - // this only happens if all workers have existed, - // so we don't return the send error here, - // avoiding hiding the real error from workers. - break; + let writer = async { + while let Ok(mut builder) = builder_rx.recv().await { + builder.write(dest_store).await?; } + Result::Ok(()) + }; - total_num_rows += num_rows; - if total_num_rows >= last_num_rows + 1_000_000 { - log::debug!( - "indexed {} documents, elapsed: {:?}, speed: {}rows/s", - total_num_rows, - start.elapsed(), - total_num_rows as f32 / start.elapsed().as_secs_f32() - ); - last_num_rows = total_num_rows; + let index_build = async { + // Keep the channel lifetime tied to the worker tasks so senders observe + // worker exits instead of blocking on an orphaned receiver handle. + drop(receiver); + + let mut stream = Box::pin(stream); + log::info!("indexing FTS with {} workers", num_workers); + + let mut last_num_rows = 0; + let mut total_num_rows = 0; + let start = std::time::Instant::now(); + while let Some(batch) = stream.try_next().await? { + let num_rows = batch.num_rows(); + + if sender.send(batch).await.is_err() { + // this only happens if all workers have exited, + // so we don't return the send error here, + // avoiding hiding the real error from workers. + break; + } + + total_num_rows += num_rows; + if total_num_rows >= last_num_rows + 1_000_000 { + log::debug!( + "indexed {} documents, elapsed: {:?}, speed: {}rows/s", + total_num_rows, + start.elapsed(), + total_num_rows as f32 / start.elapsed().as_secs_f32() + ); + last_num_rows = total_num_rows; + } } - } - // drop the sender to stop receivers - drop(stream); - drop(sender); - log::info!("dispatching elapsed: {:?}", start.elapsed()); - - // wait for the workers to finish - let start = std::time::Instant::now(); - for index_task in index_tasks { - self.new_partitions.extend(index_task.await??); - } - log::info!("wait workers indexing elapsed: {:?}", start.elapsed()); + // drop the sender to stop receivers + drop(stream); + drop(sender); + log::info!("dispatching elapsed: {:?}", start.elapsed()); + + // wait for the workers to finish + let start = std::time::Instant::now(); + let mut tail_partitions = Vec::new(); + for index_task in index_tasks { + let output = index_task.await??; + self.new_partitions.extend(output.partitions); + if let Some(tail_partition) = output.tail_partition { + tail_partitions.push(tail_partition); + } + } + let merge_tasks = group_tail_partitions(tail_partitions, worker_memory_limit_bytes) + .into_iter() + .map(|group| spawn_cpu(move || merge_tail_partition_group(group))) + .collect::>(); + for merge_task in merge_tasks { + let builder = merge_task.await?; + self.new_partitions.push(builder.id()); + builder_tx.send(builder).await.map_err(|err| { + Error::execution(format!( + "failed to send merged tail partition to writer: {err}" + )) + })?; + } + drop(builder_tx); + log::info!("wait workers indexing elapsed: {:?}", start.elapsed()); + Result::Ok(()) + }; + + let (_, _) = tokio::try_join!(writer, index_build)?; Ok(()) } @@ -354,84 +440,47 @@ impl InvertedIndexBuilder { } async fn write(&self, dest_store: &dyn IndexStore) -> Result<()> { - if self.params.skip_merge { - let mut partitions = - Vec::with_capacity(self.partitions.len() + self.new_partitions.len()); - partitions.extend_from_slice(&self.partitions); - partitions.extend_from_slice(&self.new_partitions); - partitions.sort_unstable(); - - self.progress - .stage_start( - "copy_partitions", - Some(partitions.len() as u64), - "partitions", - ) - .await?; - let mut copied = 0; - for part in self.partitions.iter() { - self.src_store - .copy_index_file(&token_file_path(*part), dest_store) - .await?; - self.src_store - .copy_index_file(&posting_file_path(*part), dest_store) - .await?; - self.src_store - .copy_index_file(&doc_file_path(*part), dest_store) - .await?; - copied += 1; - self.progress - .stage_progress("copy_partitions", copied) - .await?; - } - for part in self.new_partitions.iter() { - self.local_store - .copy_index_file(&token_file_path(*part), dest_store) - .await?; - self.local_store - .copy_index_file(&posting_file_path(*part), dest_store) - .await?; - self.local_store - .copy_index_file(&doc_file_path(*part), dest_store) - .await?; - copied += 1; - self.progress - .stage_progress("copy_partitions", copied) - .await?; - } - self.progress.stage_complete("copy_partitions").await?; - - self.write_metadata_with_progress(dest_store, &partitions) - .await?; - return Ok(()); - } + let mut partitions = Vec::with_capacity(self.partitions.len() + self.new_partitions.len()); + partitions.extend_from_slice(&self.partitions); + partitions.extend_from_slice(&self.new_partitions); + partitions.sort_unstable(); - let partitions = self - .partitions - .iter() - .map(|part| PartitionSource::new(self.src_store.clone(), *part)) - .chain( - self.new_partitions - .iter() - .map(|part| PartitionSource::new(self.local_store.clone(), *part)), - ) - .collect::>(); self.progress .stage_start( - "merge_partitions", + "copy_partitions", Some(partitions.len() as u64), "partitions", ) .await?; - let mut merger = SizeBasedMerger::new( - dest_store, - partitions, - *LANCE_FTS_TARGET_SIZE << 20, - self.token_set_format, - self.progress.clone(), - ); - let partitions = merger.merge().await?; - self.progress.stage_complete("merge_partitions").await?; + let mut copied = 0; + for part in self.partitions.iter() { + self.src_store + .as_ref() + .expect("existing partitions require a source store") + .copy_index_file(&token_file_path(*part), dest_store) + .await?; + self.src_store + .as_ref() + .expect("existing partitions require a source store") + .copy_index_file(&posting_file_path(*part), dest_store) + .await?; + self.src_store + .as_ref() + .expect("existing partitions require a source store") + .copy_index_file(&doc_file_path(*part), dest_store) + .await?; + copied += 1; + self.progress + .stage_progress("copy_partitions", copied) + .await?; + } + for _part in self.new_partitions.iter() { + copied += 1; + self.progress + .stage_progress("copy_partitions", copied) + .await?; + } + self.progress.stage_complete("copy_partitions").await?; self.write_metadata_with_progress(dest_store, &partitions) .await?; @@ -514,6 +563,74 @@ impl InnerBuilder { Ok(()) } + pub fn merge_from(&mut self, other: Self) -> Result<()> { + let Self { + id: _, + with_position, + token_set_format, + tokens, + posting_lists, + docs, + } = other; + + if self.with_position != with_position { + return Err(Error::index(format!( + "cannot merge partitions with mismatched positions settings: {} vs {}", + self.with_position, with_position + ))); + } + if self.token_set_format != token_set_format { + return Err(Error::index(format!( + "cannot merge partitions with mismatched token set formats: {:?} vs {:?}", + self.token_set_format, token_set_format + ))); + } + + let mut token_id_map = vec![u32::MAX; posting_lists.len()]; + match tokens.tokens { + TokenMap::HashMap(map) => { + for (token, token_id) in map { + let new_token_id = self.tokens.get_or_add(token.as_str()); + token_id_map[token_id as usize] = new_token_id; + } + } + TokenMap::Fst(map) => { + let mut stream = map.stream(); + while let Some((token, token_id)) = stream.next() { + let new_token_id = self + .tokens + .get_or_add(String::from_utf8_lossy(token).as_ref()); + token_id_map[token_id as usize] = new_token_id; + } + } + } + + let doc_id_offset = self.docs.len() as u32; + for (row_id, num_tokens) in docs.iter() { + self.docs.append(*row_id, *num_tokens); + } + self.posting_lists + .resize_with(self.tokens.len(), || PostingListBuilder::new(with_position)); + + for (token_id, posting_list) in posting_lists.into_iter().enumerate() { + if posting_list.is_empty() { + continue; + } + let new_token_id = token_id_map[token_id]; + debug_assert_ne!(new_token_id, u32::MAX); + let merged_posting = &mut self.posting_lists[new_token_id as usize]; + for (doc_id, freq, positions) in posting_list.iter() { + let positions = match positions { + Some(positions) => PositionRecorder::Position(positions.into()), + None => PositionRecorder::Count(freq), + }; + merged_posting.add(doc_id_offset + doc_id, positions); + } + } + + Ok(()) + } + pub async fn write(&mut self, store: &dyn IndexStore) -> Result<()> { let docs = Arc::new(std::mem::take(&mut self.docs)); self.write_posting_lists(store, docs.clone()).await?; @@ -546,12 +663,12 @@ impl InnerBuilder { let schema = inverted_list_schema(self.with_position); let docs_for_batches = docs.clone(); let schema_for_batches = schema.clone(); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let (tx, rx) = async_channel::bounded(*LANCE_FTS_WRITE_QUEUE_SIZE); let producer = spawn_cpu(move || { for posting_list in posting_lists { let batch = posting_list .to_batch_with_docs(&docs_for_batches, schema_for_batches.clone())?; - if let Err(err) = tx.send(batch) { + if let Err(err) = tx.send_blocking(batch) { return Err(Error::execution(format!( "failed to send posting list batch to writer: {err}" ))); @@ -562,7 +679,7 @@ impl InnerBuilder { let mut write_duration = std::time::Duration::ZERO; let mut num_posting_lists = 0; - while let Some(batch) = rx.recv().await { + while let Ok(batch) = rx.recv().await { num_posting_lists += 1; let start = std::time::Instant::now(); if let Err(err) = writer.write_record_batch(batch).await { @@ -616,13 +733,14 @@ impl InnerBuilder { } struct IndexWorker { - store: Arc, tokenizer: Box, + builder_tx: async_channel::Sender, id_alloc: Arc, builder: InnerBuilder, partitions: Vec, schema: SchemaRef, - estimated_size: u64, + memory_size: u64, + worker_memory_limit_bytes: u64, total_doc_length: usize, fragment_mask: Option, token_set_format: TokenSetFormat, @@ -632,20 +750,55 @@ struct IndexWorker { last_unique_token_count: usize, } +struct TailPartition { + builder: InnerBuilder, + memory_size_bytes: u64, +} + +struct WorkerOutput { + partitions: Vec, + tail_partition: Option, +} + impl IndexWorker { + fn posting_lists_overhead_size(&self) -> u64 { + (self.builder.posting_lists.capacity() * std::mem::size_of::()) as u64 + } + + fn adjust_tracked_value(tracked: &mut u64, old: u64, new: u64) { + if new >= old { + *tracked += new - old; + } else { + *tracked -= old - new; + } + } + + fn adjust_tracked_memory_size(&mut self, old_memory_size: u64, new_memory_size: u64) { + Self::adjust_tracked_value(&mut self.memory_size, old_memory_size, new_memory_size); + } + + fn apply_delta(total: &mut u64, delta: i64) { + if delta >= 0 { + *total += delta as u64; + } else { + *total -= (-delta) as u64; + } + } + async fn new( - store: Arc, tokenizer: Box, + builder_tx: async_channel::Sender, with_position: bool, id_alloc: Arc, fragment_mask: Option, token_set_format: TokenSetFormat, + worker_memory_limit_bytes: u64, ) -> Result { let schema = inverted_list_schema(with_position); Ok(Self { - store, tokenizer, + builder_tx, builder: InnerBuilder::new( id_alloc.fetch_add(1, std::sync::atomic::Ordering::Relaxed) | fragment_mask.unwrap_or(0), @@ -655,7 +808,8 @@ impl IndexWorker { partitions: Vec::new(), id_alloc, schema, - estimated_size: 0, + memory_size: 0, + worker_memory_limit_bytes, total_doc_length: 0, fragment_mask, token_set_format, @@ -680,6 +834,8 @@ impl IndexWorker { let with_position = self.has_position(); for (doc, row_id) in docs { + let builder_was_empty = self.builder.docs.is_empty(); + let old_token_memory_size = self.builder.tokens.memory_size() as u64; let mut token_num: u32 = 0; if with_position { if self.token_occurrences.capacity() < self.last_unique_token_count { @@ -715,27 +871,53 @@ impl IndexWorker { token_num += 1; } } + self.adjust_tracked_memory_size( + old_token_memory_size, + self.builder.tokens.memory_size() as u64, + ); + + let old_posting_lists_overhead_size = self.posting_lists_overhead_size(); self.builder .posting_lists .resize_with(self.builder.tokens.len(), || { PostingListBuilder::new(with_position) }); + let new_posting_lists_overhead_size = self.posting_lists_overhead_size(); + Self::adjust_tracked_value( + &mut self.memory_size, + old_posting_lists_overhead_size, + new_posting_lists_overhead_size, + ); + + let old_doc_memory_size = self.builder.docs.memory_size() as u64; let doc_id = self.builder.docs.append(row_id, token_num); + self.adjust_tracked_memory_size( + old_doc_memory_size, + self.builder.docs.memory_size() as u64, + ); self.total_doc_length += doc.len(); if with_position { - let unique_tokens = self.token_occurrences.len(); - for (token_id, term_positions) in self.token_occurrences.drain() { - let posting_list = &mut self.builder.posting_lists[token_id as usize]; - - let old_size = posting_list.size(); - posting_list.add(doc_id, term_positions); - let new_size = posting_list.size(); - self.estimated_size += new_size - old_size; + let mut token_occurrences = std::mem::take(&mut self.token_occurrences); + let unique_tokens = token_occurrences.len(); + let mut posting_memory_delta = 0i64; + for (token_id, term_positions) in token_occurrences.drain() { + let (old_posting_memory_size, new_posting_memory_size) = { + let posting_list = &mut self.builder.posting_lists[token_id as usize]; + let old_posting_memory_size = posting_list.size(); + posting_list.add(doc_id, term_positions); + let new_posting_memory_size = posting_list.size(); + (old_posting_memory_size, new_posting_memory_size) + }; + posting_memory_delta += + new_posting_memory_size as i64 - old_posting_memory_size as i64; } + Self::apply_delta(&mut self.memory_size, posting_memory_delta); + self.token_occurrences = token_occurrences; self.last_unique_token_count = unique_tokens; } else if token_num > 0 { self.token_ids.sort_unstable(); + let mut posting_memory_delta = 0i64; let mut iter = self.token_ids.iter(); let mut current = *iter.next().unwrap(); let mut count = 1u32; @@ -745,25 +927,41 @@ impl IndexWorker { continue; } - let posting_list = &mut self.builder.posting_lists[current as usize]; - let old_size = posting_list.size(); - posting_list.add(doc_id, PositionRecorder::Count(count)); - let new_size = posting_list.size(); - self.estimated_size += new_size - old_size; + let (old_posting_memory_size, new_posting_memory_size) = { + let posting_list = &mut self.builder.posting_lists[current as usize]; + let old_posting_memory_size = posting_list.size(); + posting_list.add(doc_id, PositionRecorder::Count(count)); + let new_posting_memory_size = posting_list.size(); + (old_posting_memory_size, new_posting_memory_size) + }; + posting_memory_delta += + new_posting_memory_size as i64 - old_posting_memory_size as i64; current = token_id; count = 1; } - let posting_list = &mut self.builder.posting_lists[current as usize]; - let old_size = posting_list.size(); - posting_list.add(doc_id, PositionRecorder::Count(count)); - let new_size = posting_list.size(); - self.estimated_size += new_size - old_size; + let (old_posting_memory_size, new_posting_memory_size) = { + let posting_list = &mut self.builder.posting_lists[current as usize]; + let old_posting_memory_size = posting_list.size(); + posting_list.add(doc_id, PositionRecorder::Count(count)); + let new_posting_memory_size = posting_list.size(); + (old_posting_memory_size, new_posting_memory_size) + }; + posting_memory_delta += + new_posting_memory_size as i64 - old_posting_memory_size as i64; + Self::apply_delta(&mut self.memory_size, posting_memory_delta); } self.last_token_count = token_num as usize; + if self.builder.docs.len() == 1 && self.memory_size > self.worker_memory_limit_bytes { + return Err(Error::invalid_input(format!( + "single document row_id={} exceeds worker memory limit: {} > {} bytes", + row_id, self.memory_size, self.worker_memory_limit_bytes + ))); + } + if self.builder.docs.len() as u32 == u32::MAX - || self.estimated_size >= *LANCE_FTS_PARTITION_SIZE << 20 + || (!builder_was_empty && self.memory_size >= self.worker_memory_limit_bytes) { self.flush().await?; } @@ -779,12 +977,12 @@ impl IndexWorker { } log::info!( - "flushing posting lists, estimated size: {} MiB", - self.estimated_size / (1024 * 1024) + "flushing posting lists, memory size: {} MiB", + self.memory_size / (1024 * 1024) ); - self.estimated_size = 0; + self.memory_size = 0; let with_position = self.has_position(); - let mut builder = std::mem::replace( + let builder = std::mem::replace( &mut self.builder, InnerBuilder::new( self.id_alloc @@ -794,16 +992,30 @@ impl IndexWorker { self.token_set_format, ), ); - builder.write(self.store.as_ref()).await?; - self.partitions.push(builder.id()); + let written_partition_id = builder.id(); + self.builder_tx.send(builder).await.map_err(|err| { + Error::execution(format!( + "failed to send finalized partition {} to writer: {err}", + written_partition_id + )) + })?; + self.partitions.push(written_partition_id); Ok(()) } - async fn finish(mut self) -> Result> { - if !self.builder.tokens.is_empty() { - self.flush().await?; - } - Ok(self.partitions) + async fn finish(self) -> Result { + let tail_partition = if self.builder.tokens.is_empty() { + None + } else { + Some(TailPartition { + builder: self.builder, + memory_size_bytes: self.memory_size, + }) + }; + Ok(WorkerOutput { + partitions: self.partitions, + tail_partition, + }) } } @@ -1421,7 +1633,7 @@ mod tests { } #[tokio::test] - async fn test_skip_merge_writes_partitions_as_is() -> Result<()> { + async fn test_build_only_path_writes_partitions_as_is() -> Result<()> { let src_dir = TempDir::default(); let dest_dir = TempDir::default(); let src_store = Arc::new(LanceIndexStore::new( @@ -1439,40 +1651,63 @@ mod tests { let tokenizer = params.build()?; let token_set_format = TokenSetFormat::default(); let id_alloc = Arc::new(AtomicU64::new(0)); + let (builder_tx, builder_rx) = async_channel::bounded(2); let mut worker1 = IndexWorker::new( - src_store.clone(), tokenizer.clone(), + builder_tx.clone(), params.with_position, id_alloc.clone(), None, token_set_format, + u64::MAX, ) .await?; worker1 .process_batch(make_doc_batch("hello world", 0)) .await?; - let mut partitions = worker1.finish().await?; + let output1 = worker1.finish().await?; + let mut partitions = output1.partitions; + if let Some(mut tail_partition) = output1.tail_partition { + partitions.push(tail_partition.builder.id()); + tail_partition.builder.write(src_store.as_ref()).await?; + } + while let Ok(mut builder) = builder_rx.try_recv() { + builder.write(src_store.as_ref()).await?; + } let mut worker2 = IndexWorker::new( - src_store.clone(), tokenizer.clone(), + builder_tx, params.with_position, id_alloc.clone(), None, token_set_format, + u64::MAX, ) .await?; worker2 .process_batch(make_doc_batch("goodbye world", 1)) .await?; - partitions.extend(worker2.finish().await?); + let output2 = worker2.finish().await?; + partitions.extend(output2.partitions); + if let Some(mut tail_partition) = output2.tail_partition { + partitions.push(tail_partition.builder.id()); + tail_partition.builder.write(src_store.as_ref()).await?; + } partitions.sort_unstable(); assert_eq!(partitions.len(), 2); assert_ne!(partitions[0], partitions[1]); + // Drain finalized builders and write them to the source store, mirroring the + // direct-to-destination writer task used by the production indexing path. + // The bounded channel size keeps these writes backpressured in normal execution. + while let Ok(mut builder) = builder_rx.try_recv() { + builder.write(src_store.as_ref()).await?; + } + let builder = InvertedIndexBuilder::from_existing_index( - InvertedIndexParams::default().skip_merge(true), + InvertedIndexParams::default(), Some(src_store.clone()), partitions.clone(), token_set_format, @@ -1617,9 +1852,8 @@ mod tests { let stream = Box::pin(stream); let progress = Arc::new(RecordingProgress::default()); - let mut builder = - InvertedIndexBuilder::new(InvertedIndexParams::default().skip_merge(true)) - .with_progress(progress.clone()); + let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default()) + .with_progress(progress.clone()); builder.update(stream, store.as_ref()).await?; let events = progress.events.lock().await.clone(); @@ -1692,15 +1926,196 @@ mod tests { ); assert!( !tags.iter().any(|e| e == "start:merge_partitions"), - "merge_partitions should not run in skip_merge mode" + "merge_partitions should not run in the build-only path" ); Ok(()) } + #[tokio::test] + async fn test_builder_default_path_skips_merge_stage() -> Result<()> { + let index_dir = TempDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + index_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + let batch = make_doc_batch("hello world", 0); + let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)])); + let stream = Box::pin(stream); + + let progress = Arc::new(RecordingProgress::default()); + let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default()) + .with_progress(progress.clone()); + builder.update(stream, store.as_ref()).await?; + + let tags = progress + .events + .lock() + .await + .iter() + .map(|(kind, stage, _)| format!("{kind}:{stage}")) + .collect::>(); + + assert!( + tags.iter().any(|e| e == "start:copy_partitions"), + "default path should copy finalized partitions" + ); + assert!( + !tags.iter().any(|e| e == "start:merge_partitions"), + "default path should not run merge_partitions" + ); + Ok(()) + } + + #[tokio::test] + async fn test_worker_memory_limit_rejects_single_large_doc() { + let index_dir = TempDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + index_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + let batch = make_doc_batch("hello world", 42); + let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)])); + let stream = Box::pin(stream); + + let mut builder = + InvertedIndexBuilder::new(InvertedIndexParams::default().memory_limit_mb(0)); + let err = builder + .update(stream, store.as_ref()) + .await + .expect_err("single doc should exceed zero worker memory limit"); + assert!( + err.to_string().contains("row_id=42"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_resolve_worker_memory_limit_uses_default_when_unset() { + let params = InvertedIndexParams::default(); + assert_eq!( + resolve_worker_memory_limit_bytes(¶ms, 8), + *LANCE_FTS_PARTITION_SIZE << 20 + ); + } + + #[test] + fn test_resolve_num_workers_uses_default_when_unset() { + let expected = default_num_workers().clamp(1, get_num_compute_intensive_cpus().max(1)); + assert_eq!( + resolve_num_workers(&InvertedIndexParams::default()), + expected + ); + } + + #[test] + fn test_resolve_num_workers_clamps_requested_value() { + let max_workers = get_num_compute_intensive_cpus().max(1); + assert_eq!( + resolve_num_workers(&InvertedIndexParams::default().num_workers(0)), + 1 + ); + assert_eq!( + resolve_num_workers(&InvertedIndexParams::default().num_workers(max_workers + 10)), + max_workers + ); + } + + #[test] + fn test_resolve_worker_memory_limit_splits_total_memory_limit() { + let params = InvertedIndexParams::default().memory_limit_mb(4096); + assert_eq!(resolve_worker_memory_limit_bytes(¶ms, 16), 256 << 20); + } + + #[test] + fn test_group_tail_partitions_uses_worker_memory_limit() { + let groups = group_tail_partitions( + vec![ + TailPartition { + builder: InnerBuilder::new(0, false, TokenSetFormat::default()), + memory_size_bytes: 40, + }, + TailPartition { + builder: InnerBuilder::new(1, false, TokenSetFormat::default()), + memory_size_bytes: 50, + }, + TailPartition { + builder: InnerBuilder::new(2, false, TokenSetFormat::default()), + memory_size_bytes: 30, + }, + ], + 80, + ); + + let grouped_ids = groups + .iter() + .map(|group| { + group + .iter() + .map(|tail| tail.builder.id()) + .collect::>() + }) + .collect::>(); + assert_eq!(grouped_ids, vec![vec![0], vec![1, 2]]); + } + + #[test] + fn test_merge_tail_partition_group_combines_tail_builders() -> Result<()> { + let mut first = InnerBuilder::new(0, false, TokenSetFormat::default()); + let hello = first.tokens.add("hello".to_owned()); + first + .posting_lists + .resize_with(first.tokens.len(), || PostingListBuilder::new(false)); + let first_doc = first.docs.append(10, 1); + first.posting_lists[hello as usize].add(first_doc, PositionRecorder::Count(1)); + + let mut second = InnerBuilder::new(1, false, TokenSetFormat::default()); + let world = second.tokens.add("world".to_owned()); + second + .posting_lists + .resize_with(second.tokens.len(), || PostingListBuilder::new(false)); + let second_doc = second.docs.append(20, 2); + second.posting_lists[world as usize].add(second_doc, PositionRecorder::Count(2)); + + let merged = merge_tail_partition_group(vec![ + TailPartition { + builder: first, + memory_size_bytes: 10, + }, + TailPartition { + builder: second, + memory_size_bytes: 20, + }, + ])?; + + assert_eq!(merged.id(), 0); + assert_eq!(merged.docs.len(), 2); + assert_eq!(merged.tokens.len(), 2); + assert_eq!(merged.posting_lists.len(), 2); + assert_eq!( + merged.posting_lists[merged.tokens.get("hello").unwrap() as usize].len(), + 1 + ); + assert_eq!( + merged.posting_lists[merged.tokens.get("world").unwrap() as usize].len(), + 1 + ); + Ok(()) + } + #[tokio::test] async fn test_update_index_returns_worker_error_when_workers_exit_during_dispatch() { let num_batches = (*LANCE_FTS_NUM_SHARDS * 2 + 1) as u64; + let index_dir = TempDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + index_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); let schema = make_doc_batch("hello world", 0).schema(); let stream = RecordBatchStreamAdapter::new( schema, @@ -1708,14 +2123,16 @@ mod tests { ); let stream = Box::pin(stream); - let mut builder = - InvertedIndexBuilder::new(InvertedIndexParams::default().skip_merge(true)) - .with_progress(Arc::new(FailingProgress)); + let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default()) + .with_progress(Arc::new(FailingProgress)); - let result = tokio::time::timeout(Duration::from_secs(5), builder.update_index(stream)) - .await - .expect("update_index should not hang") - .expect_err("worker failure should be returned"); + let result = tokio::time::timeout( + Duration::from_secs(5), + builder.update_index(stream, store.as_ref()), + ) + .await + .expect("update_index should not hang") + .expect_err("worker failure should be returned"); assert!( result.to_string().contains("injected progress failure"), diff --git a/rust/lance-index/src/scalar/inverted/encoding.rs b/rust/lance-index/src/scalar/inverted/encoding.rs index 57bc80cda66..9d299d516df 100644 --- a/rust/lance-index/src/scalar/inverted/encoding.rs +++ b/rust/lance-index/src/scalar/inverted/encoding.rs @@ -4,9 +4,7 @@ use std::io::Write; use super::builder::BLOCK_SIZE; -use arrow::array::{AsArray, LargeBinaryBuilder}; -use arrow::array::{ListBuilder, UInt32Builder}; -use arrow_array::{Array, ListArray}; +use arrow::array::LargeBinaryBuilder; use bitpacking::{BitPacker, BitPacker4x}; use lance_core::Result; @@ -28,6 +26,7 @@ use lance_core::Result; // compress the posting list to multiple blocks of fixed number of elements (BLOCK_SIZE), // returns a LargeBinaryArray, where each binary is a compressed block (128 row ids + 128 frequencies) +#[cfg(test)] pub fn compress_posting_list<'a>( length: usize, doc_ids: impl Iterator, @@ -90,95 +89,34 @@ pub fn compress_posting_list<'a>( Ok(builder.finish()) } -pub fn compress_posting_list_with_scores<'a, F>( - length: usize, - doc_ids: impl Iterator, - frequencies: impl Iterator, - mut score_for: F, - idf_scale: f32, -) -> Result<(arrow::array::LargeBinaryArray, f32)> -where - F: FnMut(u32, u32) -> f32, -{ - // `length` comes from posting list size; zero would produce an invalid block - // (a max-score header with no doc/frequency data) and readers assume > 0 docs. - debug_assert!(length > 0); - if length < BLOCK_SIZE { - let mut builder = LargeBinaryBuilder::with_capacity(1, length * 4 * 2 + 1); - let mut max_score = f32::MIN; - let mut doc_id_buffer = Vec::with_capacity(length); - let mut freq_buffer = Vec::with_capacity(length); - for (doc_id, freq) in std::iter::zip(doc_ids, frequencies) { - let doc_id = *doc_id; - let freq = *freq; - doc_id_buffer.push(doc_id); - freq_buffer.push(freq); - let score = score_for(doc_id, freq); - if score > max_score { - max_score = score; - } - } - let max_score = max_score * idf_scale; - let _ = builder.write(max_score.to_le_bytes().as_ref())?; - compress_remainder(&doc_id_buffer, &mut builder)?; - compress_remainder(&freq_buffer, &mut builder)?; - builder.append_value(""); - return Ok((builder.finish(), max_score)); - } - - let mut builder = LargeBinaryBuilder::with_capacity(length.div_ceil(BLOCK_SIZE), length * 3); +pub fn encode_full_posting_block_into( + doc_ids: &[u32], + frequencies: &[u32], + block: &mut Vec, +) -> Result<()> { + debug_assert_eq!(doc_ids.len(), BLOCK_SIZE); + debug_assert_eq!(frequencies.len(), BLOCK_SIZE); + block.extend_from_slice(&0f32.to_le_bytes()); let mut buffer = [0u8; BLOCK_SIZE * 4 + 5]; - let mut doc_id_buffer = Vec::with_capacity(BLOCK_SIZE); - let mut freq_buffer = Vec::with_capacity(BLOCK_SIZE); - let mut max_score = f32::MIN; - let mut block_max_score = f32::MIN; - for (doc_id, freq) in std::iter::zip(doc_ids, frequencies) { - let doc_id = *doc_id; - let freq = *freq; - doc_id_buffer.push(doc_id); - freq_buffer.push(freq); - - let score = score_for(doc_id, freq); - if score > block_max_score { - block_max_score = score; - } - - if doc_id_buffer.len() < BLOCK_SIZE { - continue; - } - - let block_score = block_max_score * idf_scale; - if block_score > max_score { - max_score = block_score; - } - let _ = builder.write(block_score.to_le_bytes().as_ref())?; - compress_sorted_block(&doc_id_buffer, &mut buffer, &mut builder)?; - compress_block(&freq_buffer, &mut buffer, &mut builder)?; - builder.append_value(""); - doc_id_buffer.clear(); - freq_buffer.clear(); - block_max_score = f32::MIN; - } + compress_sorted_block(doc_ids, &mut buffer, block)?; + compress_block(frequencies, &mut buffer, block)?; + Ok(()) +} - if !doc_id_buffer.is_empty() { - let block_score = block_max_score * idf_scale; - if block_score > max_score { - max_score = block_score; - } - let _ = builder.write(block_score.to_le_bytes().as_ref())?; - compress_remainder(&doc_id_buffer, &mut builder)?; - compress_remainder(&freq_buffer, &mut builder)?; - builder.append_value(""); - } - Ok((builder.finish(), max_score)) +pub fn encode_remainder_posting_block_into( + doc_ids: &[u32], + frequencies: &[u32], + block: &mut Vec, +) -> Result<()> { + debug_assert_eq!(doc_ids.len(), frequencies.len()); + block.extend_from_slice(&0f32.to_le_bytes()); + compress_remainder(doc_ids, block)?; + compress_remainder(frequencies, block)?; + Ok(()) } #[inline] -fn compress_sorted_block( - data: &[u32], - buffer: &mut [u8], - builder: &mut LargeBinaryBuilder, -) -> Result<()> { +fn compress_sorted_block(data: &[u32], buffer: &mut [u8], builder: &mut impl Write) -> Result<()> { let compressor = BitPacker4x::new(); let num_bits = compressor.num_bits_sorted(data[0], data); let num_bytes = compressor.compress_sorted(data[0], data, buffer, num_bits); @@ -189,7 +127,7 @@ fn compress_sorted_block( } #[inline] -fn compress_block(data: &[u32], buffer: &mut [u8], builder: &mut LargeBinaryBuilder) -> Result<()> { +fn compress_block(data: &[u32], buffer: &mut [u8], builder: &mut impl Write) -> Result<()> { let compressor = BitPacker4x::new(); let num_bits = compressor.num_bits(data); let num_bytes = compressor.compress(data, buffer, num_bits); @@ -199,7 +137,7 @@ fn compress_block(data: &[u32], buffer: &mut [u8], builder: &mut LargeBinaryBuil } #[inline] -fn compress_remainder(data: &[u32], builder: &mut LargeBinaryBuilder) -> Result<()> { +fn compress_remainder(data: &[u32], builder: &mut impl Write) -> Result<()> { for value in data.iter() { let _ = builder.write(value.to_le_bytes().as_ref())?; } @@ -236,7 +174,7 @@ pub fn compress_positions(positions: &[u32]) -> Result Vec< positions } -// decompress the positions list from a ListArray of binary -// to a ListArray of u32 -#[allow(dead_code)] -pub fn decompress_positions_list(compressed: &ListArray) -> Result { - let mut builder = ListBuilder::with_capacity(UInt32Builder::new(), compressed.len()); - for i in 0..compressed.len() { - let compressed = compressed.value(i); - let compressed = compressed.as_binary::(); - let positions = decompress_positions(compressed); - builder.values().append_slice(&positions); - builder.append(true); - } - Ok(builder.finish()) -} - pub fn read_num_positions(compressed: &arrow::array::LargeBinaryArray) -> u32 { u32::from_le_bytes(compressed.value(0).try_into().unwrap()) } @@ -322,6 +245,11 @@ pub fn decompress_posting_remainder( decompress_remainder(&block[n * 4..], n, frequencies); } +pub fn decode_full_posting_block(block: &[u8], doc_ids: &mut Vec, frequencies: &mut Vec) { + let mut buffer = [0u32; BLOCK_SIZE]; + decompress_posting_block(block, &mut buffer, doc_ids, frequencies); +} + pub fn decompress_sorted_block( block: &[u8], buffer: &mut [u32; BLOCK_SIZE], diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index 0e3fd1b8798..19cb68ae82c 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -19,7 +19,7 @@ use arrow::{ array::{ AsArray, LargeBinaryBuilder, ListBuilder, StringBuilder, UInt32Builder, UInt64Builder, }, - buffer::OffsetBuffer, + buffer::{Buffer, OffsetBuffer}, }; use arrow::{buffer::ScalarBuffer, datatypes::UInt32Type}; use arrow_array::{ @@ -38,12 +38,9 @@ use lance_arrow::{RecordBatchExt, iter_str_array}; use lance_core::cache::{CacheKey, LanceCache, WeakLanceCache}; use lance_core::error::{DataFusionResult, LanceOptionExt}; use lance_core::utils::mask::{RowAddrMask, RowAddrTreeMap}; +use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu}; use lance_core::utils::tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS}; use lance_core::{Error, ROW_ID, ROW_ID_FIELD, Result}; -use lance_core::{ - container::list::ExpLinkedList, - utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu}, -}; use roaring::RoaringBitmap; use std::sync::LazyLock; use tokio::task::spawn_blocking; @@ -61,7 +58,6 @@ use super::{ }; use super::{ builder::{InnerBuilder, PositionRecorder}, - encoding::{compress_posting_list, compress_posting_list_with_scores}, iter::CompressedPostingListIterator, }; use super::{encoding::compress_positions, iter::PostingListIterator}; @@ -1205,6 +1201,19 @@ impl TokenSet { pub fn next_id(&self) -> u32 { self.next_id } + + pub(crate) fn memory_size(&self) -> usize { + match &self.tokens { + TokenMap::HashMap(map) => { + self.total_length + + map.capacity() + * (std::mem::size_of::() + + std::mem::size_of::() + + std::mem::size_of::()) + } + TokenMap::Fst(map) => map.as_fst().size(), + } + } } pub struct PostingListReader { @@ -1899,61 +1908,285 @@ impl CompressedPostingList { } } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct EncodedBlocks { + offsets: Vec, + bytes: Vec, +} + +impl EncodedBlocks { + fn len(&self) -> usize { + self.offsets.len() + } + + fn size(&self) -> usize { + self.offsets.capacity() * std::mem::size_of::() + self.bytes.capacity() + } + + fn push_full_block(&mut self, doc_ids: &[u32], frequencies: &[u32]) -> Result { + let start = self.bytes.len(); + self.offsets.push(start as u32); + super::encoding::encode_full_posting_block_into(doc_ids, frequencies, &mut self.bytes)?; + Ok(self.bytes.len() - start) + } + + fn block(&self, index: usize) -> &[u8] { + let (start, end) = self.block_range(index); + &self.bytes[start..end] + } + + fn block_range(&self, index: usize) -> (usize, usize) { + let start = self.offsets[index] as usize; + let end = self + .offsets + .get(index + 1) + .map(|offset| *offset as usize) + .unwrap_or(self.bytes.len()); + (start, end) + } + + fn set_block_score(&mut self, index: usize, score: f32) { + let (start, _) = self.block_range(index); + self.bytes[start..start + 4].copy_from_slice(&score.to_le_bytes()); + } + + fn append_remainder_block(&mut self, doc_ids: &[u32], frequencies: &[u32]) -> Result<()> { + self.offsets.push(self.bytes.len() as u32); + super::encoding::encode_remainder_posting_block_into(doc_ids, frequencies, &mut self.bytes) + } + + fn into_array(mut self) -> LargeBinaryArray { + let mut offsets = Vec::with_capacity(self.offsets.len() + 1); + offsets.extend(self.offsets.into_iter().map(i64::from)); + offsets.push(self.bytes.len() as i64); + LargeBinaryArray::new( + OffsetBuffer::new(ScalarBuffer::from(offsets)), + Buffer::from_vec(std::mem::take(&mut self.bytes)), + None, + ) + } + + fn iter(&self) -> impl Iterator { + (0..self.len()).map(|index| self.block(index)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct CompressedDocPositions { + blocks: Vec>, +} + +impl CompressedDocPositions { + fn from_positions(positions: &[u32]) -> Result { + let compressed = compress_positions(positions)?; + let blocks = compressed + .iter() + .map(|block| { + block + .map(|value| value.to_vec()) + .ok_or_else(|| Error::index("null compressed position block".to_owned())) + }) + .collect::>>()?; + Ok(Self { blocks }) + } + + fn size(&self) -> usize { + self.blocks + .iter() + .map(|block| block.capacity()) + .sum::() + + self.blocks.capacity() * std::mem::size_of::>() + } + + fn decompress(&self) -> Vec { + let mut builder = LargeBinaryBuilder::with_capacity(self.blocks.len(), 0); + for block in &self.blocks { + builder.append_value(block); + } + super::encoding::decompress_positions(&builder.finish()) + } +} + #[derive(Debug)] pub struct PostingListBuilder { - pub doc_ids: ExpLinkedList, - pub frequencies: ExpLinkedList, - pub positions: Option, + encoded_blocks: Option>, + positions: Option>, + tail_entries: Vec, + memory_size_bytes: u32, + len: u32, } impl PostingListBuilder { pub fn size(&self) -> u64 { - (std::mem::size_of::() * self.doc_ids.len() - + std::mem::size_of::() * self.frequencies.len() - + self - .positions - .as_ref() - .map(|positions| positions.size()) - .unwrap_or(0)) as u64 + self.memory_size_bytes as u64 } pub fn has_positions(&self) -> bool { self.positions.is_some() } - pub fn new(with_position: bool) -> Self { + pub fn new(_with_position: bool) -> Self { Self { - doc_ids: ExpLinkedList::new().with_capacity_limit(128), - frequencies: ExpLinkedList::new().with_capacity_limit(128), - positions: with_position.then(PositionBuilder::new), + encoded_blocks: None, + positions: None, + tail_entries: Vec::new(), + len: 0, + memory_size_bytes: 0, } } pub fn len(&self) -> usize { - self.doc_ids.len() + self.len as usize } pub fn is_empty(&self) -> bool { - self.len() == 0 + self.len == 0 } - pub fn iter(&self) -> impl Iterator)> { - self.doc_ids - .iter() - .zip(self.frequencies.iter()) - .enumerate() - .map(|(idx, (doc_id, freq))| { - let positions = self.positions.as_ref().map(|positions| positions.get(idx)); - (doc_id, freq, positions) - }) + pub fn iter(&self) -> std::vec::IntoIter<(u32, u32, Option>)> { + self.collect_entries().into_iter() } pub fn add(&mut self, doc_id: u32, term_positions: PositionRecorder) { - self.doc_ids.push(doc_id); - self.frequencies.push(term_positions.len()); - if let Some(positions) = self.positions.as_mut() { - positions.push(term_positions.into_vec()); + let tail_entries_capacity_before = self.tail_entries.capacity(); + self.tail_entries + .push(RawDocInfo::new(doc_id, term_positions.len())); + let tail_entries_capacity_after = self.tail_entries.capacity(); + if tail_entries_capacity_after > tail_entries_capacity_before { + self.add_memory_bytes( + (tail_entries_capacity_after - tail_entries_capacity_before) + * std::mem::size_of::(), + ); + } + if let PositionRecorder::Position(positions_in_doc) = term_positions { + if self.positions.is_none() { + self.positions = Some(Vec::new()); + self.add_memory_bytes(std::mem::size_of::>()); + } + let positions = self.positions.as_mut().expect("positions must exist"); + let positions_capacity_before = positions.capacity(); + let compressed_doc = CompressedDocPositions::from_positions(&positions_in_doc) + .expect("position compression should succeed"); + let compressed_doc_size = compressed_doc.size(); + positions.push(compressed_doc); + let positions_capacity_after = positions.capacity(); + let mut positions_delta = compressed_doc_size; + if positions_capacity_after > positions_capacity_before { + positions_delta += (positions_capacity_after - positions_capacity_before) + * std::mem::size_of::(); + } + self.add_memory_bytes(positions_delta); + } + self.len += 1; + + if self.tail_entries.len() == BLOCK_SIZE { + self.flush_tail_block() + .expect("posting list block compression should succeed"); + } + } + + fn collect_entries(&self) -> Vec<(u32, u32, Option>)> { + let mut entries = Vec::with_capacity(self.len()); + let mut position_index = 0usize; + + if let Some(encoded_blocks) = self.encoded_blocks.as_deref() { + for block in encoded_blocks.iter() { + let mut doc_ids = Vec::with_capacity(BLOCK_SIZE); + let mut frequencies = Vec::with_capacity(BLOCK_SIZE); + super::encoding::decode_full_posting_block(block, &mut doc_ids, &mut frequencies); + for (doc_id, frequency) in doc_ids.into_iter().zip(frequencies.into_iter()) { + let positions = self.positions.as_ref().map(|positions| { + let decompressed = positions[position_index].decompress(); + position_index += 1; + decompressed + }); + entries.push((doc_id, frequency, positions)); + } + } + } + + for entry in &self.tail_entries { + let positions = self.positions.as_ref().map(|positions| { + let decompressed = positions[position_index].decompress(); + position_index += 1; + decompressed + }); + entries.push((entry.doc_id, entry.frequency, positions)); + } + + entries + } + + fn encoded_blocks_mut(&mut self) -> &mut EncodedBlocks { + if self.encoded_blocks.is_none() { + self.encoded_blocks = Some(Box::default()); + self.add_memory_bytes(std::mem::size_of::()); + } + self.encoded_blocks + .as_deref_mut() + .expect("encoded blocks must exist") + } + + fn flush_tail_block(&mut self) -> Result<()> { + if self.tail_entries.is_empty() { + return Ok(()); + } + debug_assert_eq!(self.tail_entries.len(), BLOCK_SIZE); + let encoded_blocks_size_before = self + .encoded_blocks + .as_ref() + .map(|encoded_blocks| encoded_blocks.size()) + .unwrap_or(0usize); + { + let mut doc_ids = [0u32; BLOCK_SIZE]; + let mut frequencies = [0u32; BLOCK_SIZE]; + for (index, entry) in self.tail_entries.iter().enumerate() { + doc_ids[index] = entry.doc_id; + frequencies[index] = entry.frequency; + } + self.encoded_blocks_mut() + .push_full_block(&doc_ids, &frequencies)?; + } + let encoded_blocks_size_after = self + .encoded_blocks + .as_ref() + .map(|encoded_blocks| encoded_blocks.size()) + .unwrap_or(0usize); + if encoded_blocks_size_after > encoded_blocks_size_before { + self.add_memory_bytes(encoded_blocks_size_after - encoded_blocks_size_before); + } + self.tail_entries.clear(); + Ok(()) + } + + fn add_memory_bytes(&mut self, bytes: usize) { + self.memory_size_bytes = self + .memory_size_bytes + .checked_add( + u32::try_from(bytes).expect("posting list memory size delta overflowed u32"), + ) + .expect("posting list memory size overflowed u32"); + } + + fn build_positions_column( + positions: Option<&[CompressedDocPositions]>, + len: usize, + ) -> Result> { + let Some(positions) = positions else { + return Ok(None); + }; + + let mut position_builder = + ListBuilder::new(ListBuilder::with_capacity(LargeBinaryBuilder::new(), len)); + let docs_builder = position_builder.values(); + for compressed_doc in positions.iter() { + for block in &compressed_doc.blocks { + docs_builder.values().append_value(block); + } + docs_builder.append(true); } + position_builder.append(true); + Ok(Some(Arc::new(position_builder.finish()) as ArrayRef)) } fn build_batch( @@ -1977,135 +2210,190 @@ impl PostingListBuilder { ))) as ArrayRef, ]; - if let Some(positions) = self.positions.as_ref() { - let mut position_builder = ListBuilder::new(ListBuilder::with_capacity( - LargeBinaryBuilder::new(), - length, - )); - for index in 0..length { - let positions_in_doc = positions.get(index); - let compressed = compress_positions(positions_in_doc)?; - let inner_builder = position_builder.values(); - inner_builder.append_value(compressed.into_iter()); - } - position_builder.append(true); - let position_col = position_builder.finish(); - columns.push(Arc::new(position_col)); + if let Some(position_col) = Self::build_positions_column(self.positions.as_deref(), length)? + { + columns.push(position_col); } let batch = RecordBatch::try_new(schema, columns)?; Ok(batch) } - // assume the posting list is sorted by doc id + fn extend_tail_components( + tail_entries: &[RawDocInfo], + doc_ids: &mut Vec, + frequencies: &mut Vec, + ) { + doc_ids.clear(); + frequencies.clear(); + doc_ids.extend(tail_entries.iter().map(|entry| entry.doc_id)); + frequencies.extend(tail_entries.iter().map(|entry| entry.frequency)); + } + + fn build_compressed_with_scores_from_parts( + length: usize, + mut encoded_blocks: EncodedBlocks, + tail_entries: &[RawDocInfo], + docs: &DocSet, + ) -> Result<(LargeBinaryArray, f32)> { + let avgdl = docs.average_length(); + let idf_scale = idf(length, docs.len()) * (K1 + 1.0); + let mut max_score = f32::MIN; + let mut doc_ids = Vec::with_capacity(BLOCK_SIZE); + let mut frequencies = Vec::with_capacity(BLOCK_SIZE); + + for index in 0..encoded_blocks.len() { + let block = encoded_blocks.block(index); + doc_ids.clear(); + frequencies.clear(); + super::encoding::decode_full_posting_block(block, &mut doc_ids, &mut frequencies); + let block_score = compute_block_score( + docs, + avgdl, + idf_scale, + doc_ids.iter().copied(), + frequencies.iter().copied(), + ); + max_score = max_score.max(block_score); + encoded_blocks.set_block_score(index, block_score); + } + + if !tail_entries.is_empty() { + Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies); + let block_score = compute_block_score( + docs, + avgdl, + idf_scale, + doc_ids.iter().copied(), + frequencies.iter().copied(), + ); + max_score = max_score.max(block_score); + encoded_blocks.append_remainder_block(doc_ids.as_slice(), frequencies.as_slice())?; + encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score); + } + + Ok((encoded_blocks.into_array(), max_score)) + } + + fn build_compressed_with_block_scores_from_parts( + mut encoded_blocks: EncodedBlocks, + tail_entries: &[RawDocInfo], + mut block_max_scores: impl Iterator, + ) -> Result<(LargeBinaryArray, f32)> { + let mut max_score = f32::MIN; + let mut doc_ids = Vec::with_capacity(BLOCK_SIZE); + let mut frequencies = Vec::with_capacity(BLOCK_SIZE); + + for index in 0..encoded_blocks.len() { + let block_score = block_max_scores + .next() + .ok_or_else(|| Error::index("missing block max score".to_owned()))?; + max_score = max_score.max(block_score); + encoded_blocks.set_block_score(index, block_score); + } + + if !tail_entries.is_empty() { + let block_score = block_max_scores + .next() + .ok_or_else(|| Error::index("missing tail block max score".to_owned()))?; + max_score = max_score.max(block_score); + Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies); + encoded_blocks.append_remainder_block(doc_ids.as_slice(), frequencies.as_slice())?; + encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score); + } + + Ok((encoded_blocks.into_array(), max_score)) + } + pub fn to_batch(self, block_max_scores: Vec) -> Result { - let max_score = block_max_scores.iter().copied().fold(f32::MIN, f32::max); let schema = inverted_list_schema(self.has_positions()); - let compressed = compress_posting_list( - self.doc_ids.len(), - self.doc_ids.iter(), - self.frequencies.iter(), + let Self { + encoded_blocks, + positions, + tail_entries, + len, + .. + } = self; + let (compressed, max_score) = Self::build_compressed_with_block_scores_from_parts( + encoded_blocks + .map(|encoded_blocks| *encoded_blocks) + .unwrap_or_default(), + tail_entries.as_slice(), block_max_scores.into_iter(), )?; - self.build_batch(compressed, max_score, schema) + let builder = Self { + encoded_blocks: None, + positions, + tail_entries: Vec::new(), + memory_size_bytes: 0, + len, + }; + builder.build_batch(compressed, max_score, schema) } pub fn to_batch_with_docs(self, docs: &DocSet, schema: SchemaRef) -> Result { - let length = self.len(); - let avgdl = docs.average_length(); - let idf_scale = idf(length, docs.len()) * (K1 + 1.0); - let (compressed, max_score) = compress_posting_list_with_scores( + let Self { + encoded_blocks, + positions, + tail_entries, + len, + .. + } = self; + let length = len as usize; + let (compressed, max_score) = Self::build_compressed_with_scores_from_parts( length, - self.doc_ids.iter(), - self.frequencies.iter(), - |doc_id, freq| { - let doc_norm = K1 * (1.0 - B + B * docs.num_tokens(doc_id) as f32 / avgdl); - let freq = freq as f32; - freq / (freq + doc_norm) - }, - idf_scale, + encoded_blocks + .map(|encoded_blocks| *encoded_blocks) + .unwrap_or_default(), + tail_entries.as_slice(), + docs, )?; - self.build_batch(compressed, max_score, schema) + let builder = Self { + encoded_blocks: None, + positions, + tail_entries: Vec::new(), + memory_size_bytes: 0, + len, + }; + builder.build_batch(compressed, max_score, schema) } pub fn remap(&mut self, removed: &[u32]) { let mut cursor = 0; - let mut new_doc_ids = ExpLinkedList::with_capacity(self.len()); - let mut new_frequencies = ExpLinkedList::with_capacity(self.len()); - let mut new_positions = self.positions.as_mut().map(|_| PositionBuilder::new()); - for (&doc_id, &freq, positions) in self.iter() { + let mut new_builder = Self::new(self.has_positions()); + for (doc_id, freq, positions) in self.iter() { while cursor < removed.len() && removed[cursor] < doc_id { cursor += 1; } if cursor < removed.len() && removed[cursor] == doc_id { - // this doc is removed continue; } - // there are cursor removed docs before this doc - // so we need to shift the doc id - new_doc_ids.push(doc_id - cursor as u32); - new_frequencies.push(freq); - if let Some(new_positions) = new_positions.as_mut() { - new_positions.push(positions.unwrap().to_vec()); - } - } - - self.doc_ids = new_doc_ids; - self.frequencies = new_frequencies; - self.positions = new_positions; - } -} - -#[derive(Debug, Clone, DeepSizeOf)] -pub struct PositionBuilder { - positions: Vec, - offsets: Vec, -} - -impl Default for PositionBuilder { - fn default() -> Self { - Self::new() - } -} - -impl PositionBuilder { - pub fn new() -> Self { - Self { - positions: Vec::new(), - offsets: vec![0], + let positions = match positions { + Some(positions) => PositionRecorder::Position(positions.into()), + None => PositionRecorder::Count(freq), + }; + new_builder.add(doc_id - cursor as u32, positions); } - } - - pub fn size(&self) -> usize { - std::mem::size_of::() * self.positions.len() - + std::mem::size_of::() * self.offsets.len() - } - pub fn total_len(&self) -> usize { - self.positions.len() - } - - pub fn push(&mut self, positions: Vec) { - self.positions.extend(positions); - self.offsets.push(self.positions.len() as i32); - } - - pub fn get(&self, i: usize) -> &[u32] { - let start = self.offsets[i] as usize; - let end = self.offsets[i + 1] as usize; - &self.positions[start..end] + *self = new_builder; } } -impl From>> for PositionBuilder { - fn from(positions: Vec>) -> Self { - let mut builder = Self::new(); - builder.offsets.reserve(positions.len()); - for pos in positions { - builder.push(pos); - } - builder +fn compute_block_score( + docs: &DocSet, + avgdl: f32, + idf_scale: f32, + doc_ids: impl Iterator, + frequencies: impl Iterator, +) -> f32 { + let mut block_max_score = f32::MIN; + for (doc_id, freq) in doc_ids.zip(frequencies) { + let doc_norm = K1 * (1.0 - B + B * docs.num_tokens(doc_id) as f32 / avgdl); + let freq = freq as f32; + let score = freq / (freq + doc_norm); + block_max_score = block_max_score.max(score); } + block_max_score * idf_scale } #[derive(Debug, Clone, DeepSizeOf, Copy)] @@ -2446,6 +2734,12 @@ impl DocSet { self.total_tokens += num_tokens as u64; self.row_ids.len() as u32 - 1 } + + pub(crate) fn memory_size(&self) -> usize { + self.row_ids.capacity() * std::mem::size_of::() + + self.num_tokens.capacity() * std::mem::size_of::() + + self.inv.capacity() * std::mem::size_of::<(u64, u32)>() + } } pub fn flat_full_text_search( @@ -2832,8 +3126,9 @@ mod tests { for i in 0..n - removed.len() { expected.add(i as u32, PositionRecorder::Count(1)); } - assert_eq!(builder.doc_ids, expected.doc_ids); - assert_eq!(builder.frequencies, expected.frequencies); + let expected_entries = expected.iter().collect::>(); + let actual_entries = builder.iter().collect::>(); + assert_eq!(actual_entries, expected_entries); // BLOCK_SIZE + 3 elements should be reduced to BLOCK_SIZE + 1, // there are still 2 blocks. @@ -2849,17 +3144,55 @@ mod tests { assert!( doc_ids .iter() - .zip(expected.doc_ids.iter()) + .zip(expected_entries.iter().map(|(doc_id, _, _)| doc_id)) .all(|(a, b)| a == b) ); assert!( freqs .iter() - .zip(expected.frequencies.iter()) + .zip(expected_entries.iter().map(|(_, freq, _)| freq)) .all(|(a, b)| a == b) ); } + #[test] + fn test_posting_builder_size_tracking_matches_structure() { + fn tracked_memory_size(builder: &PostingListBuilder) -> u64 { + let encoded_blocks_size = builder + .encoded_blocks + .iter() + .map(|encoded_blocks| std::mem::size_of::() + encoded_blocks.size()) + .sum::(); + let positions_size = builder + .positions + .as_ref() + .map(|positions| positions.iter().map(CompressedDocPositions::size).sum()) + .unwrap_or(0usize); + let positions_capacity = builder + .positions + .as_ref() + .map(|positions| { + std::mem::size_of::>() + + positions.capacity() * std::mem::size_of::() + }) + .unwrap_or(0usize); + (encoded_blocks_size + + builder.tail_entries.capacity() * std::mem::size_of::() + + positions_capacity + + positions_size) as u64 + } + + let mut builder = PostingListBuilder::new(true); + for doc_id in 0..(BLOCK_SIZE + 5) as u32 { + builder.add( + doc_id, + PositionRecorder::Position(smallvec::smallvec![1, 3, 5]), + ); + } + + assert_eq!(builder.size(), tracked_memory_size(&builder)); + } + #[test] fn test_posting_list_batch_matches_docset_scoring() { let mut docs = DocSet::default(); diff --git a/rust/lance-index/src/scalar/inverted/merger.rs b/rust/lance-index/src/scalar/inverted/merger.rs deleted file mode 100644 index 500adacac93..00000000000 --- a/rust/lance-index/src/scalar/inverted/merger.rs +++ /dev/null @@ -1,426 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -use fst::Streamer; -use futures::{StreamExt, TryStreamExt, stream}; -use lance_core::{Error, Result, cache::LanceCache, utils::tokio::get_num_compute_intensive_cpus}; -use std::sync::Arc; - -use crate::progress::IndexBuildProgress; -use crate::scalar::IndexStore; - -use super::{ - InvertedPartition, PostingListBuilder, TokenMap, TokenSetFormat, - builder::{InnerBuilder, PositionRecorder, doc_file_path, posting_file_path, token_file_path}, -}; - -pub trait Merger { - // Merge the partitions and write new partitions, - // the new partitions are returned. - // This method streams partitions with bounded buffering to avoid - // loading all partitions into memory at once. - async fn merge(&mut self) -> Result>; -} - -#[derive(Debug, Clone)] -pub(super) struct PartitionSource { - store: std::sync::Arc, - id: u64, -} - -impl PartitionSource { - pub(super) fn new(store: std::sync::Arc, id: u64) -> Self { - Self { store, id } - } - - async fn load( - &self, - cache: &LanceCache, - token_set_format: TokenSetFormat, - ) -> Result { - InvertedPartition::load(self.store.clone(), self.id, None, cache, token_set_format).await - } -} - -// A merger that merges partitions based on their size, -// it would read the posting lists for each token from -// the partitions and write them to a new partition, -// until the size of the new partition reaches the target size. -pub struct SizeBasedMerger<'a> { - dest_store: &'a dyn IndexStore, - input: Vec, - with_position: Option, - target_size: u64, - token_set_format: TokenSetFormat, - progress: Arc, - builder: Option, - next_id: u64, - partitions: Vec, -} - -impl<'a> SizeBasedMerger<'a> { - // Create a new SizeBasedMerger with the target size, - // the size is compressed size in byte. - // Typically, just set the size to the memory limit, - // because less partitions means faster query. - pub fn new( - dest_store: &'a dyn IndexStore, - input: Vec, - target_size: u64, - token_set_format: TokenSetFormat, - progress: Arc, - ) -> Self { - let max_id = input.iter().map(|p| p.id).max().unwrap_or(0); - - Self { - dest_store, - input, - with_position: None, - target_size, - token_set_format, - progress, - builder: None, - next_id: max_id + 1, - partitions: Vec::new(), - } - } - - async fn flush(&mut self) -> Result<()> { - let Some(builder) = self.builder.as_mut() else { - return Ok(()); - }; - - if !builder.tokens.is_empty() { - log::info!("flushing partition {}", builder.id()); - let start = std::time::Instant::now(); - builder.write(self.dest_store).await?; - log::info!( - "flushed partition {} in {:?}", - builder.id(), - start.elapsed() - ); - self.partitions.push(builder.id()); - let with_position = self.with_position.expect("with_position must be set"); - let next_id = self.next_id; - self.builder = Some(InnerBuilder::new( - next_id, - with_position, - self.token_set_format, - )); - self.next_id += 1; - } - Ok(()) - } - - fn ensure_builder(&mut self, part: &InvertedPartition) -> Result<()> { - let with_position = part.inverted_list.has_positions(); - match self.with_position { - Some(existing) => { - if existing != with_position { - return Err(Error::index( - "partition position settings do not match".to_string(), - )); - } - } - None => { - self.with_position = Some(with_position); - } - } - - if self.builder.is_none() { - let with_position = self.with_position.expect("with_position must be set"); - self.builder = Some(InnerBuilder::new( - self.next_id, - with_position, - self.token_set_format, - )); - self.next_id += 1; - } - Ok(()) - } - - async fn merge_partition( - &mut self, - part: InvertedPartition, - estimated_size: &mut u64, - ) -> Result<()> { - self.ensure_builder(&part)?; - - { - let builder = self.builder.as_ref().expect("builder must exist"); - if builder.docs.len() + part.docs.len() > u32::MAX as usize - || *estimated_size >= self.target_size - { - self.flush().await?; - *estimated_size = 0; - self.ensure_builder(&part)?; - } - } - - let builder = self.builder.as_mut().expect("builder must exist"); - let mut token_id_map = vec![u32::MAX; part.tokens.len()]; - match &part.tokens.tokens { - TokenMap::HashMap(map) => { - for (token, token_id) in map.iter() { - let new_token_id = builder.tokens.get_or_add(token.as_str()); - let index = *token_id as usize; - debug_assert!(index < token_id_map.len()); - token_id_map[index] = new_token_id; - } - } - TokenMap::Fst(map) => { - let mut stream = map.stream(); - while let Some((token, token_id)) = stream.next() { - let token_id = token_id as u32; - let token = String::from_utf8_lossy(token); - let new_token_id = builder.tokens.get_or_add(token.as_ref()); - let index = token_id as usize; - debug_assert!(index < token_id_map.len()); - token_id_map[index] = new_token_id; - } - } - } - let doc_id_offset = builder.docs.len() as u32; - for (row_id, num_tokens) in part.docs.iter() { - builder.docs.append(*row_id, *num_tokens); - } - builder.posting_lists.resize_with(builder.tokens.len(), || { - PostingListBuilder::new(part.inverted_list.has_positions()) - }); - - let postings = part - .inverted_list - .read_batch(part.inverted_list.has_positions()) - .await?; - for token_id in 0..part.tokens.len() as u32 { - let posting_list = part - .inverted_list - .posting_list_from_batch(&postings.slice(token_id as usize, 1), token_id)?; - let new_token_id = token_id_map[token_id as usize]; - debug_assert_ne!(new_token_id, u32::MAX); - let builder = &mut builder.posting_lists[new_token_id as usize]; - let old_size = builder.size(); - for (doc_id, freq, positions) in posting_list.iter() { - let new_doc_id = doc_id_offset + doc_id as u32; - let positions = match positions { - Some(positions) => PositionRecorder::Position(positions.collect()), - None => PositionRecorder::Count(freq), - }; - builder.add(new_doc_id, positions); - } - let new_size = builder.size(); - *estimated_size += new_size - old_size; - } - Ok(()) - } -} - -impl Merger for SizeBasedMerger<'_> { - async fn merge(&mut self) -> Result> { - if self.input.len() <= 1 { - let mut completed = 0; - for part in self.input.iter() { - part.store - .copy_index_file(&token_file_path(part.id), self.dest_store) - .await?; - part.store - .copy_index_file(&posting_file_path(part.id), self.dest_store) - .await?; - part.store - .copy_index_file(&doc_file_path(part.id), self.dest_store) - .await?; - completed += 1; - self.progress - .stage_progress("merge_partitions", completed) - .await?; - } - - return Ok(self.input.iter().map(|p| p.id).collect()); - } - - // for token set, union the tokens, - // for doc set, concatenate the row ids, assign the doc id to offset + doc_id - // for posting list, concatenate the posting lists - log::info!( - "merging {} partitions with target size {} MiB", - self.input.len(), - self.target_size / 1024 / 1024 - ); - let mut estimated_size = 0; - let start = std::time::Instant::now(); - let parts = std::mem::take(&mut self.input); - let num_parts = parts.len(); - let buffer_size = std::cmp::max( - 1, - std::cmp::min(get_num_compute_intensive_cpus(), num_parts), - ); - let cache = LanceCache::no_cache(); - let token_set_format = self.token_set_format; - let mut stream = stream::iter(parts.into_iter().map(|part| { - let cache = cache.clone(); - tokio::task::spawn(async move { part.load(&cache, token_set_format).await }) - })) - .buffered(buffer_size); - - let mut idx = 0; - while let Some(part) = stream.try_next().await? { - let part = part?; - idx += 1; - self.merge_partition(part, &mut estimated_size).await?; - self.progress - .stage_progress("merge_partitions", idx as u64) - .await?; - log::info!( - "merged {}/{} partitions in {:?}", - idx, - num_parts, - start.elapsed() - ); - } - - self.flush().await?; - Ok(self.partitions.clone()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::metrics::NoOpMetricsCollector; - use crate::scalar::lance_format::LanceIndexStore; - use lance_core::cache::LanceCache; - use lance_core::utils::tempfile::TempObjDir; - use lance_io::object_store::ObjectStore; - use std::sync::Arc; - - #[tokio::test] - async fn test_merge_reuses_token_ids_for_shared_tokens() -> Result<()> { - let src_dir = TempObjDir::default(); - let dest_dir = TempObjDir::default(); - let src_store = Arc::new(LanceIndexStore::new( - ObjectStore::local().into(), - src_dir.clone(), - Arc::new(LanceCache::no_cache()), - )); - let dest_store = Arc::new(LanceIndexStore::new( - ObjectStore::local().into(), - dest_dir.clone(), - Arc::new(LanceCache::no_cache()), - )); - - let token_set_format = TokenSetFormat::default(); - - let mut builder0 = InnerBuilder::new(0, false, token_set_format); - let apple_id = builder0.tokens.add("apple".to_owned()); - let banana_id = builder0.tokens.add("banana".to_owned()); - builder0 - .posting_lists - .resize_with(builder0.tokens.len(), || PostingListBuilder::new(false)); - let doc_id = builder0.docs.append(10, 2); - builder0.posting_lists[apple_id as usize].add(doc_id, PositionRecorder::Count(1)); - builder0.posting_lists[banana_id as usize].add(doc_id, PositionRecorder::Count(1)); - builder0.write(src_store.as_ref()).await?; - - let mut builder1 = InnerBuilder::new(1, false, token_set_format); - let banana_id = builder1.tokens.add("banana".to_owned()); - let carrot_id = builder1.tokens.add("carrot".to_owned()); - builder1 - .posting_lists - .resize_with(builder1.tokens.len(), || PostingListBuilder::new(false)); - let doc_id = builder1.docs.append(20, 2); - builder1.posting_lists[banana_id as usize].add(doc_id, PositionRecorder::Count(1)); - builder1.posting_lists[carrot_id as usize].add(doc_id, PositionRecorder::Count(1)); - builder1.write(src_store.as_ref()).await?; - - let mut merger = SizeBasedMerger::new( - dest_store.as_ref(), - vec![ - PartitionSource::new(src_store.clone(), 0), - PartitionSource::new(src_store.clone(), 1), - ], - u64::MAX, - token_set_format, - crate::progress::noop_progress(), - ); - let merged_partitions = merger.merge().await?; - assert_eq!(merged_partitions, vec![2]); - - let merged = InvertedPartition::load( - dest_store.clone(), - merged_partitions[0], - None, - &LanceCache::no_cache(), - token_set_format, - ) - .await?; - - assert_eq!(merged.tokens.len(), 3); - assert_eq!(merged.docs.len(), 2); - assert_eq!(merged.docs.row_id(0), 10); - assert_eq!(merged.docs.row_id(1), 20); - - let banana_token_id = merged.tokens.get("banana").unwrap(); - let posting = merged - .inverted_list - .posting_list(banana_token_id, false, &NoOpMetricsCollector) - .await?; - let doc_ids: Vec = posting.iter().map(|(doc_id, _, _)| doc_id).collect(); - assert_eq!(doc_ids, vec![0, 1]); - - Ok(()) - } - - #[tokio::test] - async fn test_merge_streams_partitions_in_batches() -> Result<()> { - let src_dir = TempObjDir::default(); - let dest_dir = TempObjDir::default(); - let src_store = Arc::new(LanceIndexStore::new( - ObjectStore::local().into(), - src_dir.clone(), - Arc::new(LanceCache::no_cache()), - )); - let dest_store = Arc::new(LanceIndexStore::new( - ObjectStore::local().into(), - dest_dir.clone(), - Arc::new(LanceCache::no_cache()), - )); - - let token_set_format = TokenSetFormat::default(); - let num_parts = get_num_compute_intensive_cpus().saturating_add(2); - let mut sources = Vec::with_capacity(num_parts); - for id in 0..num_parts as u64 { - let mut builder = InnerBuilder::new(id, false, token_set_format); - let token_id = builder.tokens.add(format!("token_{}", id)); - builder - .posting_lists - .resize_with(builder.tokens.len(), || PostingListBuilder::new(false)); - let doc_id = builder.docs.append(id * 10, 1); - builder.posting_lists[token_id as usize].add(doc_id, PositionRecorder::Count(1)); - builder.write(src_store.as_ref()).await?; - sources.push(PartitionSource::new(src_store.clone(), id)); - } - - let mut merger = SizeBasedMerger::new( - dest_store.as_ref(), - sources, - u64::MAX, - token_set_format, - crate::progress::noop_progress(), - ); - let merged_partitions = merger.merge().await?; - assert_eq!(merged_partitions, vec![num_parts as u64]); - - let merged = InvertedPartition::load( - dest_store.clone(), - merged_partitions[0], - None, - &LanceCache::no_cache(), - token_set_format, - ) - .await?; - assert_eq!(merged.tokens.len(), num_parts); - assert_eq!(merged.docs.len(), num_parts); - - Ok(()) - } -} diff --git a/rust/lance-index/src/scalar/inverted/query.rs b/rust/lance-index/src/scalar/inverted/query.rs index a422a4524b3..4c207d83134 100644 --- a/rust/lance-index/src/scalar/inverted/query.rs +++ b/rust/lance-index/src/scalar/inverted/query.rs @@ -625,7 +625,7 @@ impl BooleanQuery { } #[derive(Debug, Clone, PartialEq)] -#[allow(dead_code)] +#[cfg(test)] pub(crate) struct BooleanMatchPlan { pub column: String, pub should: Vec, @@ -633,7 +633,7 @@ pub(crate) struct BooleanMatchPlan { pub must_not: Vec, } -#[allow(dead_code)] +#[cfg(test)] impl BooleanMatchPlan { pub(crate) fn try_build(query: &FtsQuery) -> Option { match query { diff --git a/rust/lance-index/src/scalar/inverted/tokenizer.rs b/rust/lance-index/src/scalar/inverted/tokenizer.rs index 0bb3ead446f..efd317e7393 100644 --- a/rust/lance-index/src/scalar/inverted/tokenizer.rs +++ b/rust/lance-index/src/scalar/inverted/tokenizer.rs @@ -90,10 +90,28 @@ pub struct InvertedIndexParams { #[serde(default)] pub(crate) prefix_only: bool, - /// If true, skip the partition merge stage after indexing. - /// This can be useful for distributed indexing where merge is handled separately. - #[serde(default)] - pub(crate) skip_merge: bool, + /// Total memory limit in MiB for the build stage. + /// + /// This is split evenly across FTS workers at build time. By default Lance + /// uses roughly `num_cpus / 2` workers, unless `LANCE_FTS_NUM_SHARDS` is set. + /// If unset, each worker defaults to a 2 GiB build-time memory limit. + /// + /// This is a build-time only parameter and is not persisted with the index. + #[serde( + rename = "memory_limit", + skip_serializing, + default, + alias = "worker_memory_limit_mb" + )] + pub(crate) memory_limit_mb: Option, + + /// Number of workers to use for FTS build. + /// + /// This is a build-time only parameter and is not persisted with the index. + /// By default Lance uses roughly `num_cpus / 2` workers. + /// The effective worker count is clamped to `[1, num_cpus - 2]`. + #[serde(rename = "num_workers", skip_serializing, default)] + pub(crate) num_workers: Option, } impl TryFrom<&InvertedIndexParams> for pbold::InvertedIndexDetails { @@ -139,7 +157,8 @@ impl TryFrom<&pbold::InvertedIndexDetails> for InvertedIndexParams { min_ngram_length: details.min_ngram_length, max_ngram_length: details.max_ngram_length, prefix_only: details.prefix_only, - skip_merge: defaults.skip_merge, + memory_limit_mb: defaults.memory_limit_mb, + num_workers: defaults.num_workers, }) } } @@ -191,7 +210,8 @@ impl InvertedIndexParams { min_ngram_length: default_min_ngram_length(), max_ngram_length: default_max_ngram_length(), prefix_only: false, - skip_merge: false, + memory_limit_mb: None, + num_workers: None, } } @@ -280,9 +300,17 @@ impl InvertedIndexParams { self } - /// Skip merging partitions after indexing. - pub fn skip_merge(mut self, skip_merge: bool) -> Self { - self.skip_merge = skip_merge; + pub fn memory_limit_mb(mut self, memory_limit_mb: u64) -> Self { + self.memory_limit_mb = Some(memory_limit_mb); + self + } + + /// Set the number of workers to use for this build. + /// + /// By default Lance uses roughly `num_cpus / 2` workers. + /// The effective worker count is clamped to `[1, num_cpus - 2]`. + pub fn num_workers(mut self, num_workers: usize) -> Self { + self.num_workers = Some(num_workers); self } @@ -391,3 +419,43 @@ pub fn language_model_home() -> Option { Err(_) => dirs::data_local_dir().map(|p| p.join(LANCE_LANGUAGE_MODEL_DEFAULT_DIRECTORY)), } } + +#[cfg(test)] +mod tests { + use super::InvertedIndexParams; + + #[test] + fn test_build_only_fields_are_not_serialized() { + let params = InvertedIndexParams::default() + .memory_limit_mb(4096) + .num_workers(7); + let json = serde_json::to_value(¶ms).unwrap(); + assert!(json.get("memory_limit").is_none()); + assert!(json.get("num_workers").is_none()); + } + + #[test] + fn test_memory_limit_serde_accepts_legacy_worker_field_name() { + let mut json = serde_json::to_value(InvertedIndexParams::default()).unwrap(); + let obj = json.as_object_mut().unwrap(); + obj.remove("memory_limit"); + obj.insert( + "worker_memory_limit_mb".to_string(), + serde_json::Value::from(2048), + ); + let params: InvertedIndexParams = serde_json::from_value(json).unwrap(); + assert_eq!(params.memory_limit_mb, Some(2048)); + } + + #[test] + fn test_build_only_fields_deserialize_from_public_names() { + let mut json = serde_json::to_value(InvertedIndexParams::default()).unwrap(); + let obj = json.as_object_mut().unwrap(); + obj.insert("memory_limit".to_string(), serde_json::Value::from(4096)); + obj.insert("num_workers".to_string(), serde_json::Value::from(3)); + + let params: InvertedIndexParams = serde_json::from_value(json).unwrap(); + assert_eq!(params.memory_limit_mb, Some(4096)); + assert_eq!(params.num_workers, Some(3)); + } +} diff --git a/rust/lance/src/dataset/cleanup.rs b/rust/lance/src/dataset/cleanup.rs index 464f735e2f6..18380458eeb 100644 --- a/rust/lance/src/dataset/cleanup.rs +++ b/rust/lance/src/dataset/cleanup.rs @@ -2867,7 +2867,7 @@ mod tests { assert_eq!(setup.branch1.counts.num_data_files, 2); assert_eq!(setup.branch1.counts.num_tx_files, 1); assert_eq!(setup.branch1.counts.num_delete_files, 2); - assert_eq!(setup.branch1.counts.num_index_files, 8); + assert_eq!(setup.branch1.counts.num_index_files, 23); setup.assert_all_unchanged().await; setup.branch1.compact().await.unwrap(); @@ -2882,7 +2882,7 @@ mod tests { assert_eq!(setup.branch1.counts.num_data_files, 2); assert_eq!(setup.branch1.counts.num_tx_files, 1); assert_eq!(setup.branch1.counts.num_delete_files, 1); - assert_eq!(setup.branch1.counts.num_index_files, 8); + assert_eq!(setup.branch1.counts.num_index_files, 23); setup.assert_all_unchanged().await; // Now we clean the referenced files of branch1 by branch2 and branch3 @@ -2896,14 +2896,14 @@ mod tests { assert_eq!(setup.branch2.counts.num_data_files, 1); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 0); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 13); // Only the latest manifest is retained. // (1, 1, 1, 0, 4) is the counts for the latest version of compaction assert_eq!(setup.branch3.counts.num_manifest_files, 1); assert_eq!(setup.branch3.counts.num_data_files, 1); assert_eq!(setup.branch3.counts.num_tx_files, 1); assert_eq!(setup.branch3.counts.num_delete_files, 0); - assert_eq!(setup.branch3.counts.num_index_files, 4); + assert_eq!(setup.branch3.counts.num_index_files, 16); setup.branch1.run_cleanup().await.unwrap(); // Only the latest manifest is retained. @@ -2912,7 +2912,7 @@ mod tests { assert_eq!(setup.branch1.counts.num_data_files, 1); assert_eq!(setup.branch1.counts.num_tx_files, 1); assert_eq!(setup.branch1.counts.num_delete_files, 0); - assert_eq!(setup.branch1.counts.num_index_files, 4); + assert_eq!(setup.branch1.counts.num_index_files, 13); setup.assert_all_unchanged().await; } @@ -2929,7 +2929,7 @@ mod tests { assert_eq!(setup.branch3.counts.num_data_files, 2); assert_eq!(setup.branch3.counts.num_tx_files, 1); assert_eq!(setup.branch3.counts.num_delete_files, 2); - assert_eq!(setup.branch3.counts.num_index_files, 4); + assert_eq!(setup.branch3.counts.num_index_files, 19); setup .assert_unchanged(&["branch1", "branch2", "branch4", "main"]) .await; @@ -2945,7 +2945,7 @@ mod tests { assert_eq!(setup.branch2.counts.num_data_files, 2); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 1); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 13); setup.branch3.compact().await.unwrap(); setup.branch3.run_cleanup().await.unwrap(); @@ -2955,7 +2955,7 @@ mod tests { assert_eq!(setup.branch3.counts.num_data_files, 1); assert_eq!(setup.branch3.counts.num_tx_files, 1); assert_eq!(setup.branch3.counts.num_delete_files, 0); - assert_eq!(setup.branch3.counts.num_index_files, 4); + assert_eq!(setup.branch3.counts.num_index_files, 19); setup .assert_unchanged(&["branch1", "branch2", "branch4", "main"]) .await; @@ -2968,7 +2968,7 @@ mod tests { assert_eq!(setup.branch2.counts.num_data_files, 1); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 0); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 13); } #[tokio::test] @@ -2985,7 +2985,7 @@ mod tests { assert_eq!(setup.branch4.counts.num_data_files, 2); assert_eq!(setup.branch4.counts.num_tx_files, 1); assert_eq!(setup.branch4.counts.num_delete_files, 2); - assert_eq!(setup.branch4.counts.num_index_files, 4); + assert_eq!(setup.branch4.counts.num_index_files, 16); setup.assert_all_unchanged().await; setup.main.compact().await.unwrap(); @@ -3005,7 +3005,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 4); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 2); - assert_eq!(setup.main.counts.num_index_files, 8); + assert_eq!(setup.main.counts.num_index_files, 17); setup.branch4.compact().await.unwrap(); setup.branch4.run_cleanup().await.unwrap(); @@ -3015,7 +3015,7 @@ mod tests { assert_eq!(setup.branch4.counts.num_data_files, 1); assert_eq!(setup.branch4.counts.num_tx_files, 1); assert_eq!(setup.branch4.counts.num_delete_files, 0); - assert_eq!(setup.branch4.counts.num_index_files, 4); + assert_eq!(setup.branch4.counts.num_index_files, 16); setup.assert_all_unchanged().await; setup.main.run_cleanup().await.unwrap(); @@ -3029,7 +3029,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 3); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 1); - assert_eq!(setup.main.counts.num_index_files, 8); + assert_eq!(setup.main.counts.num_index_files, 17); } #[tokio::test] @@ -3054,7 +3054,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 4); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 3); - assert_eq!(setup.main.counts.num_index_files, 12); + assert_eq!(setup.main.counts.num_index_files, 30); setup.assert_all_unchanged().await; setup.main.compact().await.unwrap(); @@ -3065,7 +3065,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 4); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 2); - assert_eq!(setup.main.counts.num_index_files, 12); + assert_eq!(setup.main.counts.num_index_files, 30); setup.assert_all_unchanged().await; setup.branch1.write_data().await.unwrap(); @@ -3086,14 +3086,14 @@ mod tests { assert_eq!(setup.branch2.counts.num_data_files, 2); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 1); - assert_eq!(setup.branch2.counts.num_index_files, 8); + assert_eq!(setup.branch2.counts.num_index_files, 29); setup.branch1.run_cleanup().await.unwrap(); // Cleanup 4 index files referenced from branch2 assert_eq!(setup.branch1.counts.num_manifest_files, 2); assert_eq!(setup.branch1.counts.num_data_files, 2); assert_eq!(setup.branch1.counts.num_tx_files, 1); assert_eq!(setup.branch1.counts.num_delete_files, 1); - assert_eq!(setup.branch1.counts.num_index_files, 4); + assert_eq!(setup.branch1.counts.num_index_files, 13); setup.main.run_cleanup().await.unwrap(); // Branch3 holds references from main: @@ -3109,7 +3109,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 4); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 2); - assert_eq!(setup.main.counts.num_index_files, 8); + assert_eq!(setup.main.counts.num_index_files, 23); setup.branch3.write_data().await.unwrap(); setup.branch3.compact().await.unwrap(); @@ -3119,7 +3119,7 @@ mod tests { assert_eq!(setup.branch3.counts.num_data_files, 1); assert_eq!(setup.branch3.counts.num_tx_files, 1); assert_eq!(setup.branch3.counts.num_delete_files, 0); - assert_eq!(setup.branch3.counts.num_index_files, 4); + assert_eq!(setup.branch3.counts.num_index_files, 19); setup.main.run_cleanup().await.unwrap(); // Cleanup doesn't take effects if we don't clean branch2 and branch1 first @@ -3127,7 +3127,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 4); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 2); - assert_eq!(setup.main.counts.num_index_files, 8); + assert_eq!(setup.main.counts.num_index_files, 23); // Cleanup doesn't take effect if we don't clean branch2 first setup.branch1.run_cleanup().await.unwrap(); @@ -3135,7 +3135,7 @@ mod tests { assert_eq!(setup.branch1.counts.num_data_files, 2); assert_eq!(setup.branch1.counts.num_tx_files, 1); assert_eq!(setup.branch1.counts.num_delete_files, 1); - assert_eq!(setup.branch1.counts.num_index_files, 4); + assert_eq!(setup.branch1.counts.num_index_files, 13); setup.branch2.run_cleanup().await.unwrap(); // Only the latest manifest is retained. @@ -3144,7 +3144,7 @@ mod tests { assert_eq!(setup.branch2.counts.num_data_files, 1); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 0); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 16); setup.branch1.run_cleanup().await.unwrap(); // Only the latest manifest is retained. @@ -3153,7 +3153,7 @@ mod tests { assert_eq!(setup.branch1.counts.num_data_files, 1); assert_eq!(setup.branch1.counts.num_tx_files, 1); assert_eq!(setup.branch1.counts.num_delete_files, 0); - assert_eq!(setup.branch1.counts.num_index_files, 4); + assert_eq!(setup.branch1.counts.num_index_files, 13); setup.main.run_cleanup().await.unwrap(); // Branch4 holds references from main: @@ -3165,7 +3165,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 4); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 2); - assert_eq!(setup.main.counts.num_index_files, 8); + assert_eq!(setup.main.counts.num_index_files, 23); setup.branch4.write_data().await.unwrap(); setup.branch4.compact().await.unwrap(); @@ -3176,7 +3176,7 @@ mod tests { assert_eq!(setup.branch4.counts.num_data_files, 1); assert_eq!(setup.branch4.counts.num_tx_files, 1); assert_eq!(setup.branch4.counts.num_delete_files, 0); - assert_eq!(setup.branch4.counts.num_index_files, 4); + assert_eq!(setup.branch4.counts.num_index_files, 16); setup.main.run_cleanup().await.unwrap(); // Only the latest manifest is retained. @@ -3185,7 +3185,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 1); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 0); - assert_eq!(setup.main.counts.num_index_files, 4); + assert_eq!(setup.main.counts.num_index_files, 13); } #[tokio::test] @@ -3209,7 +3209,7 @@ mod tests { assert_eq!(setup.branch2.counts.num_data_files, 1); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 1); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 13); // After auto-clean: branch3 // 2 appends produced 2 data files // 2 deletes produced 2 deletion files @@ -3217,7 +3217,7 @@ mod tests { assert_eq!(setup.branch3.counts.num_data_files, 2); assert_eq!(setup.branch3.counts.num_tx_files, 1); assert_eq!(setup.branch3.counts.num_delete_files, 2); - assert_eq!(setup.branch3.counts.num_index_files, 4); + assert_eq!(setup.branch3.counts.num_index_files, 19); setup .assert_unchanged(&["branch1", "branch4", "main"]) .await; @@ -3239,14 +3239,14 @@ mod tests { assert_eq!(setup.branch2.counts.num_data_files, 1); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 0); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 16); // Only the latest manifest is retained. // (1, 1, 1, 0, 4) is the counts of one version assert_eq!(setup.branch3.counts.num_manifest_files, 1); assert_eq!(setup.branch3.counts.num_data_files, 1); assert_eq!(setup.branch3.counts.num_tx_files, 1); assert_eq!(setup.branch3.counts.num_delete_files, 0); - assert_eq!(setup.branch3.counts.num_index_files, 4); + assert_eq!(setup.branch3.counts.num_index_files, 19); setup .assert_unchanged(&["branch1", "branch4", "main"]) .await; @@ -3276,7 +3276,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 4); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 3); - assert_eq!(setup.main.counts.num_index_files, 4); + assert_eq!(setup.main.counts.num_index_files, 13); setup.main.compact().await.unwrap(); setup @@ -3296,7 +3296,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 4); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 2); - assert_eq!(setup.main.counts.num_index_files, 4); + assert_eq!(setup.main.counts.num_index_files, 13); setup.branch4.compact().await.unwrap(); setup @@ -3313,13 +3313,13 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 3); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 1); - assert_eq!(setup.main.counts.num_index_files, 4); + assert_eq!(setup.main.counts.num_index_files, 13); // (1, 1, 1, 0, 4) is the counts of one version assert_eq!(setup.branch4.counts.num_manifest_files, 1); assert_eq!(setup.branch4.counts.num_data_files, 1); assert_eq!(setup.branch4.counts.num_tx_files, 1); assert_eq!(setup.branch4.counts.num_delete_files, 0); - assert_eq!(setup.branch4.counts.num_index_files, 4); + assert_eq!(setup.branch4.counts.num_index_files, 13); setup.branch1.write_data().await.unwrap(); setup.branch1.compact().await.unwrap(); @@ -3337,7 +3337,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 3); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 1); - assert_eq!(setup.main.counts.num_index_files, 4); + assert_eq!(setup.main.counts.num_index_files, 13); // Branch3 and branch2 still hold references from branch1: // - 1 manifest file // - 1 data files @@ -3346,7 +3346,7 @@ mod tests { assert_eq!(setup.branch1.counts.num_data_files, 2); assert_eq!(setup.branch1.counts.num_tx_files, 1); assert_eq!(setup.branch1.counts.num_delete_files, 1); - assert_eq!(setup.branch1.counts.num_index_files, 4); + assert_eq!(setup.branch1.counts.num_index_files, 13); setup.branch2.write_data().await.unwrap(); setup.branch2.compact().await.unwrap(); @@ -3364,7 +3364,7 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 3); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 1); - assert_eq!(setup.main.counts.num_index_files, 4); + assert_eq!(setup.main.counts.num_index_files, 13); // Branch3 still holds references from branch1: // - 1 manifest file // - 1 data files @@ -3373,7 +3373,7 @@ mod tests { assert_eq!(setup.branch1.counts.num_data_files, 2); assert_eq!(setup.branch1.counts.num_tx_files, 1); assert_eq!(setup.branch1.counts.num_delete_files, 1); - assert_eq!(setup.branch1.counts.num_index_files, 4); + assert_eq!(setup.branch1.counts.num_index_files, 13); // Branch3 still holds references from branch2: // - 1 manifest file // - 1 data files @@ -3382,7 +3382,7 @@ mod tests { assert_eq!(setup.branch2.counts.num_data_files, 2); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 1); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 16); setup.branch3.write_data().await.unwrap(); setup.branch3.compact().await.unwrap(); @@ -3400,22 +3400,22 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 1); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 0); - assert_eq!(setup.main.counts.num_index_files, 4); + assert_eq!(setup.main.counts.num_index_files, 13); assert_eq!(setup.branch1.counts.num_manifest_files, 1); assert_eq!(setup.branch1.counts.num_data_files, 1); assert_eq!(setup.branch1.counts.num_tx_files, 1); assert_eq!(setup.branch1.counts.num_delete_files, 0); - assert_eq!(setup.branch1.counts.num_index_files, 4); + assert_eq!(setup.branch1.counts.num_index_files, 13); assert_eq!(setup.branch2.counts.num_manifest_files, 1); assert_eq!(setup.branch2.counts.num_data_files, 1); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 0); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 16); assert_eq!(setup.branch3.counts.num_manifest_files, 1); assert_eq!(setup.branch3.counts.num_data_files, 1); assert_eq!(setup.branch3.counts.num_tx_files, 1); assert_eq!(setup.branch3.counts.num_delete_files, 0); - assert_eq!(setup.branch3.counts.num_index_files, 4); + assert_eq!(setup.branch3.counts.num_index_files, 19); setup.assert_unchanged(&["branch4"]).await; } @@ -3459,24 +3459,24 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 4); assert_eq!(setup.main.counts.num_tx_files, 2); assert_eq!(setup.main.counts.num_delete_files, 2); - assert_eq!(setup.main.counts.num_index_files, 8); + assert_eq!(setup.main.counts.num_index_files, 20); // Branch3 tag holds branch1 with 1 tx file, 1 data files, 1 deletion files and 4 index files assert_eq!(setup.branch2.counts.num_manifest_files, 2); assert_eq!(setup.branch2.counts.num_data_files, 2); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 1); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 13); // Branch3 tag holds branch2 with 1 tx file, 1 data files, 1 deletion files and 4 index files assert_eq!(setup.branch2.counts.num_manifest_files, 2); assert_eq!(setup.branch2.counts.num_data_files, 2); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 1); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 13); assert_eq!(setup.branch4.counts.num_manifest_files, 1); assert_eq!(setup.branch4.counts.num_data_files, 1); assert_eq!(setup.branch4.counts.num_tx_files, 1); assert_eq!(setup.branch4.counts.num_delete_files, 0); - assert_eq!(setup.branch4.counts.num_index_files, 4); + assert_eq!(setup.branch4.counts.num_index_files, 13); setup .branch3 @@ -3499,27 +3499,27 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 4); assert_eq!(setup.main.counts.num_tx_files, 2); assert_eq!(setup.main.counts.num_delete_files, 2); - assert_eq!(setup.main.counts.num_index_files, 8); + assert_eq!(setup.main.counts.num_index_files, 20); assert_eq!(setup.branch1.counts.num_manifest_files, 1); assert_eq!(setup.branch1.counts.num_data_files, 1); assert_eq!(setup.branch1.counts.num_tx_files, 1); assert_eq!(setup.branch1.counts.num_delete_files, 0); - assert_eq!(setup.branch1.counts.num_index_files, 4); + assert_eq!(setup.branch1.counts.num_index_files, 10); assert_eq!(setup.branch2.counts.num_manifest_files, 1); assert_eq!(setup.branch2.counts.num_data_files, 1); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 0); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 13); assert_eq!(setup.branch3.counts.num_manifest_files, 1); assert_eq!(setup.branch3.counts.num_data_files, 1); assert_eq!(setup.branch3.counts.num_tx_files, 1); assert_eq!(setup.branch3.counts.num_delete_files, 0); - assert_eq!(setup.branch3.counts.num_index_files, 4); + assert_eq!(setup.branch3.counts.num_index_files, 16); assert_eq!(setup.branch4.counts.num_manifest_files, 1); assert_eq!(setup.branch4.counts.num_data_files, 1); assert_eq!(setup.branch4.counts.num_tx_files, 1); assert_eq!(setup.branch4.counts.num_delete_files, 0); - assert_eq!(setup.branch4.counts.num_index_files, 4); + assert_eq!(setup.branch4.counts.num_index_files, 13); setup.main.dataset.tags().delete("main-tag").await.unwrap(); setup @@ -3535,22 +3535,22 @@ mod tests { assert_eq!(setup.main.counts.num_data_files, 1); assert_eq!(setup.main.counts.num_tx_files, 1); assert_eq!(setup.main.counts.num_delete_files, 0); - assert_eq!(setup.main.counts.num_index_files, 4); + assert_eq!(setup.main.counts.num_index_files, 10); assert_eq!(setup.branch2.counts.num_manifest_files, 1); assert_eq!(setup.branch2.counts.num_data_files, 1); assert_eq!(setup.branch2.counts.num_tx_files, 1); assert_eq!(setup.branch2.counts.num_delete_files, 0); - assert_eq!(setup.branch2.counts.num_index_files, 4); + assert_eq!(setup.branch2.counts.num_index_files, 13); assert_eq!(setup.branch3.counts.num_manifest_files, 1); assert_eq!(setup.branch3.counts.num_data_files, 1); assert_eq!(setup.branch3.counts.num_tx_files, 1); assert_eq!(setup.branch3.counts.num_delete_files, 0); - assert_eq!(setup.branch3.counts.num_index_files, 4); + assert_eq!(setup.branch3.counts.num_index_files, 16); assert_eq!(setup.branch4.counts.num_manifest_files, 1); assert_eq!(setup.branch4.counts.num_data_files, 1); assert_eq!(setup.branch4.counts.num_tx_files, 1); assert_eq!(setup.branch4.counts.num_delete_files, 0); - assert_eq!(setup.branch4.counts.num_index_files, 4); + assert_eq!(setup.branch4.counts.num_index_files, 13); } #[test] From 3f74834171a9ce231325e3604a0410886bc3612a Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Thu, 12 Mar 2026 22:36:21 +0000 Subject: [PATCH 017/206] chore: release beta version 4.0.0-beta.10 --- .bumpversion.toml | 2 +- Cargo.lock | 228 +++++++++++++++++++++----------------- Cargo.toml | 40 +++---- java/lance-jni/Cargo.lock | 166 +++++++++++++-------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 178 ++++++++++++++--------------- python/Cargo.toml | 2 +- 8 files changed, 322 insertions(+), 298 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 43c4b15314f..7b4d8f9895e 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.0.0-beta.9" +current_version = "4.0.0-beta.10" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 2feb5add054..21d0b06acf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,7 +121,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -144,6 +159,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + [[package]] name = "anstyle-query" version = "1.1.5" @@ -1628,9 +1652,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -1638,11 +1662,11 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream", + "anstream 1.0.0", "anstyle", "clap_lex", "strsim 0.11.1", @@ -1650,9 +1674,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -1662,9 +1686,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" @@ -2142,9 +2166,9 @@ dependencies = [ [[package]] name = "datafusion" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503f1f4a9060ae6e650d3dff5dc7a21266fea1302d890768d45b4b28586e830f" +checksum = "ea28305c211e3541c9cfcf06a23d0d8c7c824b4502ed1fdf0a6ff4ad24ee531c" dependencies = [ "arrow", "arrow-schema", @@ -2193,9 +2217,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14417a3ee4ae3d092b56cd6c1d32e8ff3e2c9ec130ecb2276ec91c89fd599399" +checksum = "78ab99b6df5f60a6ddbc515e4c05caee1192d395cf3cb67ce5d1c17e3c9b9b74" dependencies = [ "arrow", "async-trait", @@ -2218,9 +2242,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0eba824adb45a4b3ac6f0251d40df3f6a9382371cad136f4f14ac9ebc6bc10" +checksum = "77ae3d14912c0d779ada98d30dc60f3244f3c26c2446b87394629ea5c076a31c" dependencies = [ "arrow", "async-trait", @@ -2241,9 +2265,9 @@ dependencies = [ [[package]] name = "datafusion-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0039deefbd00c56adf5168b7ca58568fb058e4ba4c5a03b09f8be371b4e434b6" +checksum = "ea2df29b9592a5d55b8238eaf67d2f21963d5a08cd1a8b7670134405206caabd" dependencies = [ "ahash", "arrow", @@ -2264,9 +2288,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec7e3e60b813048331f8fb9673583173e5d2dd8fef862834ee871fc98b57ca7" +checksum = "42639baa0049d5fffd7e283504b9b5e7b9b2e7a2dea476eed60ab0d40d999b85" dependencies = [ "futures", "log", @@ -2275,9 +2299,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802068957f620302ecf05f84ff4019601aeafd36f5f3f1334984af2e34265129" +checksum = "25951b617bb22a9619e1520450590cb2004bfcad10bcb396b961f4a1a10dcec5" dependencies = [ "arrow", "async-trait", @@ -2304,9 +2328,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-arrow" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90fc387d5067c62d494a6647d29c5ad4fcdd5a6e50ab4ea1d2568caa2d66f2cc" +checksum = "dc0b28226960ba99c50d78ac6f736ebe09eb5cb3bb9bb58194266278000ca41f" dependencies = [ "arrow", "arrow-ipc", @@ -2328,9 +2352,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd5e20579bb6c8bd4e6c620253972fb723822030c280dd6aa047f660d09eeba" +checksum = "f538b57b052a678b1ce860181c65d3ace5a8486312dc50b41c01dd585a773a51" dependencies = [ "arrow", "async-trait", @@ -2351,9 +2375,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0788b0d48fcef31880a02013ea3cc18e5a4e0eacc3b0abdd2cd0597b99dc96e" +checksum = "89fbc1d32b1b03c9734e27c0c5f041232b68621c8455f22769838634750a196c" dependencies = [ "arrow", "async-trait", @@ -2373,9 +2397,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-parquet" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66639b70f1f363f5f0950733170100e588f1acfacac90c1894e231194aa35957" +checksum = "203271d31fe5613a5943181db70ec98162121d1de94a9a300d5e5f19f9500a32" dependencies = [ "arrow", "async-trait", @@ -2403,15 +2427,15 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e44b41f3e8267c6cf3eec982d63f34db9f1dd5f30abfd2e1f124f0871708952e" +checksum = "5b6450dc702b3d39e8ced54c3356abb453bd2f3cea86d90d555a4b92f7a38462" [[package]] name = "datafusion-execution" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e456f60e5d38db45335e84617006d90af14a8c8c5b8e959add708b2daaa0e2c" +checksum = "e66a02fa601de49da5181dbdcf904a18b16a184db2b31f5e5534552ea2d5e660" dependencies = [ "arrow", "async-trait", @@ -2430,9 +2454,9 @@ dependencies = [ [[package]] name = "datafusion-expr" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6507c719804265a58043134580c1c20767e7c23ba450724393f03ec982769ad9" +checksum = "cdf59a9b308a1a07dc2eb2f85e6366bc0226dc390b40f3aa0a72d79f1cfe2465" dependencies = [ "arrow", "async-trait", @@ -2452,9 +2476,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a413caa9c5885072b539337aed68488f0291653e8edd7d676c92df2480f6cab0" +checksum = "bd99eac4c6538c708638db43e7a3bd88e0e57955ddb722d420fb9a6d38dfc28f" dependencies = [ "arrow", "datafusion-common", @@ -2465,9 +2489,9 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "189256495dc9cbbb8e20dbcf161f60422e628d201a78df8207e44bd4baefadb6" +checksum = "11aa2c492ac046397b36d57c62a72982aad306495bbcbcdbcabd424d4a2fe245" dependencies = [ "arrow", "arrow-buffer", @@ -2496,9 +2520,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12e73dfee4cd67c4a507ffff4c5a711d39983adf544adbc09c09bf06f789f413" +checksum = "325a00081898945d48d6194d9ca26120e523c993be3bb7c084061a5a2a72e787" dependencies = [ "ahash", "arrow", @@ -2517,9 +2541,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87727bd9e65f4f9ac6d608c9810b7da9eaa3b18b26a4a4b76520592d49020acf" +checksum = "809bbcb1e0dbec5d0ce30d493d135aea7564f1ba4550395f7f94321223df2dae" dependencies = [ "ahash", "arrow", @@ -2530,9 +2554,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5ef761359224b7c2b5a1bfad6296ac63225f8583d08ad18af9ba1a89ac3887" +checksum = "29ebaa5d7024ef45973e0a7db1e9aeaa647936496f4d4061c0448f23d77d6320" dependencies = [ "arrow", "arrow-ord", @@ -2553,9 +2577,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b17dac25dfda2d2a90ff0ad1c054a11fb1523766226bec6e9bd8c410daee2ae" +checksum = "60eab6f39df9ee49a2c7fa38eddc01fa0086ee31b29c7d19f38e72f479609752" dependencies = [ "arrow", "async-trait", @@ -2569,9 +2593,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c594a29ddb22cbdbce500e4d99b5b2392c5cecb4c1086298b41d1ffec14dbb77" +checksum = "e00b2c15e342a90e65a846199c9e49293dd09fe1bcd63d8be2544604892f7eb8" dependencies = [ "arrow", "datafusion-common", @@ -2587,9 +2611,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa1b15ed81c7543f62264a30dd49dec4b1b0b698053b968f53be32dfba4f729" +checksum = "493e2e1d1f4753dfc139a5213f1b5d0b97eea46a82d9bda3c7908aa96981b74b" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -2597,9 +2621,9 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00c31c4795597aa25b74cab5174ac07a53051f27ce1e011ecaffa9eaeecef81" +checksum = "ba01c55ade8278a791b429f7bf5cb1de64de587a342d084b18245edfae7096e2" dependencies = [ "datafusion-doc", "quote", @@ -2608,9 +2632,9 @@ dependencies = [ [[package]] name = "datafusion-optimizer" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80ccf60767c09302b2e0fc3afebb3761a6d508d07316fab8c5e93312728a21bb" +checksum = "a80c6dfbba6a2163a9507f6353ac78c69d8deb26232c9e419160e58ff7c3e047" dependencies = [ "arrow", "chrono", @@ -2627,9 +2651,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64b7f277556944e4edd3558da01d9e9ff9f5416f1c0aa7fee088e57bd141a7e" +checksum = "5d3a86264bb9163e7360b6622e789bc7fcbb43672e78a8493f0bc369a41a57c6" dependencies = [ "ahash", "arrow", @@ -2650,9 +2674,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-adapter" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7abaee372ea2d19c016ee9ef8629c4415257d291cdd152bc7f0b75f28af1b63" +checksum = "3f5e00e524ac33500be6c5eeac940bd3f6b984ba9b7df0cd5f6c34a8a2cc4d6b" dependencies = [ "arrow", "datafusion-common", @@ -2665,9 +2689,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42237efe621f92adc22d111b531fdbc2cc38ca9b5e02327535628fb103ae2157" +checksum = "2ae769ea5d688b4e74e9be5cad6f9d9f295b540825355868a3ab942380dd97ce" dependencies = [ "ahash", "arrow", @@ -2682,9 +2706,9 @@ dependencies = [ [[package]] name = "datafusion-physical-optimizer" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd093498bd1319c6e5c76e9dfa905e78486f01b34579ce97f2e3a49f84c37fac" +checksum = "f3588753ab2b47b0e43cd823fe5e7944df6734dabd6dafb72e2cc1c2a22f1944" dependencies = [ "arrow", "datafusion-common", @@ -2700,9 +2724,9 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cbe61b12daf81a9f20ba03bd3541165d51f86e004ef37426b11881330eed261" +checksum = "79949cbb109c2a45c527bfe0d956b9f2916807c05d4d2e66f3fd0af827ac2b61" dependencies = [ "ahash", "arrow", @@ -2731,9 +2755,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0124331116db7f79df92ebfd2c3b11a8f90240f253555c9bb084f10b6fecf1dd" +checksum = "6434e2ee8a39d04b95fed688ff34dc251af6e4a0c2e1714716b6e3846690d589" dependencies = [ "arrow", "datafusion-common", @@ -2748,9 +2772,9 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1673e3c58ba618a6ea0568672f00664087b8982c581e9afd5aa6c3c79c9b431f" +checksum = "c91efb8302b4877d499c37e9a71886b90236ab27d9cc42fd51112febf341abd6" dependencies = [ "async-trait", "datafusion-common", @@ -2762,9 +2786,9 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5272d256dab5347bb39d2040589f45d8c6b715b27edcb5fffe88cc8b9c3909cb" +checksum = "3f01eef7bcf4d00e87305b55f1b75792384e130fe0258bac02cd48378ae5ff87" dependencies = [ "arrow", "bigdecimal", @@ -2779,9 +2803,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199790fd96e852997b30da4ff11109378c944841757d93875ea85fc69587ec91" +checksum = "fe00df31ca03a167d3e40054120930fe5fb689e66bc625b602fac7153b222aea" dependencies = [ "async-recursion", "async-trait", @@ -3193,7 +3217,7 @@ version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ - "anstream", + "anstream 0.6.21", "anstyle", "env_filter", "jiff", @@ -3443,7 +3467,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "lance-datagen", @@ -4735,7 +4759,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "all_asserts", "approx", @@ -4830,7 +4854,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "arrow-buffer", @@ -4850,7 +4874,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrayref", "paste", @@ -4859,7 +4883,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "arrow-buffer", @@ -4899,7 +4923,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", @@ -4933,7 +4957,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", @@ -4953,7 +4977,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-arith", "arrow-array", @@ -5000,7 +5024,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "all_asserts", "arrow", @@ -5026,7 +5050,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-arith", "arrow-array", @@ -5069,7 +5093,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "datafusion", "geo-traits", @@ -5083,7 +5107,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "approx", "arrow", @@ -5163,7 +5187,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-arith", @@ -5211,7 +5235,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "approx", "arrow-array", @@ -5232,7 +5256,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "async-trait", @@ -5246,7 +5270,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", @@ -5264,7 +5288,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-ipc", @@ -5322,7 +5346,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", @@ -5368,7 +5392,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "proc-macro2", "quote", @@ -5377,7 +5401,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "arrow-schema", @@ -5388,7 +5412,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "clap", "lance-core", @@ -6292,9 +6316,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -6368,9 +6392,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ "bitflags 2.11.0", "cfg-if", @@ -6400,9 +6424,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index a332b11ff6e..e2918c0c9dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.0.0-beta.9", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.0.0-beta.9", path = "./rust/lance-arrow" } -lance-core = { version = "=4.0.0-beta.9", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.0.0-beta.9", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.0.0-beta.9", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.0.0-beta.9", path = "./rust/lance-encoding" } -lance-file = { version = "=4.0.0-beta.9", path = "./rust/lance-file" } -lance-geo = { version = "=4.0.0-beta.9", path = "./rust/lance-geo" } -lance-index = { version = "=4.0.0-beta.9", path = "./rust/lance-index" } -lance-io = { version = "=4.0.0-beta.9", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.0.0-beta.9", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.0.0-beta.9", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.0.0-beta.9", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.0.0-beta.9", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=4.0.0-beta.10", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=4.0.0-beta.10", path = "./rust/lance-arrow" } +lance-core = { version = "=4.0.0-beta.10", path = "./rust/lance-core" } +lance-datafusion = { version = "=4.0.0-beta.10", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=4.0.0-beta.10", path = "./rust/lance-datagen" } +lance-encoding = { version = "=4.0.0-beta.10", path = "./rust/lance-encoding" } +lance-file = { version = "=4.0.0-beta.10", path = "./rust/lance-file" } +lance-geo = { version = "=4.0.0-beta.10", path = "./rust/lance-geo" } +lance-index = { version = "=4.0.0-beta.10", path = "./rust/lance-index" } +lance-io = { version = "=4.0.0-beta.10", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=4.0.0-beta.10", path = "./rust/lance-linalg" } +lance-namespace = { version = "=4.0.0-beta.10", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=4.0.0-beta.10", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=4.0.0-beta.10", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.5.2" -lance-table = { version = "=4.0.0-beta.9", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.0.0-beta.9", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.0.0-beta.9", path = "./rust/lance-testing" } +lance-table = { version = "=4.0.0-beta.10", path = "./rust/lance-table" } +lance-test-macros = { version = "=4.0.0-beta.10", path = "./rust/lance-test-macros" } +lance-testing = { version = "=4.0.0-beta.10", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.0.0-beta.9", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=4.0.0-beta.10", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.0.0-beta.9", path = "./rust/compression/fsst" } +fsst = { version = "=4.0.0-beta.10", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 2524961a2b0..a90b5542276 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -1429,9 +1429,9 @@ dependencies = [ [[package]] name = "datafusion" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503f1f4a9060ae6e650d3dff5dc7a21266fea1302d890768d45b4b28586e830f" +checksum = "ea28305c211e3541c9cfcf06a23d0d8c7c824b4502ed1fdf0a6ff4ad24ee531c" dependencies = [ "arrow", "arrow-schema", @@ -1480,9 +1480,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14417a3ee4ae3d092b56cd6c1d32e8ff3e2c9ec130ecb2276ec91c89fd599399" +checksum = "78ab99b6df5f60a6ddbc515e4c05caee1192d395cf3cb67ce5d1c17e3c9b9b74" dependencies = [ "arrow", "async-trait", @@ -1505,9 +1505,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0eba824adb45a4b3ac6f0251d40df3f6a9382371cad136f4f14ac9ebc6bc10" +checksum = "77ae3d14912c0d779ada98d30dc60f3244f3c26c2446b87394629ea5c076a31c" dependencies = [ "arrow", "async-trait", @@ -1528,9 +1528,9 @@ dependencies = [ [[package]] name = "datafusion-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0039deefbd00c56adf5168b7ca58568fb058e4ba4c5a03b09f8be371b4e434b6" +checksum = "ea2df29b9592a5d55b8238eaf67d2f21963d5a08cd1a8b7670134405206caabd" dependencies = [ "ahash", "arrow", @@ -1551,9 +1551,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec7e3e60b813048331f8fb9673583173e5d2dd8fef862834ee871fc98b57ca7" +checksum = "42639baa0049d5fffd7e283504b9b5e7b9b2e7a2dea476eed60ab0d40d999b85" dependencies = [ "futures", "log", @@ -1562,9 +1562,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802068957f620302ecf05f84ff4019601aeafd36f5f3f1334984af2e34265129" +checksum = "25951b617bb22a9619e1520450590cb2004bfcad10bcb396b961f4a1a10dcec5" dependencies = [ "arrow", "async-trait", @@ -1591,9 +1591,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-arrow" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90fc387d5067c62d494a6647d29c5ad4fcdd5a6e50ab4ea1d2568caa2d66f2cc" +checksum = "dc0b28226960ba99c50d78ac6f736ebe09eb5cb3bb9bb58194266278000ca41f" dependencies = [ "arrow", "arrow-ipc", @@ -1615,9 +1615,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd5e20579bb6c8bd4e6c620253972fb723822030c280dd6aa047f660d09eeba" +checksum = "f538b57b052a678b1ce860181c65d3ace5a8486312dc50b41c01dd585a773a51" dependencies = [ "arrow", "async-trait", @@ -1638,9 +1638,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0788b0d48fcef31880a02013ea3cc18e5a4e0eacc3b0abdd2cd0597b99dc96e" +checksum = "89fbc1d32b1b03c9734e27c0c5f041232b68621c8455f22769838634750a196c" dependencies = [ "arrow", "async-trait", @@ -1660,9 +1660,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-parquet" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66639b70f1f363f5f0950733170100e588f1acfacac90c1894e231194aa35957" +checksum = "203271d31fe5613a5943181db70ec98162121d1de94a9a300d5e5f19f9500a32" dependencies = [ "arrow", "async-trait", @@ -1690,15 +1690,15 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e44b41f3e8267c6cf3eec982d63f34db9f1dd5f30abfd2e1f124f0871708952e" +checksum = "5b6450dc702b3d39e8ced54c3356abb453bd2f3cea86d90d555a4b92f7a38462" [[package]] name = "datafusion-execution" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e456f60e5d38db45335e84617006d90af14a8c8c5b8e959add708b2daaa0e2c" +checksum = "e66a02fa601de49da5181dbdcf904a18b16a184db2b31f5e5534552ea2d5e660" dependencies = [ "arrow", "async-trait", @@ -1717,9 +1717,9 @@ dependencies = [ [[package]] name = "datafusion-expr" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6507c719804265a58043134580c1c20767e7c23ba450724393f03ec982769ad9" +checksum = "cdf59a9b308a1a07dc2eb2f85e6366bc0226dc390b40f3aa0a72d79f1cfe2465" dependencies = [ "arrow", "async-trait", @@ -1739,9 +1739,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a413caa9c5885072b539337aed68488f0291653e8edd7d676c92df2480f6cab0" +checksum = "bd99eac4c6538c708638db43e7a3bd88e0e57955ddb722d420fb9a6d38dfc28f" dependencies = [ "arrow", "datafusion-common", @@ -1752,9 +1752,9 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "189256495dc9cbbb8e20dbcf161f60422e628d201a78df8207e44bd4baefadb6" +checksum = "11aa2c492ac046397b36d57c62a72982aad306495bbcbcdbcabd424d4a2fe245" dependencies = [ "arrow", "arrow-buffer", @@ -1783,9 +1783,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12e73dfee4cd67c4a507ffff4c5a711d39983adf544adbc09c09bf06f789f413" +checksum = "325a00081898945d48d6194d9ca26120e523c993be3bb7c084061a5a2a72e787" dependencies = [ "ahash", "arrow", @@ -1804,9 +1804,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87727bd9e65f4f9ac6d608c9810b7da9eaa3b18b26a4a4b76520592d49020acf" +checksum = "809bbcb1e0dbec5d0ce30d493d135aea7564f1ba4550395f7f94321223df2dae" dependencies = [ "ahash", "arrow", @@ -1817,9 +1817,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5ef761359224b7c2b5a1bfad6296ac63225f8583d08ad18af9ba1a89ac3887" +checksum = "29ebaa5d7024ef45973e0a7db1e9aeaa647936496f4d4061c0448f23d77d6320" dependencies = [ "arrow", "arrow-ord", @@ -1840,9 +1840,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b17dac25dfda2d2a90ff0ad1c054a11fb1523766226bec6e9bd8c410daee2ae" +checksum = "60eab6f39df9ee49a2c7fa38eddc01fa0086ee31b29c7d19f38e72f479609752" dependencies = [ "arrow", "async-trait", @@ -1856,9 +1856,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c594a29ddb22cbdbce500e4d99b5b2392c5cecb4c1086298b41d1ffec14dbb77" +checksum = "e00b2c15e342a90e65a846199c9e49293dd09fe1bcd63d8be2544604892f7eb8" dependencies = [ "arrow", "datafusion-common", @@ -1874,9 +1874,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa1b15ed81c7543f62264a30dd49dec4b1b0b698053b968f53be32dfba4f729" +checksum = "493e2e1d1f4753dfc139a5213f1b5d0b97eea46a82d9bda3c7908aa96981b74b" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -1884,9 +1884,9 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00c31c4795597aa25b74cab5174ac07a53051f27ce1e011ecaffa9eaeecef81" +checksum = "ba01c55ade8278a791b429f7bf5cb1de64de587a342d084b18245edfae7096e2" dependencies = [ "datafusion-doc", "quote", @@ -1895,9 +1895,9 @@ dependencies = [ [[package]] name = "datafusion-optimizer" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80ccf60767c09302b2e0fc3afebb3761a6d508d07316fab8c5e93312728a21bb" +checksum = "a80c6dfbba6a2163a9507f6353ac78c69d8deb26232c9e419160e58ff7c3e047" dependencies = [ "arrow", "chrono", @@ -1914,9 +1914,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64b7f277556944e4edd3558da01d9e9ff9f5416f1c0aa7fee088e57bd141a7e" +checksum = "5d3a86264bb9163e7360b6622e789bc7fcbb43672e78a8493f0bc369a41a57c6" dependencies = [ "ahash", "arrow", @@ -1937,9 +1937,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-adapter" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7abaee372ea2d19c016ee9ef8629c4415257d291cdd152bc7f0b75f28af1b63" +checksum = "3f5e00e524ac33500be6c5eeac940bd3f6b984ba9b7df0cd5f6c34a8a2cc4d6b" dependencies = [ "arrow", "datafusion-common", @@ -1952,9 +1952,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42237efe621f92adc22d111b531fdbc2cc38ca9b5e02327535628fb103ae2157" +checksum = "2ae769ea5d688b4e74e9be5cad6f9d9f295b540825355868a3ab942380dd97ce" dependencies = [ "ahash", "arrow", @@ -1969,9 +1969,9 @@ dependencies = [ [[package]] name = "datafusion-physical-optimizer" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd093498bd1319c6e5c76e9dfa905e78486f01b34579ce97f2e3a49f84c37fac" +checksum = "f3588753ab2b47b0e43cd823fe5e7944df6734dabd6dafb72e2cc1c2a22f1944" dependencies = [ "arrow", "datafusion-common", @@ -1987,9 +1987,9 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cbe61b12daf81a9f20ba03bd3541165d51f86e004ef37426b11881330eed261" +checksum = "79949cbb109c2a45c527bfe0d956b9f2916807c05d4d2e66f3fd0af827ac2b61" dependencies = [ "ahash", "arrow", @@ -2018,9 +2018,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0124331116db7f79df92ebfd2c3b11a8f90240f253555c9bb084f10b6fecf1dd" +checksum = "6434e2ee8a39d04b95fed688ff34dc251af6e4a0c2e1714716b6e3846690d589" dependencies = [ "arrow", "datafusion-common", @@ -2035,9 +2035,9 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1673e3c58ba618a6ea0568672f00664087b8982c581e9afd5aa6c3c79c9b431f" +checksum = "c91efb8302b4877d499c37e9a71886b90236ab27d9cc42fd51112febf341abd6" dependencies = [ "async-trait", "datafusion-common", @@ -2049,9 +2049,9 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5272d256dab5347bb39d2040589f45d8c6b715b27edcb5fffe88cc8b9c3909cb" +checksum = "3f01eef7bcf4d00e87305b55f1b75792384e130fe0258bac02cd48378ae5ff87" dependencies = [ "arrow", "bigdecimal", @@ -2066,9 +2066,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199790fd96e852997b30da4ff11109378c944841757d93875ea85fc69587ec91" +checksum = "fe00df31ca03a167d3e40054120930fe5fb689e66bc625b602fac7153b222aea" dependencies = [ "async-recursion", "async-trait", @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3353,7 +3353,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-arith", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "arrow-buffer", @@ -3439,7 +3439,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrayref", "paste", @@ -3448,7 +3448,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "arrow-buffer", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", @@ -3518,7 +3518,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", @@ -3536,7 +3536,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-arith", "arrow-array", @@ -3573,7 +3573,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-arith", "arrow-array", @@ -3605,7 +3605,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "datafusion", "geo-traits", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-arith", @@ -3686,7 +3686,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-arith", @@ -3727,7 +3727,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-schema", @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "arrow-buffer", @@ -3777,7 +3777,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "async-trait", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-ipc", @@ -3833,7 +3833,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", @@ -4407,9 +4407,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 03477d86acf..e000d503b52 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 08f53df6a51..a778daf9def 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.0.0-beta.9 + 4.0.0-beta.10 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index eaae4d908fa..a4f21f64bc6 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -1665,9 +1665,9 @@ dependencies = [ [[package]] name = "datafusion" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503f1f4a9060ae6e650d3dff5dc7a21266fea1302d890768d45b4b28586e830f" +checksum = "ea28305c211e3541c9cfcf06a23d0d8c7c824b4502ed1fdf0a6ff4ad24ee531c" dependencies = [ "arrow", "arrow-schema", @@ -1720,9 +1720,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14417a3ee4ae3d092b56cd6c1d32e8ff3e2c9ec130ecb2276ec91c89fd599399" +checksum = "78ab99b6df5f60a6ddbc515e4c05caee1192d395cf3cb67ce5d1c17e3c9b9b74" dependencies = [ "arrow", "async-trait", @@ -1745,9 +1745,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0eba824adb45a4b3ac6f0251d40df3f6a9382371cad136f4f14ac9ebc6bc10" +checksum = "77ae3d14912c0d779ada98d30dc60f3244f3c26c2446b87394629ea5c076a31c" dependencies = [ "arrow", "async-trait", @@ -1768,9 +1768,9 @@ dependencies = [ [[package]] name = "datafusion-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0039deefbd00c56adf5168b7ca58568fb058e4ba4c5a03b09f8be371b4e434b6" +checksum = "ea2df29b9592a5d55b8238eaf67d2f21963d5a08cd1a8b7670134405206caabd" dependencies = [ "ahash", "arrow", @@ -1792,9 +1792,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec7e3e60b813048331f8fb9673583173e5d2dd8fef862834ee871fc98b57ca7" +checksum = "42639baa0049d5fffd7e283504b9b5e7b9b2e7a2dea476eed60ab0d40d999b85" dependencies = [ "futures", "log", @@ -1803,9 +1803,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802068957f620302ecf05f84ff4019601aeafd36f5f3f1334984af2e34265129" +checksum = "25951b617bb22a9619e1520450590cb2004bfcad10bcb396b961f4a1a10dcec5" dependencies = [ "arrow", "async-compression", @@ -1838,9 +1838,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-arrow" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90fc387d5067c62d494a6647d29c5ad4fcdd5a6e50ab4ea1d2568caa2d66f2cc" +checksum = "dc0b28226960ba99c50d78ac6f736ebe09eb5cb3bb9bb58194266278000ca41f" dependencies = [ "arrow", "arrow-ipc", @@ -1862,9 +1862,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd5e20579bb6c8bd4e6c620253972fb723822030c280dd6aa047f660d09eeba" +checksum = "f538b57b052a678b1ce860181c65d3ace5a8486312dc50b41c01dd585a773a51" dependencies = [ "arrow", "async-trait", @@ -1885,9 +1885,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0788b0d48fcef31880a02013ea3cc18e5a4e0eacc3b0abdd2cd0597b99dc96e" +checksum = "89fbc1d32b1b03c9734e27c0c5f041232b68621c8455f22769838634750a196c" dependencies = [ "arrow", "async-trait", @@ -1907,9 +1907,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-parquet" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66639b70f1f363f5f0950733170100e588f1acfacac90c1894e231194aa35957" +checksum = "203271d31fe5613a5943181db70ec98162121d1de94a9a300d5e5f19f9500a32" dependencies = [ "arrow", "async-trait", @@ -1937,15 +1937,15 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e44b41f3e8267c6cf3eec982d63f34db9f1dd5f30abfd2e1f124f0871708952e" +checksum = "5b6450dc702b3d39e8ced54c3356abb453bd2f3cea86d90d555a4b92f7a38462" [[package]] name = "datafusion-execution" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e456f60e5d38db45335e84617006d90af14a8c8c5b8e959add708b2daaa0e2c" +checksum = "e66a02fa601de49da5181dbdcf904a18b16a184db2b31f5e5534552ea2d5e660" dependencies = [ "arrow", "async-trait", @@ -1964,9 +1964,9 @@ dependencies = [ [[package]] name = "datafusion-expr" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6507c719804265a58043134580c1c20767e7c23ba450724393f03ec982769ad9" +checksum = "cdf59a9b308a1a07dc2eb2f85e6366bc0226dc390b40f3aa0a72d79f1cfe2465" dependencies = [ "arrow", "async-trait", @@ -1987,9 +1987,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a413caa9c5885072b539337aed68488f0291653e8edd7d676c92df2480f6cab0" +checksum = "bd99eac4c6538c708638db43e7a3bd88e0e57955ddb722d420fb9a6d38dfc28f" dependencies = [ "arrow", "datafusion-common", @@ -2000,9 +2000,9 @@ dependencies = [ [[package]] name = "datafusion-ffi" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca486e22de2bb1512dda751fb490c1cabafa9aec67b456cd4038e812be527f7" +checksum = "f3b7b2b314f996ff30a2c695d74ec8cf9299305ca774269b56caed08652be7b5" dependencies = [ "abi_stable", "arrow", @@ -2030,9 +2030,9 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "189256495dc9cbbb8e20dbcf161f60422e628d201a78df8207e44bd4baefadb6" +checksum = "11aa2c492ac046397b36d57c62a72982aad306495bbcbcdbcabd424d4a2fe245" dependencies = [ "arrow", "arrow-buffer", @@ -2061,9 +2061,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12e73dfee4cd67c4a507ffff4c5a711d39983adf544adbc09c09bf06f789f413" +checksum = "325a00081898945d48d6194d9ca26120e523c993be3bb7c084061a5a2a72e787" dependencies = [ "ahash", "arrow", @@ -2082,9 +2082,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87727bd9e65f4f9ac6d608c9810b7da9eaa3b18b26a4a4b76520592d49020acf" +checksum = "809bbcb1e0dbec5d0ce30d493d135aea7564f1ba4550395f7f94321223df2dae" dependencies = [ "ahash", "arrow", @@ -2095,9 +2095,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5ef761359224b7c2b5a1bfad6296ac63225f8583d08ad18af9ba1a89ac3887" +checksum = "29ebaa5d7024ef45973e0a7db1e9aeaa647936496f4d4061c0448f23d77d6320" dependencies = [ "arrow", "arrow-ord", @@ -2118,9 +2118,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b17dac25dfda2d2a90ff0ad1c054a11fb1523766226bec6e9bd8c410daee2ae" +checksum = "60eab6f39df9ee49a2c7fa38eddc01fa0086ee31b29c7d19f38e72f479609752" dependencies = [ "arrow", "async-trait", @@ -2134,9 +2134,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c594a29ddb22cbdbce500e4d99b5b2392c5cecb4c1086298b41d1ffec14dbb77" +checksum = "e00b2c15e342a90e65a846199c9e49293dd09fe1bcd63d8be2544604892f7eb8" dependencies = [ "arrow", "datafusion-common", @@ -2152,9 +2152,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa1b15ed81c7543f62264a30dd49dec4b1b0b698053b968f53be32dfba4f729" +checksum = "493e2e1d1f4753dfc139a5213f1b5d0b97eea46a82d9bda3c7908aa96981b74b" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -2162,9 +2162,9 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00c31c4795597aa25b74cab5174ac07a53051f27ce1e011ecaffa9eaeecef81" +checksum = "ba01c55ade8278a791b429f7bf5cb1de64de587a342d084b18245edfae7096e2" dependencies = [ "datafusion-doc", "quote", @@ -2173,9 +2173,9 @@ dependencies = [ [[package]] name = "datafusion-optimizer" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80ccf60767c09302b2e0fc3afebb3761a6d508d07316fab8c5e93312728a21bb" +checksum = "a80c6dfbba6a2163a9507f6353ac78c69d8deb26232c9e419160e58ff7c3e047" dependencies = [ "arrow", "chrono", @@ -2193,9 +2193,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64b7f277556944e4edd3558da01d9e9ff9f5416f1c0aa7fee088e57bd141a7e" +checksum = "5d3a86264bb9163e7360b6622e789bc7fcbb43672e78a8493f0bc369a41a57c6" dependencies = [ "ahash", "arrow", @@ -2217,9 +2217,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-adapter" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7abaee372ea2d19c016ee9ef8629c4415257d291cdd152bc7f0b75f28af1b63" +checksum = "3f5e00e524ac33500be6c5eeac940bd3f6b984ba9b7df0cd5f6c34a8a2cc4d6b" dependencies = [ "arrow", "datafusion-common", @@ -2232,9 +2232,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42237efe621f92adc22d111b531fdbc2cc38ca9b5e02327535628fb103ae2157" +checksum = "2ae769ea5d688b4e74e9be5cad6f9d9f295b540825355868a3ab942380dd97ce" dependencies = [ "ahash", "arrow", @@ -2249,9 +2249,9 @@ dependencies = [ [[package]] name = "datafusion-physical-optimizer" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd093498bd1319c6e5c76e9dfa905e78486f01b34579ce97f2e3a49f84c37fac" +checksum = "f3588753ab2b47b0e43cd823fe5e7944df6734dabd6dafb72e2cc1c2a22f1944" dependencies = [ "arrow", "datafusion-common", @@ -2268,9 +2268,9 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cbe61b12daf81a9f20ba03bd3541165d51f86e004ef37426b11881330eed261" +checksum = "79949cbb109c2a45c527bfe0d956b9f2916807c05d4d2e66f3fd0af827ac2b61" dependencies = [ "ahash", "arrow", @@ -2299,9 +2299,9 @@ dependencies = [ [[package]] name = "datafusion-proto" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c055594ab7e3f5430aea1024055bc9bd29ba6479a9cae6fe29823a2f527470" +checksum = "51eb50848366adf7ff2734e767442916bb45b7e739d5edbf33e9e4c47c5fa797" dependencies = [ "arrow", "chrono", @@ -2326,9 +2326,9 @@ dependencies = [ [[package]] name = "datafusion-proto-common" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b2523bb8e7269b943c9060a3ae91c5a61e6b1d800c014a9433547a9ce23e55" +checksum = "b16bf2f2a9f99c48b61537870736f0aa9500c4128407c9999261ff3e8a170523" dependencies = [ "arrow", "datafusion-common", @@ -2337,9 +2337,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0124331116db7f79df92ebfd2c3b11a8f90240f253555c9bb084f10b6fecf1dd" +checksum = "6434e2ee8a39d04b95fed688ff34dc251af6e4a0c2e1714716b6e3846690d589" dependencies = [ "arrow", "datafusion-common", @@ -2354,9 +2354,9 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1673e3c58ba618a6ea0568672f00664087b8982c581e9afd5aa6c3c79c9b431f" +checksum = "c91efb8302b4877d499c37e9a71886b90236ab27d9cc42fd51112febf341abd6" dependencies = [ "async-trait", "datafusion-common", @@ -2368,9 +2368,9 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5272d256dab5347bb39d2040589f45d8c6b715b27edcb5fffe88cc8b9c3909cb" +checksum = "3f01eef7bcf4d00e87305b55f1b75792384e130fe0258bac02cd48378ae5ff87" dependencies = [ "arrow", "bigdecimal", @@ -2386,9 +2386,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "52.2.0" +version = "52.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199790fd96e852997b30da4ff11109378c944841757d93875ea85fc69587ec91" +checksum = "fe00df31ca03a167d3e40054120930fe5fb689e66bc625b602fac7153b222aea" dependencies = [ "async-recursion", "async-trait", @@ -2824,7 +2824,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3911,7 +3911,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-arith", @@ -3978,7 +3978,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "arrow-buffer", @@ -3998,7 +3998,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrayref", "paste", @@ -4007,7 +4007,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "arrow-buffer", @@ -4044,7 +4044,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", @@ -4077,7 +4077,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-arith", "arrow-array", @@ -4132,7 +4132,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-arith", "arrow-array", @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "datafusion", "geo-traits", @@ -4178,7 +4178,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-arith", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-arith", @@ -4289,7 +4289,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow-array", "arrow-buffer", @@ -4305,7 +4305,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "async-trait", @@ -4317,7 +4317,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-ipc", @@ -4361,7 +4361,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", @@ -5159,9 +5159,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" dependencies = [ "arrow", "arrow-array", diff --git a/python/Cargo.toml b/python/Cargo.toml index e901cd5c225..f130b50bc98 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.0.0-beta.9" +version = "4.0.0-beta.10" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 46826b18d14df90d2efebd670f4521ad3468f340 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Thu, 12 Mar 2026 17:04:07 -0700 Subject: [PATCH 018/206] docs: update the rules for data replacement conflicts to reflect reality (#6182) --- docs/src/format/table/transaction.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/format/table/transaction.md b/docs/src/format/table/transaction.md index a78dc3aae64..d1a5191bf54 100644 --- a/docs/src/format/table/transaction.md +++ b/docs/src/format/table/transaction.md @@ -456,6 +456,8 @@ The following operations are retryable conflicts with DataReplacement: - DataReplacement (only if same field and overlapping fragments) - CreateIndex (only if the field being replaced is being indexed) - Rewrite (only if overlapping fragments) +- Update (only if overlapping fragments) +- Merge (always) ### UpdateMemWalState From 0851b146b6fa7b5663573bcdd223d6e16303f98f Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 13 Mar 2026 15:28:08 +0800 Subject: [PATCH 019/206] fix: handle nullable validity layers without def levels (#6187) This fixes the reader panic in #6185 when a page keeps nullable rep/def layer metadata but does not materialize any definition levels. The decoder now treats that page-local state as all-valid and includes a regression test that reproduces the mixed-page case before the fix. Closes #6185. --- rust/lance-encoding/src/repdef.rs | 36 ++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/rust/lance-encoding/src/repdef.rs b/rust/lance-encoding/src/repdef.rs index 5530f78a722..939e20f2050 100644 --- a/rust/lance-encoding/src/repdef.rs +++ b/rust/lance-encoding/src/repdef.rs @@ -1225,7 +1225,7 @@ impl RepDefUnraveler { } pub fn is_all_valid(&self) -> bool { - self.def_meaning[self.current_layer].is_all_valid() + self.def_levels.is_none() || self.def_meaning[self.current_layer].is_all_valid() } /// If the current level is a repetition layer then this returns the number of lists @@ -1400,15 +1400,14 @@ impl RepDefUnraveler { } pub fn skip_validity(&mut self) { - debug_assert!( - self.def_meaning[self.current_layer] == DefinitionInterpretation::AllValidItem - ); + debug_assert!(self.is_all_valid()); self.current_layer += 1; } /// Unravels a layer of validity from the definition levels pub fn unravel_validity(&mut self, validity: &mut BooleanBufferBuilder) { - if self.def_meaning[self.current_layer] == DefinitionInterpretation::AllValidItem { + let meaning = self.def_meaning[self.current_layer]; + if meaning == DefinitionInterpretation::AllValidItem || self.def_levels.is_none() { self.current_layer += 1; validity.append_n(self.num_items as usize, true); return; @@ -3184,4 +3183,31 @@ mod tests { ) ); } + + #[test] + fn test_mixed_unraveler_nullable_without_def_levels() { + // A page can keep nullable layer metadata even when all definition levels are 0 + // and no definition buffer needs to be materialized. This should decode as all-valid. + let mut unraveler = CompositeRepDefUnraveler::new(vec![ + RepDefUnraveler::new( + None, + Some(vec![0, 1, 0, 1]), + vec![DefinitionInterpretation::NullableItem].into(), + 4, + ), + RepDefUnraveler::new( + None, + None, + vec![DefinitionInterpretation::NullableItem].into(), + 4, + ), + ]); + + assert_eq!( + unraveler.unravel_validity(8), + Some(validity(&[ + true, false, true, false, true, true, true, true + ])) + ); + } } From 311ff1ad1b37e784dceed8d30873282ccacdc907 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 13 Mar 2026 17:02:04 +0800 Subject: [PATCH 020/206] fix: preserve merge insert delete-by-source semantics (#6148) This fixes the merge-insert fast path for delete-by-source operations while preserving the existing `UpdateIf` semantics. It also keeps full-schema `FixedSizeList` merges on the optimized path so target-side payload columns are pruned from the join build side. Fix https://github.com/lancedb/lancedb/issues/3094 --- rust/lance/src/dataset/write/merge_insert.rs | 208 +++++++++++++++++- .../write/merge_insert/assign_action.rs | 38 +++- 2 files changed, 230 insertions(+), 16 deletions(-) diff --git a/rust/lance/src/dataset/write/merge_insert.rs b/rust/lance/src/dataset/write/merge_insert.rs index a0f58b78ae2..cfc1e8f0dca 100644 --- a/rust/lance/src/dataset/write/merge_insert.rs +++ b/rust/lance/src/dataset/write/merge_insert.rs @@ -1299,6 +1299,21 @@ impl MergeInsertJob { self.execute_uncommitted_impl(stream).await } + fn create_plan_join_type(&self) -> JoinType { + let keep_unmatched_source_rows = self.params.insert_not_matched; + let keep_unmatched_target_rows = !matches!( + self.params.delete_not_matched_by_source, + WhenNotMatchedBySource::Keep + ); + + match (keep_unmatched_target_rows, keep_unmatched_source_rows) { + (false, false) => JoinType::Inner, + (false, true) => JoinType::Right, + (true, false) => JoinType::Left, + (true, true) => JoinType::Full, + } + } + async fn create_plan( self, source: SendableRecordBatchStream, @@ -1322,11 +1337,7 @@ impl MergeInsertJob { let source_df = session_ctx.read_one_shot(source)?; let source_df_aliased = source_df.alias("source")?; let scan_aliased = scan.alias("target")?; - let join_type = if self.params.insert_not_matched { - JoinType::Right - } else { - JoinType::Inner - }; + let join_type = self.create_plan_join_type(); let dataset_schema: Schema = self.dataset.schema().into(); let df = scan_aliased .join( @@ -1446,7 +1457,7 @@ impl MergeInsertJob { /// - when_matched is UpdateAll or UpdateIf or Fail /// - Either use_index is false OR there's no scalar index on join key /// - Source schema matches dataset schema exactly - /// - when_not_matched_by_source is Keep + /// - when_not_matched_by_source is Keep, Delete, or DeleteIf async fn can_use_create_plan(&self, source_schema: &Schema) -> Result { // Convert to lance schema for comparison let lance_schema = lance_core::datatypes::Schema::try_from(source_schema)?; @@ -1492,6 +1503,8 @@ impl MergeInsertJob { && matches!( self.params.delete_not_matched_by_source, WhenNotMatchedBySource::Keep + | WhenNotMatchedBySource::Delete + | WhenNotMatchedBySource::DeleteIf(_) )) } @@ -1733,7 +1746,7 @@ impl MergeInsertJob { // Check if we can use create_plan if !self.can_use_create_plan(&schema).await? { - return Err(Error::not_supported_source("This merge insert configuration does not support explain_plan. Only upsert operations with full schema, no scalar index, and keeping unmatched rows are supported.".into())); + return Err(Error::not_supported_source("This merge insert configuration does not support explain_plan. Only full-schema merge insert operations without a scalar-index execution path are currently supported.".into())); } // Create an empty batch with the provided schema to pass to create_plan @@ -1773,7 +1786,7 @@ impl MergeInsertJob { pub async fn analyze_plan(&self, source: SendableRecordBatchStream) -> Result { // Check if we can use create_plan if !self.can_use_create_plan(source.schema().as_ref()).await? { - return Err(Error::not_supported_source("This merge insert configuration does not support analyze_plan. Only upsert operations with full schema, no scalar index, and keeping unmatched rows are supported.".into())); + return Err(Error::not_supported_source("This merge insert configuration does not support analyze_plan. Only full-schema merge insert operations without a scalar-index execution path are currently supported.".into())); } // Clone self since create_plan consumes the job @@ -2716,6 +2729,18 @@ mod tests { check_then_refresh_dataset(new_batch.clone(), job, &[], &[4, 5, 6, 7, 8, 9], &[3, 3, 3]) .await; + // conditional upsert, with delete all + let job = MergeInsertBuilder::try_new(ds.clone(), keys.clone()) + .unwrap() + .when_matched( + WhenMatched::update_if(&ds, "source.filterme != target.filterme").unwrap(), + ) + .when_not_matched_by_source(WhenNotMatchedBySource::Delete) + .try_build() + .unwrap(); + check_then_refresh_dataset(new_batch.clone(), job, &[4, 5], &[6, 7, 8, 9], &[3, 1, 3]) + .await; + // update only, with delete all (unusual) let job = MergeInsertBuilder::try_new(ds.clone(), keys.clone()) .unwrap() @@ -2768,6 +2793,24 @@ mod tests { ) .await; + // conditional upsert, with delete some + let job = MergeInsertBuilder::try_new(ds.clone(), keys.clone()) + .unwrap() + .when_matched( + WhenMatched::update_if(&ds, "source.filterme != target.filterme").unwrap(), + ) + .when_not_matched_by_source(WhenNotMatchedBySource::DeleteIf(condition.clone())) + .try_build() + .unwrap(); + check_then_refresh_dataset( + new_batch.clone(), + job, + &[1, 4, 5], + &[6, 7, 8, 9], + &[3, 1, 2], + ) + .await; + // update only, witxh delete some (unusual) let job = MergeInsertBuilder::try_new(ds.clone(), keys.clone()) .unwrap() @@ -5312,6 +5355,155 @@ MergeInsert: on=[id], when_matched=UpdateAll, when_not_matched=InsertAll, when_n assert_string_matches(&verbose_plan, expected_pattern).unwrap(); } + #[tokio::test] + async fn test_explain_plan_full_schema_delete_by_source_with_fsl() { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vec", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + true, + ), + ])); + + let dataset_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2, 3])), + Arc::new( + FixedSizeListArray::try_new_from_values( + Float32Array::from(vec![ + 1.0, 1.1, 1.2, 1.3, 2.0, 2.1, 2.2, 2.3, 3.0, 3.1, 3.2, 3.3, + ]), + 4, + ) + .unwrap(), + ), + ], + ) + .unwrap(); + + let dataset = Dataset::write( + Box::new(RecordBatchIterator::new( + [Ok(dataset_batch)], + schema.clone(), + )), + "memory://test_explain_plan_full_schema_delete_by_source_with_fsl", + None, + ) + .await + .unwrap(); + + let merge_insert_job = + MergeInsertBuilder::try_new(Arc::new(dataset), vec!["id".to_string()]) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::InsertAll) + .when_not_matched_by_source(WhenNotMatchedBySource::Delete) + .use_index(false) + .try_build() + .unwrap(); + + let plan = merge_insert_job.explain_plan(None, false).await.unwrap(); + assert!(plan.contains("HashJoinExec")); + assert!(plan.contains("join_type=Full")); + assert!(plan.contains("projection=[_rowid")); + assert!( + plan.contains("LanceRead: uri=") && plan.contains("projection=[id]"), + "target-side scan should prune the FSL payload from the join build side: {plan}" + ); + assert!( + !plan.contains("LanceRead: uri=test_explain_plan_full_schema_delete_by_source_with_fsl/data, projection=[id, vec]"), + "target-side scan should not include the FSL payload in the join build side: {plan}" + ); + } + + #[tokio::test] + async fn test_merge_insert_full_schema_delete_by_source_with_fsl() { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vec", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + true, + ), + ])); + + let dataset_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2, 3])), + Arc::new( + FixedSizeListArray::try_new_from_values( + Float32Array::from(vec![ + 1.0, 1.1, 1.2, 1.3, 2.0, 2.1, 2.2, 2.3, 3.0, 3.1, 3.2, 3.3, + ]), + 4, + ) + .unwrap(), + ), + ], + ) + .unwrap(); + + let dataset = Dataset::write( + Box::new(RecordBatchIterator::new( + [Ok(dataset_batch)], + schema.clone(), + )), + "memory://test_merge_insert_full_schema_delete_by_source_with_fsl", + None, + ) + .await + .unwrap(); + + let source_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![2, 4])), + Arc::new( + FixedSizeListArray::try_new_from_values( + Float32Array::from(vec![20.0, 20.1, 20.2, 20.3, 40.0, 40.1, 40.2, 40.3]), + 4, + ) + .unwrap(), + ), + ], + ) + .unwrap(); + + let (merged_dataset, stats) = + MergeInsertBuilder::try_new(Arc::new(dataset), vec!["id".to_string()]) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::InsertAll) + .when_not_matched_by_source(WhenNotMatchedBySource::Delete) + .try_build() + .unwrap() + .execute_reader(Box::new(RecordBatchIterator::new( + [Ok(source_batch)], + schema.clone(), + ))) + .await + .unwrap(); + + assert_eq!(stats.num_deleted_rows, 2); + assert_eq!(stats.num_updated_rows, 1); + assert_eq!(stats.num_inserted_rows, 1); + + let merged = merged_dataset.scan().try_into_batch().await.unwrap(); + let ids = merged["id"].as_primitive::().values().to_vec(); + assert_eq!(ids, vec![2, 4]); + + let vecs = merged["vec"].as_fixed_size_list(); + let actual = vecs + .values() + .as_primitive::() + .values() + .to_vec(); + assert_eq!(actual, vec![20.0, 20.1, 20.2, 20.3, 40.0, 40.1, 40.2, 40.3]); + } + #[tokio::test] async fn test_analyze_plan() { // Set up test data using lance_datagen diff --git a/rust/lance/src/dataset/write/merge_insert/assign_action.rs b/rust/lance/src/dataset/write/merge_insert/assign_action.rs index e4ec637c1cf..75d8932a70c 100644 --- a/rust/lance/src/dataset/write/merge_insert/assign_action.rs +++ b/rust/lance/src/dataset/write/merge_insert/assign_action.rs @@ -3,6 +3,10 @@ use super::{MergeInsertParams, WhenNotMatchedBySource}; use crate::{Result, dataset::WhenMatched}; +use datafusion::common::{ + Column, TableReference, + tree_node::{Transformed, TransformedResult, TreeNode}, +}; use datafusion::scalar::ScalarValue; use datafusion_expr::{Case, Expr, col}; @@ -50,6 +54,24 @@ impl Action { } } +fn qualify_unqualified_columns(expr: Expr, relation: &'static str) -> Result { + expr.transform(|expr| { + Ok(if let Expr::Column(column) = expr { + if column.relation.is_none() { + let qualified = Column::new_unqualified(column.name) + .with_relation(TableReference::bare(relation)); + Transformed::yes(Expr::Column(qualified)) + } else { + Transformed::no(Expr::Column(column)) + } + } else { + Transformed::no(expr) + }) + }) + .data() + .map_err(crate::Error::from) +} + /// Transforms merge insert parameters into a logical expression. The output /// is a single "action" column, that describes what to do with each row. pub fn merge_insert_action( @@ -80,18 +102,17 @@ pub fn merge_insert_action( .unwrap_or_else(|| datafusion_expr::lit(false)) }; - let row_addr_is_not_null = col("target._rowaddr").is_not_null(); - let matched = source_has_key.clone().and(row_addr_is_not_null); + let target_has_row = col("target._rowaddr").is_not_null(); + let matched = source_has_key.clone().and(target_has_row.clone()); - let row_addr_is_null = col("target._rowaddr").is_null(); - let not_matched_in_target = source_has_key.and(row_addr_is_null); + let source_only = source_has_key.clone().and(col("target._rowaddr").is_null()); - let not_matched_in_source = col("target._rowaddr").is_null().is_not_true(); + let target_only = target_has_row.and(source_has_key.is_not_true()); let mut cases = vec![]; if params.insert_not_matched { - cases.push((not_matched_in_target, Action::Insert.as_literal_expr())); + cases.push((source_only, Action::Insert.as_literal_expr())); } match ¶ms.when_matched { @@ -130,11 +151,12 @@ pub fn merge_insert_action( match ¶ms.delete_not_matched_by_source { WhenNotMatchedBySource::Delete => { - cases.push((not_matched_in_source, Action::Delete.as_literal_expr())); + cases.push((target_only, Action::Delete.as_literal_expr())); } WhenNotMatchedBySource::DeleteIf(condition) => { + let target_condition = qualify_unqualified_columns(condition.clone(), "target")?; cases.push(( - not_matched_in_source.and(condition.clone()), + target_only.and(target_condition), Action::Delete.as_literal_expr(), )); } From 29075c9efeec8b53a338f3261c6804b193e67877 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 13 Mar 2026 17:02:51 +0800 Subject: [PATCH 021/206] fix: use to_arrow_reader in benchmark datagen (#6190) This updates the benchmark TPC-H datagen path to use DuckDB's `to_arrow_reader()` API instead of the deprecated `fetch_arrow_reader()` call. The benchmark CI treats `DeprecationWarning` as an error, so this removes the warning that was breaking the random access benchmark job. I also dropped a leftover `print(ds.count_rows())` debug statement to keep benchmark logs clean. --- python/python/ci_benchmarks/datagen/lineitems.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/python/ci_benchmarks/datagen/lineitems.py b/python/python/ci_benchmarks/datagen/lineitems.py index 9a6bb27eaed..19e9e1fe745 100644 --- a/python/python/ci_benchmarks/datagen/lineitems.py +++ b/python/python/ci_benchmarks/datagen/lineitems.py @@ -21,14 +21,13 @@ def _gen_data(tmpdir: str, scale_factor: int): con.execute("INSTALL tpch; LOAD tpch") con.execute(f"CALL dbgen(sf={scale_factor})") res = con.query("SELECT * FROM lineitem") - return res.fetch_arrow_reader() + return res.to_arrow_reader() def _create(dataset_uri: str, data_storage_version: str, scale_factor: int = 10): tmpdir = tempfile.mkdtemp(prefix=f"tpch-scale-factor-{scale_factor}-") try: ds = lance.dataset(dataset_uri) - print(ds.count_rows()) if ds.count_rows() == NUM_ROWS: return elif ds.count_rows() == 0: From e675fdbfd88da6ad6e8806f3c8d9cd8eb58e9ace Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Fri, 13 Mar 2026 21:57:30 +0800 Subject: [PATCH 022/206] perf(inverted): reuse posting batch builder and merge tail partitions (#6191) Signed-off-by: BubbleCal --- .../src/scalar/inverted/builder.rs | 149 +++++++----------- rust/lance-index/src/scalar/inverted/index.rs | 147 +++++++++++++++++ 2 files changed, 207 insertions(+), 89 deletions(-) diff --git a/rust/lance-index/src/scalar/inverted/builder.rs b/rust/lance-index/src/scalar/inverted/builder.rs index 39171d1b720..b7cbf0b2033 100644 --- a/rust/lance-index/src/scalar/inverted/builder.rs +++ b/rust/lance-index/src/scalar/inverted/builder.rs @@ -64,6 +64,12 @@ static LANCE_FTS_WRITE_QUEUE_SIZE: LazyLock = LazyLock::new(|| { .parse() .expect("failed to parse LANCE_FTS_WRITE_QUEUE_SIZE") }); +static LANCE_FTS_POSTING_BATCH_ROWS: LazyLock = LazyLock::new(|| { + std::env::var("LANCE_FTS_POSTING_BATCH_ROWS") + .unwrap_or_else(|_| "256".to_string()) + .parse() + .expect("failed to parse LANCE_FTS_POSTING_BATCH_ROWS") +}); fn default_num_workers() -> usize { let total_cpus = get_num_compute_intensive_cpus() + *IO_CORE_RESERVATION; @@ -86,32 +92,11 @@ fn resolve_worker_memory_limit_bytes(params: &InvertedIndexParams, num_workers: .unwrap_or(default_worker_memory_limit_bytes) } -fn group_tail_partitions( - tails: Vec, - worker_memory_limit_bytes: u64, -) -> Vec> { - let mut groups = Vec::new(); - let mut current_group = Vec::new(); - let mut current_memory_size = 0u64; - - for tail in tails { - if !current_group.is_empty() - && current_memory_size.saturating_add(tail.memory_size_bytes) - > worker_memory_limit_bytes - { - groups.push(current_group); - current_group = Vec::new(); - current_memory_size = 0; - } - current_memory_size = current_memory_size.saturating_add(tail.memory_size_bytes); - current_group.push(tail); +fn merge_all_tail_partitions(tails: Vec) -> Result> { + if tails.is_empty() { + return Ok(None); } - - if !current_group.is_empty() { - groups.push(current_group); - } - - groups + merge_tail_partition_group(tails).map(Some) } fn merge_tail_partition_group(group: Vec) -> Result { @@ -314,12 +299,9 @@ impl InvertedIndexBuilder { tail_partitions.push(tail_partition); } } - let merge_tasks = group_tail_partitions(tail_partitions, worker_memory_limit_bytes) - .into_iter() - .map(|group| spawn_cpu(move || merge_tail_partition_group(group))) - .collect::>(); - for merge_task in merge_tasks { - let builder = merge_task.await?; + let merged_tail_partitions = + spawn_cpu(move || merge_all_tail_partitions(tail_partitions)).await?; + if let Some(builder) = merged_tail_partitions { self.new_partitions.push(builder.id()); builder_tx.send(builder).await.map_err(|err| { Error::execution(format!( @@ -619,13 +601,14 @@ impl InnerBuilder { let new_token_id = token_id_map[token_id]; debug_assert_ne!(new_token_id, u32::MAX); let merged_posting = &mut self.posting_lists[new_token_id as usize]; - for (doc_id, freq, positions) in posting_list.iter() { + posting_list.for_each_entry(|doc_id, freq, positions| { let positions = match positions { Some(positions) => PositionRecorder::Position(positions.into()), None => PositionRecorder::Count(freq), }; merged_posting.add(doc_id_offset + doc_id, positions); - } + Ok::<(), Error>(()) + })?; } Ok(()) @@ -660,44 +643,48 @@ impl InnerBuilder { id, self.with_position ); + let with_position = self.with_position; let schema = inverted_list_schema(self.with_position); let docs_for_batches = docs.clone(); let schema_for_batches = schema.clone(); + let batch_rows = *LANCE_FTS_POSTING_BATCH_ROWS; let (tx, rx) = async_channel::bounded(*LANCE_FTS_WRITE_QUEUE_SIZE); let producer = spawn_cpu(move || { + let mut batch_builder = + PostingListBatchBuilder::new(schema_for_batches.clone(), with_position, batch_rows); for posting_list in posting_lists { - let batch = posting_list - .to_batch_with_docs(&docs_for_batches, schema_for_batches.clone())?; + posting_list.append_to_batch_with_docs(&docs_for_batches, &mut batch_builder)?; + if batch_builder.len() < batch_rows { + continue; + } + + let batch = batch_builder.finish()?; if let Err(err) = tx.send_blocking(batch) { return Err(Error::execution(format!( "failed to send posting list batch to writer: {err}" ))); } } + + if !batch_builder.is_empty() { + let batch = batch_builder.finish()?; + if let Err(err) = tx.send_blocking(batch) { + return Err(Error::execution(format!( + "failed to send posting list batch to writer: {err}" + ))); + } + } + Result::Ok(()) }); - let mut write_duration = std::time::Duration::ZERO; - let mut num_posting_lists = 0; while let Ok(batch) = rx.recv().await { - num_posting_lists += 1; - let start = std::time::Instant::now(); if let Err(err) = writer.write_record_batch(batch).await { drop(rx); // Wait for producer to stop; preserve the write error as the primary failure. let _ = producer.await; return Err(err); } - write_duration += start.elapsed(); - - if num_posting_lists % 500_000 == 0 { - log::info!( - "wrote {} posting lists of partition {}, writing elapsed: {:?}", - num_posting_lists, - id, - write_duration, - ); - } } drop(rx); producer.await?; @@ -752,7 +739,6 @@ struct IndexWorker { struct TailPartition { builder: InnerBuilder, - memory_size_bytes: u64, } struct WorkerOutput { @@ -1009,7 +995,6 @@ impl IndexWorker { } else { Some(TailPartition { builder: self.builder, - memory_size_bytes: self.memory_size, }) }; Ok(WorkerOutput { @@ -1612,7 +1597,7 @@ mod tests { } #[tokio::test] - async fn test_write_posting_lists_writes_each_batch() -> Result<()> { + async fn test_write_posting_lists_batches_multiple_rows() -> Result<()> { let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default()); for doc_id in 0..3u64 { builder.docs.append(doc_id, 1); @@ -1628,7 +1613,7 @@ mod tests { let docs = Arc::new(std::mem::take(&mut builder.docs)); builder.write_posting_lists(&store, docs).await?; - assert_eq!(store.write_count(), 3); + assert_eq!(store.write_count(), 1); Ok(()) } @@ -2032,35 +2017,27 @@ mod tests { } #[test] - fn test_group_tail_partitions_uses_worker_memory_limit() { - let groups = group_tail_partitions( - vec![ - TailPartition { - builder: InnerBuilder::new(0, false, TokenSetFormat::default()), - memory_size_bytes: 40, - }, - TailPartition { - builder: InnerBuilder::new(1, false, TokenSetFormat::default()), - memory_size_bytes: 50, - }, - TailPartition { - builder: InnerBuilder::new(2, false, TokenSetFormat::default()), - memory_size_bytes: 30, - }, - ], - 80, - ); + fn test_merge_all_tail_partitions_combines_everything() -> Result<()> { + let merged = merge_all_tail_partitions(vec![ + TailPartition { + builder: InnerBuilder::new(0, false, TokenSetFormat::default()), + }, + TailPartition { + builder: InnerBuilder::new(1, false, TokenSetFormat::default()), + }, + TailPartition { + builder: InnerBuilder::new(2, false, TokenSetFormat::default()), + }, + ])?; - let grouped_ids = groups - .iter() - .map(|group| { - group - .iter() - .map(|tail| tail.builder.id()) - .collect::>() - }) - .collect::>(); - assert_eq!(grouped_ids, vec![vec![0], vec![1, 2]]); + assert_eq!(merged.expect("merged builder should exist").id(), 0); + Ok(()) + } + + #[test] + fn test_merge_all_tail_partitions_returns_none_for_empty_input() -> Result<()> { + assert!(merge_all_tail_partitions(Vec::new())?.is_none()); + Ok(()) } #[test] @@ -2082,14 +2059,8 @@ mod tests { second.posting_lists[world as usize].add(second_doc, PositionRecorder::Count(2)); let merged = merge_tail_partition_group(vec![ - TailPartition { - builder: first, - memory_size_bytes: 10, - }, - TailPartition { - builder: second, - memory_size_bytes: 20, - }, + TailPartition { builder: first }, + TailPartition { builder: second }, ])?; assert_eq!(merged.id(), 0); diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index 19cb68ae82c..53ef5fc6a66 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -2016,6 +2016,92 @@ pub struct PostingListBuilder { len: u32, } +pub(super) struct PostingListBatchBuilder { + schema: SchemaRef, + postings: ListBuilder, + max_scores: Float32Builder, + lengths: UInt32Builder, + positions: Option>>, + len: usize, +} + +impl PostingListBatchBuilder { + pub fn new(schema: SchemaRef, with_positions: bool, capacity: usize) -> Self { + let positions = with_positions.then(|| { + ListBuilder::with_capacity( + ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity), + capacity, + ) + }); + Self { + schema, + postings: ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity), + max_scores: Float32Builder::with_capacity(capacity), + lengths: UInt32Builder::with_capacity(capacity), + positions, + len: 0, + } + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + fn append( + &mut self, + compressed: LargeBinaryArray, + max_score: f32, + length: u32, + positions: Option<&[CompressedDocPositions]>, + ) -> Result<()> { + { + let values = self.postings.values(); + for index in 0..compressed.len() { + values.append_value(compressed.value(index)); + } + } + self.postings.append(true); + self.max_scores.append_value(max_score); + self.lengths.append_value(length); + + if let Some(position_builder) = self.positions.as_mut() { + let positions = positions.ok_or_else(|| { + Error::index("positions builder missing position data".to_owned()) + })?; + { + let docs_builder = position_builder.values(); + for compressed_doc in positions { + for block in &compressed_doc.blocks { + docs_builder.values().append_value(block); + } + docs_builder.append(true); + } + } + position_builder.append(true); + } + + self.len += 1; + Ok(()) + } + + pub fn finish(&mut self) -> Result { + let mut columns = vec![ + Arc::new(self.postings.finish()) as ArrayRef, + Arc::new(self.max_scores.finish()) as ArrayRef, + Arc::new(self.lengths.finish()) as ArrayRef, + ]; + if let Some(positions) = self.positions.as_mut() { + columns.push(Arc::new(positions.finish()) as ArrayRef); + } + self.len = 0; + RecordBatch::try_new(self.schema.clone(), columns).map_err(Error::from) + } +} + impl PostingListBuilder { pub fn size(&self) -> u64 { self.memory_size_bytes as u64 @@ -2047,6 +2133,43 @@ impl PostingListBuilder { self.collect_entries().into_iter() } + pub fn for_each_entry( + &self, + mut visit: impl FnMut(u32, u32, Option>) -> std::result::Result<(), E>, + ) -> std::result::Result<(), E> { + let mut position_index = 0usize; + let mut doc_ids = Vec::with_capacity(BLOCK_SIZE); + let mut frequencies = Vec::with_capacity(BLOCK_SIZE); + + if let Some(encoded_blocks) = self.encoded_blocks.as_deref() { + for block in encoded_blocks.iter() { + doc_ids.clear(); + frequencies.clear(); + super::encoding::decode_full_posting_block(block, &mut doc_ids, &mut frequencies); + for (doc_id, frequency) in doc_ids.iter().copied().zip(frequencies.iter().copied()) + { + let positions = self.positions.as_ref().map(|positions| { + let decompressed = positions[position_index].decompress(); + position_index += 1; + decompressed + }); + visit(doc_id, frequency, positions)?; + } + } + } + + for entry in &self.tail_entries { + let positions = self.positions.as_ref().map(|positions| { + let decompressed = positions[position_index].decompress(); + position_index += 1; + decompressed + }); + visit(entry.doc_id, entry.frequency, positions)?; + } + + Ok(()) + } + pub fn add(&mut self, doc_id: u32, term_positions: PositionRecorder) { let tail_entries_capacity_before = self.tail_entries.capacity(); self.tail_entries @@ -2219,6 +2342,30 @@ impl PostingListBuilder { Ok(batch) } + pub(super) fn append_to_batch_with_docs( + self, + docs: &DocSet, + batch_builder: &mut PostingListBatchBuilder, + ) -> Result<()> { + let Self { + encoded_blocks, + positions, + tail_entries, + len, + .. + } = self; + let length = len as usize; + let (compressed, max_score) = Self::build_compressed_with_scores_from_parts( + length, + encoded_blocks + .map(|encoded_blocks| *encoded_blocks) + .unwrap_or_default(), + tail_entries.as_slice(), + docs, + )?; + batch_builder.append(compressed, max_score, len, positions.as_deref()) + } + fn extend_tail_components( tail_entries: &[RawDocInfo], doc_ids: &mut Vec, From 7aa6d334e78c9fab130f61a977676a4fd96d3858 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Fri, 13 Mar 2026 10:46:34 -0700 Subject: [PATCH 023/206] refactor: rename arrow-scalar to lance-arrow-scalar (#6199) In retrospect the old name was somewhat presumptuous. It would probably be good to get the Arrow project's permission before taking up cargo real estate. This also adds a README which was preventing the publish. --- Cargo.lock | 32 ++++++++++---------- Cargo.toml | 2 +- rust/arrow-scalar/Cargo.toml | 2 +- rust/arrow-scalar/README.md | 57 ++++++++++++++++++++++++++++++++++++ rust/arrow-scalar/src/lib.rs | 2 +- 5 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 rust/arrow-scalar/README.md diff --git a/Cargo.lock b/Cargo.lock index 21d0b06acf1..246b5f2bd22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,22 +406,6 @@ dependencies = [ "half", ] -[[package]] -name = "arrow-scalar" -version = "57.0.0" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", - "arrow-ord", - "arrow-row", - "arrow-schema", - "half", - "proptest", - "rstest", -] - [[package]] name = "arrow-schema" version = "57.3.0" @@ -4872,6 +4856,22 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "lance-arrow-scalar" +version = "57.0.0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "half", + "proptest", + "rstest", +] + [[package]] name = "lance-bitpacking" version = "4.0.0-beta.10" diff --git a/Cargo.toml b/Cargo.toml index e2918c0c9dd..c112356bcb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ lance-testing = { version = "=4.0.0-beta.10", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } -arrow-scalar = { version = "=57.0.0", path = "./rust/arrow-scalar" } +lance-arrow-scalar = { version = "=57.0.0", path = "./rust/arrow-scalar" } arrow-arith = "57.0.0" arrow-array = "57.0.0" arrow-buffer = "57.0.0" diff --git a/rust/arrow-scalar/Cargo.toml b/rust/arrow-scalar/Cargo.toml index c3d3f9181c3..f127e6c0bfa 100644 --- a/rust/arrow-scalar/Cargo.toml +++ b/rust/arrow-scalar/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "arrow-scalar" +name = "lance-arrow-scalar" version = "57.0.0" edition.workspace = true authors.workspace = true diff --git a/rust/arrow-scalar/README.md b/rust/arrow-scalar/README.md new file mode 100644 index 00000000000..7173a116262 --- /dev/null +++ b/rust/arrow-scalar/README.md @@ -0,0 +1,57 @@ +# lance-arrow-scalar + +A scalar type backed by Apache Arrow arrays with `Ord`, `Hash`, and `Eq` support. + +## Overview + +`ArrowScalar` wraps a single-element Arrow array and provides comparison and hashing operations by leveraging Apache Arrow's `OwnedRow` representation. This ensures: + +- **Correct total ordering** for all Arrow types +- **Proper NaN handling** for floating-point values +- **Consistent null ordering** +- **O(1) comparisons** via cached row bytes + +## Features + +- `Eq`, `Ord`, and `Hash` traits for Arrow scalar values +- Support for all Arrow data types +- Serde serialization/deserialization support +- Zero-copy conversion from Arrow arrays + +## Usage + +Add to your `Cargo.toml`: + +```toml +[dependencies] +lance-arrow-scalar = "57.0.0" +``` + +Then use in your code: + +```rust +use lance_arrow_scalar::ArrowScalar; + +// Create from primitive types +let a = ArrowScalar::from(42i32); +let b = ArrowScalar::from(100i32); +assert!(a < b); + +// Create from strings +let s1 = ArrowScalar::from("hello"); +let s2 = ArrowScalar::from("world"); +assert!(s1 < s2); + +// Use in collections +use std::collections::HashMap; +let mut map = HashMap::new(); +map.insert(ArrowScalar::from("key"), ArrowScalar::from(123)); +``` + +## Cross-Type Comparison + +Comparing scalars of different data types produces an arbitrary but consistent ordering based on the underlying row bytes. This allows scalars to be used as keys in sorted collections regardless of type, though the ordering across types is not semantically meaningful. + +## Implementation Details + +Comparisons and hashing are delegated to [`arrow_row::OwnedRow`], which provides efficient byte-level operations. The row representation is cached at construction time, making all comparison and hashing operations O(1). diff --git a/rust/arrow-scalar/src/lib.rs b/rust/arrow-scalar/src/lib.rs index bcb72e0c2aa..04246589296 100644 --- a/rust/arrow-scalar/src/lib.rs +++ b/rust/arrow-scalar/src/lib.rs @@ -40,7 +40,7 @@ type Result = std::result::Result; /// # Examples /// /// ``` -/// use arrow_scalar::ArrowScalar; +/// use lance_arrow_scalar::ArrowScalar; /// /// let a = ArrowScalar::from(1i32); /// let b = ArrowScalar::from(2i32); From 71c6e2184dff7cd6a69bcda1ee18914586c28fcf Mon Sep 17 00:00:00 2001 From: ndpvt-web Date: Sat, 14 Mar 2026 03:41:59 +0800 Subject: [PATCH 024/206] feat: support vector indices in describe_indices filtering (#6145) ## Summary Closes #6138 This PR extends `index_matches_criteria()` in `rust/lance/src/index/scalar.rs` to handle vector index types in addition to scalar indices. ## Problem Previously, `index_matches_criteria()` contained an early return at lines 464-467 that rejected all non-scalar (vector) indices. This made it impossible to use `describe_indices` to filter for vector indices on a specific column. ## Solution - Removed the early return that rejected all vector indices - Refactored FTS and exact equality checks to only apply to scalar indices (these checks are not relevant for vector indices) - Vector indices now pass through when matching basic criteria (name and column filters) ## Changes - 1 file modified: `rust/lance/src/index/scalar.rs` - 15 lines added, 16 lines removed - Updated existing test `test_index_matches_criteria_vector_index()` to reflect the new expected behavior ## Testing - Updated the existing unit test for vector index criteria matching - The test now correctly expects vector indices to match basic criteria instead of being rejected ## AI Disclosure This contribution was developed with the assistance of Claude (AI by Anthropic). The implementation approach, code, and PR description were AI-assisted. All changes are focused on resolving the specific issue described above. Co-Authored-By: AI Assistant (Claude) Signed-off-by: ndpvt-web Co-authored-by: ndpvt-web Co-authored-by: AI Assistant (Claude) --- rust/lance/src/index/scalar.rs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/rust/lance/src/index/scalar.rs b/rust/lance/src/index/scalar.rs index 0365f306c9b..c9194ce92eb 100644 --- a/rust/lance/src/index/scalar.rs +++ b/rust/lance/src/index/scalar.rs @@ -461,22 +461,20 @@ pub fn index_matches_criteria( return Ok(true); }; - if index_details.is_vector() { - // This method is only for finding matching scalar indexes today so reject any vector indexes - return Ok(false); - } - - if criteria.must_support_fts && !index_details.supports_fts() { - return Ok(false); - } - - // We should not use FTS / NGram indices for exact equality queries - // (i.e. merge insert with a join on the indexed column) - if criteria.must_support_exact_equality { - let plugin = index_details.get_plugin()?; - if !plugin.provides_exact_answer() { + // Only apply scalar-specific checks to scalar indices + if !index_details.is_vector() { + if criteria.must_support_fts && !index_details.supports_fts() { return Ok(false); } + + // We should not use FTS / NGram indices for exact equality queries + // (i.e. merge insert with a join on the indexed column) + if criteria.must_support_exact_equality { + let plugin = index_details.get_plugin()?; + if !plugin.provides_exact_answer() { + return Ok(false); + } + } } Ok(true) } @@ -615,11 +613,12 @@ mod tests { fields: vec![field.clone()], metadata: Default::default(), }; + // Vector indices should now match basic criteria let result = index_matches_criteria(&index1, &criteria, &[&field], true, &schema).unwrap(); - assert!(!result); + assert!(result); let result = index_matches_criteria(&index1, &criteria, &[&field], false, &schema).unwrap(); - assert!(!result); + assert!(result); } #[test] From c9c8c465fdb59f927455207a273d2fd8d9eda944 Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Sat, 14 Mar 2026 04:35:55 +0800 Subject: [PATCH 025/206] fix: memory_limit and num_workers params are not passed to index worker (#6197) Signed-off-by: BubbleCal --- .../src/scalar/inverted/tokenizer.rs | 34 +++++++++++++++++++ rust/lance/src/index/create.rs | 20 +++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/rust/lance-index/src/scalar/inverted/tokenizer.rs b/rust/lance-index/src/scalar/inverted/tokenizer.rs index efd317e7393..4da46b60670 100644 --- a/rust/lance-index/src/scalar/inverted/tokenizer.rs +++ b/rust/lance-index/src/scalar/inverted/tokenizer.rs @@ -314,6 +314,27 @@ impl InvertedIndexParams { self } + /// Serialize params for the build/training path, including build-only fields. + pub fn to_training_json(&self) -> serde_json::Result { + let mut value = serde_json::to_value(self)?; + let object = value + .as_object_mut() + .expect("inverted index params should serialize to a JSON object"); + if let Some(memory_limit_mb) = self.memory_limit_mb { + object.insert( + "memory_limit".to_string(), + serde_json::Value::from(memory_limit_mb), + ); + } + if let Some(num_workers) = self.num_workers { + object.insert( + "num_workers".to_string(), + serde_json::Value::from(num_workers), + ); + } + Ok(value) + } + pub fn build(&self) -> Result> { let mut builder = self.build_base_tokenizer()?; if let Some(max_token_length) = self.max_token_length { @@ -458,4 +479,17 @@ mod tests { assert_eq!(params.memory_limit_mb, Some(4096)); assert_eq!(params.num_workers, Some(3)); } + + #[test] + fn test_training_json_serializes_build_only_fields() { + let params = InvertedIndexParams::default() + .memory_limit_mb(4096) + .num_workers(3); + let json = params.to_training_json().unwrap(); + assert_eq!( + json.get("memory_limit"), + Some(&serde_json::Value::from(4096)) + ); + assert_eq!(json.get("num_workers"), Some(&serde_json::Value::from(3))); + } } diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index b8066d68953..be76b9a1878 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -278,8 +278,8 @@ impl<'a> CreateIndexBuilder<'a> { ) })?; - let params = - ScalarIndexParams::new("inverted".to_string()).with_params(inverted_params); + let params = ScalarIndexParams::new("inverted".to_string()) + .with_params(&inverted_params.to_training_json()?); build_scalar_index( self.dataset, column, @@ -489,6 +489,22 @@ mod tests { use lance_linalg::distance::MetricType; use std::sync::Arc; + #[test] + fn test_inverted_training_params_include_build_only_fields() { + let params = InvertedIndexParams::default() + .memory_limit_mb(4096) + .num_workers(7); + let scalar_params = ScalarIndexParams::new("inverted".to_string()) + .with_params(¶ms.to_training_json().unwrap()); + let json: serde_json::Value = + serde_json::from_str(scalar_params.params.as_ref().unwrap()).unwrap(); + assert_eq!( + json.get("memory_limit"), + Some(&serde_json::Value::from(4096)) + ); + assert_eq!(json.get("num_workers"), Some(&serde_json::Value::from(7))); + } + #[test] fn test_default_index_name() { // Single field - preserved as-is From e8109ad0e1bba185a206c7b36daae4d47f587d7a Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Fri, 13 Mar 2026 20:37:17 +0000 Subject: [PATCH 026/206] chore: release beta version 4.0.0-beta.11 --- .bumpversion.toml | 2 +- Cargo.lock | 54 +++++++++++++++++++-------------------- Cargo.toml | 40 ++++++++++++++--------------- java/lance-jni/Cargo.lock | 46 ++++++++++++++++----------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 46 ++++++++++++++++----------------- python/Cargo.toml | 2 +- 8 files changed, 97 insertions(+), 97 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 7b4d8f9895e..be5c13de3a8 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.0.0-beta.10" +current_version = "4.0.0-beta.11" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 246b5f2bd22..c29e11c681c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,9 +146,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -1685,9 +1685,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "comfy-table" @@ -3451,7 +3451,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "lance-datagen", @@ -4743,7 +4743,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "all_asserts", "approx", @@ -4838,7 +4838,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "arrow-buffer", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrayref", "paste", @@ -4883,7 +4883,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "arrow-buffer", @@ -4923,7 +4923,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -4957,7 +4957,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -4977,7 +4977,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-arith", "arrow-array", @@ -5024,7 +5024,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "all_asserts", "arrow", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-arith", "arrow-array", @@ -5093,7 +5093,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "datafusion", "geo-traits", @@ -5107,7 +5107,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "approx", "arrow", @@ -5187,7 +5187,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-arith", @@ -5235,7 +5235,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "approx", "arrow-array", @@ -5256,7 +5256,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "async-trait", @@ -5270,7 +5270,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -5288,7 +5288,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-ipc", @@ -5346,7 +5346,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "proc-macro2", "quote", @@ -5401,7 +5401,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "arrow-schema", @@ -5412,7 +5412,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "clap", "lance-core", @@ -9232,9 +9232,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", diff --git a/Cargo.toml b/Cargo.toml index c112356bcb8..55d3968e88e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.0.0-beta.10", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.0.0-beta.10", path = "./rust/lance-arrow" } -lance-core = { version = "=4.0.0-beta.10", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.0.0-beta.10", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.0.0-beta.10", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.0.0-beta.10", path = "./rust/lance-encoding" } -lance-file = { version = "=4.0.0-beta.10", path = "./rust/lance-file" } -lance-geo = { version = "=4.0.0-beta.10", path = "./rust/lance-geo" } -lance-index = { version = "=4.0.0-beta.10", path = "./rust/lance-index" } -lance-io = { version = "=4.0.0-beta.10", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.0.0-beta.10", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.0.0-beta.10", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.0.0-beta.10", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.0.0-beta.10", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=4.0.0-beta.11", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=4.0.0-beta.11", path = "./rust/lance-arrow" } +lance-core = { version = "=4.0.0-beta.11", path = "./rust/lance-core" } +lance-datafusion = { version = "=4.0.0-beta.11", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=4.0.0-beta.11", path = "./rust/lance-datagen" } +lance-encoding = { version = "=4.0.0-beta.11", path = "./rust/lance-encoding" } +lance-file = { version = "=4.0.0-beta.11", path = "./rust/lance-file" } +lance-geo = { version = "=4.0.0-beta.11", path = "./rust/lance-geo" } +lance-index = { version = "=4.0.0-beta.11", path = "./rust/lance-index" } +lance-io = { version = "=4.0.0-beta.11", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=4.0.0-beta.11", path = "./rust/lance-linalg" } +lance-namespace = { version = "=4.0.0-beta.11", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=4.0.0-beta.11", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=4.0.0-beta.11", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.5.2" -lance-table = { version = "=4.0.0-beta.10", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.0.0-beta.10", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.0.0-beta.10", path = "./rust/lance-testing" } +lance-table = { version = "=4.0.0-beta.11", path = "./rust/lance-table" } +lance-test-macros = { version = "=4.0.0-beta.11", path = "./rust/lance-test-macros" } +lance-testing = { version = "=4.0.0-beta.11", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.0.0-beta.10", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=4.0.0-beta.11", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.0.0-beta.10", path = "./rust/compression/fsst" } +fsst = { version = "=4.0.0-beta.11", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index a90b5542276..38e1afd6866 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -89,9 +89,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -1164,9 +1164,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3353,7 +3353,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-arith", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "arrow-buffer", @@ -3439,7 +3439,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrayref", "paste", @@ -3448,7 +3448,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "arrow-buffer", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -3518,7 +3518,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -3536,7 +3536,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-arith", "arrow-array", @@ -3573,7 +3573,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-arith", "arrow-array", @@ -3605,7 +3605,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "datafusion", "geo-traits", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-arith", @@ -3686,7 +3686,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-arith", @@ -3727,7 +3727,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-schema", @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "arrow-buffer", @@ -3777,7 +3777,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "async-trait", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-ipc", @@ -3833,7 +3833,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -6515,9 +6515,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index e000d503b52..ef170b2b4c6 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index a778daf9def..07a1af03e44 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.0.0-beta.10 + 4.0.0-beta.11 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index a4f21f64bc6..6108efd9705 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -143,9 +143,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -1332,9 +1332,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "comfy-table" @@ -2824,7 +2824,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3911,7 +3911,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-arith", @@ -3978,7 +3978,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "arrow-buffer", @@ -3998,7 +3998,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrayref", "paste", @@ -4007,7 +4007,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "arrow-buffer", @@ -4044,7 +4044,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -4077,7 +4077,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-arith", "arrow-array", @@ -4132,7 +4132,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-arith", "arrow-array", @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "datafusion", "geo-traits", @@ -4178,7 +4178,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-arith", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-arith", @@ -4289,7 +4289,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow-array", "arrow-buffer", @@ -4305,7 +4305,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "async-trait", @@ -4317,7 +4317,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-ipc", @@ -4361,7 +4361,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" dependencies = [ "arrow", "arrow-array", @@ -7628,9 +7628,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", diff --git a/python/Cargo.toml b/python/Cargo.toml index f130b50bc98..5863787d5c8 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.0.0-beta.10" +version = "4.0.0-beta.11" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From f02df43d6f757753c870144f46fc0430a96e3eba Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Fri, 13 Mar 2026 16:20:53 -0500 Subject: [PATCH 027/206] fix: disallowing stale credentials from directory namespace (#6194) This PR makes two changes to ensure stale credentials are not used: (1) In the Directory namespace if either vending is not enabled or a credential vendor is not configured we return `None` for storage options. (2) The `DynamicStorageOptionsCredentialProvider` falls back to the default credential provider (lazily loaded) if it is not able to retrieve credentials. Closes https://github.com/lance-format/lance-spark/issues/292 --------- Signed-off-by: Daniel Rammer --- .../org/lance/NamespaceIntegrationTest.java | 48 +++--- .../tests/test_namespace_integration.py | 140 ++++++++++------ .../src/object_store/providers/aws.rs | 24 ++- rust/lance-namespace-impls/src/dir.rs | 150 ++++++++++++------ 4 files changed, 246 insertions(+), 116 deletions(-) diff --git a/java/src/test/java/org/lance/NamespaceIntegrationTest.java b/java/src/test/java/org/lance/NamespaceIntegrationTest.java index 15a329fe451..af24279229c 100644 --- a/java/src/test/java/org/lance/NamespaceIntegrationTest.java +++ b/java/src/test/java/org/lance/NamespaceIntegrationTest.java @@ -217,27 +217,27 @@ public String namespaceId() { } /** - * Modifies storage options to add incrementing credentials with expiration timestamp. + * Simulates a credential vendor returning only vended credentials. + * + *

Returns only credential keys with expiration metadata. Clients are expected to provide + * their own connection config (endpoint, region, allow_http) via storageOptions. * - * @param storageOptions Original storage options * @param count Call count to use for credential generation - * @return Modified storage options with new credentials + * @return Storage options with vended credentials */ - private Map modifyStorageOptions( - Map storageOptions, int count) { - Map modified = - storageOptions != null ? new HashMap<>(storageOptions) : new HashMap<>(); + private Map vendStorageOptions(int count) { + Map options = new HashMap<>(); - modified.put("aws_access_key_id", "AKID_" + count); - modified.put("aws_secret_access_key", "SECRET_" + count); - modified.put("aws_session_token", "TOKEN_" + count); + options.put("aws_access_key_id", "AKID_" + count); + options.put("aws_secret_access_key", "SECRET_" + count); + options.put("aws_session_token", "TOKEN_" + count); long expiresAtMillis = System.currentTimeMillis() + (credentialExpiresInSeconds * 1000L); - modified.put("expires_at_millis", String.valueOf(expiresAtMillis)); + options.put("expires_at_millis", String.valueOf(expiresAtMillis)); // Set refresh offset to 1 second (1000ms) for short-lived credential tests - modified.put("refresh_offset_millis", "1000"); + options.put("refresh_offset_millis", "1000"); - return modified; + return options; } @Override @@ -245,7 +245,7 @@ public DeclareTableResponse declareTable(DeclareTableRequest request) { int count = createCallCount.incrementAndGet(); DeclareTableResponse response = inner.declareTable(request); - response.setStorageOptions(modifyStorageOptions(response.getStorageOptions(), count)); + response.setStorageOptions(vendStorageOptions(count)); return response; } @@ -255,7 +255,7 @@ public DescribeTableResponse describeTable(DescribeTableRequest request) { int count = describeCallCount.incrementAndGet(); DescribeTableResponse response = inner.describeTable(request); - response.setStorageOptions(modifyStorageOptions(response.getStorageOptions(), count)); + response.setStorageOptions(vendStorageOptions(count)); return response; } @@ -340,6 +340,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { .namespace(namespace) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) + .storageOptions(storageOptions) .execute()) { assertEquals(2, dataset.countRows()); } @@ -349,7 +350,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { assertEquals(1, namespace.getCreateCallCount(), "declareTable should be called once"); // Open dataset through namespace WITH refresh enabled - ReadOptions readOptions = new ReadOptions.Builder().build(); + ReadOptions readOptions = new ReadOptions.Builder().setStorageOptions(storageOptions).build(); int callCountBeforeOpen = namespace.getDescribeCallCount(); try (Dataset dsFromNamespace = @@ -470,6 +471,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { .namespace(namespace) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) + .storageOptions(storageOptions) .execute()) { assertEquals(2, dataset.countRows()); } @@ -479,7 +481,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { assertEquals(1, namespace.getCreateCallCount(), "declareTable should be called once"); // Open dataset through namespace with refresh enabled - ReadOptions readOptions = new ReadOptions.Builder().build(); + ReadOptions readOptions = new ReadOptions.Builder().setStorageOptions(storageOptions).build(); int callCountBeforeOpen = namespace.getDescribeCallCount(); try (Dataset dsFromNamespace = @@ -613,6 +615,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { .namespace(namespace) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) + .storageOptions(storageOptions) .execute()) { // Verify declareTable was called @@ -713,6 +716,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { .namespace(namespace) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) + .storageOptions(storageOptions) .execute()) { // Verify declareTable was called exactly ONCE @@ -742,7 +746,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { "describeTable should still be 0 after close (no refresh needed)"); // Now open the dataset through namespace with long-lived credentials (60s expiration) - ReadOptions readOptions = new ReadOptions.Builder().build(); + ReadOptions readOptions = new ReadOptions.Builder().setStorageOptions(storageOptions).build(); try (Dataset dsFromNamespace = Dataset.open() @@ -861,6 +865,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { .namespace(namespace) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) + .storageOptions(storageOptions) .execute()) { assertEquals(2, dataset.countRows()); } @@ -909,6 +914,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { .namespace(namespace) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.APPEND) + .storageOptions(storageOptions) .execute()) { // Verify describeTable was called @@ -1000,6 +1006,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { .namespace(namespace) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) + .storageOptions(storageOptions) .execute()) { assertEquals(1, dataset.countRows()); } @@ -1059,6 +1066,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { .namespace(namespace) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.OVERWRITE) + .storageOptions(storageOptions) .execute()) { // Verify describeTable was called for overwrite @@ -1079,6 +1087,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { .allocator(allocator) .namespace(namespace) .tableId(Arrays.asList(tableName)) + .readOptions(new ReadOptions.Builder().setStorageOptions(storageOptions).build()) .build()) { assertEquals(2, ds.countRows(), "Should have 2 rows after overwrite"); assertEquals(2, ds.listVersions().size(), "Should have 2 versions"); @@ -1208,6 +1217,7 @@ void testDistributedWriteWithNamespace() throws Exception { .allocator(allocator) .namespace(namespace) .tableId(Arrays.asList(tableName)) + .readOptions(new ReadOptions.Builder().setStorageOptions(storageOptions).build()) .build()) { assertEquals(5, dsFromNamespace.countRows(), "Should read 5 rows through namespace"); } @@ -1321,6 +1331,7 @@ void testFragmentCreateAndCommitWithNamespace() throws Exception { .allocator(allocator) .namespace(namespace) .tableId(Arrays.asList(tableName)) + .readOptions(new ReadOptions.Builder().setStorageOptions(storageOptions).build()) .build()) { assertEquals(6, ds.countRows(), "Should have 6 rows total"); assertEquals(2, ds.listVersions().size(), "Should have 2 versions"); @@ -1452,6 +1463,7 @@ void testTransactionCommitWithNamespace() throws Exception { .allocator(allocator) .namespace(namespace) .tableId(Arrays.asList(tableName)) + .readOptions(new ReadOptions.Builder().setStorageOptions(storageOptions).build()) .build()) { assertEquals(4, ds.countRows(), "Should have 4 rows total"); assertEquals(2, ds.listVersions().size(), "Should have 2 versions"); diff --git a/python/python/tests/test_namespace_integration.py b/python/python/tests/test_namespace_integration.py index 14dd7a6531f..9cb87011e9c 100644 --- a/python/python/tests/test_namespace_integration.py +++ b/python/python/tests/test_namespace_integration.py @@ -113,23 +113,23 @@ def get_create_call_count(self) -> int: def namespace_id(self) -> str: return f"TrackingNamespace {{ inner: {self.inner.namespace_id()} }}" - def _modify_storage_options( - self, storage_options: Dict[str, str], count: int - ) -> Dict[str, str]: - """Add incrementing credentials with expiration timestamp.""" - modified = copy.deepcopy(storage_options) if storage_options else {} - - modified["aws_access_key_id"] = f"AKID_{count}" - modified["aws_secret_access_key"] = f"SECRET_{count}" - modified["aws_session_token"] = f"TOKEN_{count}" - expires_at_millis = int( - (time.time() + self.credential_expires_in_seconds) * 1000 - ) - modified["expires_at_millis"] = str(expires_at_millis) - # Set refresh offset to 1 second (1000ms) for short-lived credential tests - modified["refresh_offset_millis"] = "1000" - - return modified + def _vend_storage_options(self, count: int) -> Dict[str, str]: + """Simulate a credential vendor returning only vended credentials. + + Returns only credential keys with expiration metadata, not connection + config. Clients are expected to provide their own connection config + (endpoint, region, allow_http) via storage_options. + """ + return { + "aws_access_key_id": f"AKID_{count}", + "aws_secret_access_key": f"SECRET_{count}", + "aws_session_token": f"TOKEN_{count}", + "expires_at_millis": str( + int((time.time() + self.credential_expires_in_seconds) * 1000) + ), + # Set refresh offset to 1 second (1000ms) for short-lived credential tests + "refresh_offset_millis": "1000", + } def declare_table(self, request: DeclareTableRequest) -> DeclareTableResponse: with self.lock: @@ -137,9 +137,7 @@ def declare_table(self, request: DeclareTableRequest) -> DeclareTableResponse: count = self.create_call_count response = self.inner.declare_table(request) - response.storage_options = self._modify_storage_options( - response.storage_options, count - ) + response.storage_options = self._vend_storage_options(count) return response @@ -149,9 +147,7 @@ def describe_table(self, request: DescribeTableRequest) -> DescribeTableResponse count = self.describe_call_count response = self.inner.describe_table(request) - response.storage_options = self._modify_storage_options( - response.storage_options, count - ) + response.storage_options = self._vend_storage_options(count) return response @@ -175,7 +171,11 @@ def test_namespace_open_dataset(s3_bucket: str): assert namespace.get_describe_call_count() == 0 ds = lance.write_dataset( - table1, namespace=namespace, table_id=table_id, mode="create" + table1, + namespace=namespace, + table_id=table_id, + mode="create", + storage_options=storage_options, ) assert len(ds.versions()) == 1 assert ds.count_rows() == 2 @@ -184,6 +184,7 @@ def test_namespace_open_dataset(s3_bucket: str): ds_from_namespace = lance.dataset( namespace=namespace, table_id=table_id, + storage_options=storage_options, ) assert namespace.get_describe_call_count() == 1 @@ -221,6 +222,7 @@ def test_namespace_with_refresh(s3_bucket: str): namespace=namespace, table_id=table_id, mode="create", + storage_options=storage_options, ) assert ds.count_rows() == 2 assert namespace.get_create_call_count() == 1 @@ -228,6 +230,7 @@ def test_namespace_with_refresh(s3_bucket: str): ds_from_namespace = lance.dataset( namespace=namespace, table_id=table_id, + storage_options=storage_options, ) initial_call_count = namespace.get_describe_call_count() @@ -267,7 +270,11 @@ def test_namespace_append_through_namespace(s3_bucket: str): assert namespace.get_describe_call_count() == 0 ds = lance.write_dataset( - table1, namespace=namespace, table_id=table_id, mode="create" + table1, + namespace=namespace, + table_id=table_id, + mode="create", + storage_options=storage_options, ) assert ds.count_rows() == 1 assert len(ds.versions()) == 1 @@ -276,7 +283,11 @@ def test_namespace_append_through_namespace(s3_bucket: str): table2 = pa.Table.from_pylist([{"a": 10, "b": 20}]) ds = lance.write_dataset( - table2, namespace=namespace, table_id=table_id, mode="append" + table2, + namespace=namespace, + table_id=table_id, + mode="append", + storage_options=storage_options, ) assert ds.count_rows() == 2 assert len(ds.versions()) == 2 @@ -286,6 +297,7 @@ def test_namespace_append_through_namespace(s3_bucket: str): ds_from_namespace = lance.dataset( namespace=namespace, table_id=table_id, + storage_options=storage_options, ) assert ds_from_namespace.count_rows() == 2 @@ -315,6 +327,7 @@ def test_namespace_write_create_mode(s3_bucket: str): namespace=namespace, table_id=["test_ns", table_name], mode="create", + storage_options=storage_options, ) assert namespace.get_create_call_count() == 1 @@ -343,7 +356,11 @@ def test_namespace_write_append_mode(s3_bucket: str): assert namespace.get_describe_call_count() == 0 ds = lance.write_dataset( - table1, namespace=namespace, table_id=table_id, mode="create" + table1, + namespace=namespace, + table_id=table_id, + mode="create", + storage_options=storage_options, ) assert ds.count_rows() == 1 assert namespace.get_create_call_count() == 1 @@ -356,6 +373,7 @@ def test_namespace_write_append_mode(s3_bucket: str): namespace=namespace, table_id=table_id, mode="append", + storage_options=storage_options, ) assert namespace.get_create_call_count() == 1 @@ -389,7 +407,11 @@ def test_namespace_write_overwrite_mode(s3_bucket: str): assert namespace.get_describe_call_count() == 0 ds = lance.write_dataset( - table1, namespace=namespace, table_id=table_id, mode="create" + table1, + namespace=namespace, + table_id=table_id, + mode="create", + storage_options=storage_options, ) assert ds.count_rows() == 1 assert namespace.get_create_call_count() == 1 @@ -402,6 +424,7 @@ def test_namespace_write_overwrite_mode(s3_bucket: str): namespace=namespace, table_id=table_id, mode="overwrite", + storage_options=storage_options, ) assert namespace.get_create_call_count() == 1 @@ -508,6 +531,7 @@ def test_namespace_distributed_write(s3_bucket: str): ds_from_namespace = lance.dataset( namespace=namespace, table_id=table_id, + storage_options=storage_options, ) assert ds_from_namespace.count_rows() == 5 @@ -534,7 +558,11 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): assert namespace.get_describe_call_count() == 0 ds = lance.write_dataset( - table1, namespace=namespace, table_id=table_id, mode="create" + table1, + namespace=namespace, + table_id=table_id, + mode="create", + storage_options=storage_options, ) assert ds.count_rows() == 2 assert namespace.get_create_call_count() == 1 @@ -542,7 +570,9 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): describe_response = namespace.describe_table( DescribeTableRequest(id=table_id, version=None) ) - namespace_storage_options = describe_response.storage_options + merged_options = dict(storage_options) + if describe_response.storage_options: + merged_options.update(describe_response.storage_options) provider = LanceNamespaceStorageOptionsProvider( namespace=namespace, table_id=table_id @@ -556,7 +586,7 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): writer = LanceFileWriter( file_uri, schema=schema, - storage_options=namespace_storage_options, + storage_options=merged_options, storage_options_provider=provider, ) @@ -574,7 +604,7 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): reader = LanceFileReader( file_uri, - storage_options=namespace_storage_options, + storage_options=merged_options, storage_options_provider=provider, ) result = reader.read_all(batch_size=1024) @@ -593,7 +623,7 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): writer2 = LanceFileWriter( file_uri2, schema=schema, - storage_options=namespace_storage_options, + storage_options=merged_options, storage_options_provider=provider, ) @@ -608,7 +638,7 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): reader2 = LanceFileReader( file_uri2, - storage_options=namespace_storage_options, + storage_options=merged_options, storage_options_provider=provider, ) result2 = reader2.read_all(batch_size=1024) @@ -637,14 +667,20 @@ def test_file_reader_with_storage_options_provider(s3_bucket: str): table_id = ["test_ns", table_name] ds = lance.write_dataset( - table1, namespace=namespace, table_id=table_id, mode="create" + table1, + namespace=namespace, + table_id=table_id, + mode="create", + storage_options=storage_options, ) assert ds.count_rows() == 2 describe_response = namespace.describe_table( DescribeTableRequest(id=table_id, version=None) ) - namespace_storage_options = describe_response.storage_options + merged_options = dict(storage_options) + if describe_response.storage_options: + merged_options.update(describe_response.storage_options) provider = LanceNamespaceStorageOptionsProvider( namespace=namespace, table_id=table_id @@ -657,7 +693,7 @@ def test_file_reader_with_storage_options_provider(s3_bucket: str): writer = LanceFileWriter( file_uri, schema=schema, - storage_options=namespace_storage_options, + storage_options=merged_options, ) batch = pa.RecordBatch.from_pydict({"x": [1, 2, 3], "y": [4, 5, 6]}, schema=schema) writer.write_batch(batch) @@ -667,14 +703,16 @@ def test_file_reader_with_storage_options_provider(s3_bucket: str): describe_response = namespace.describe_table( DescribeTableRequest(id=table_id, version=None) ) - namespace_storage_options = describe_response.storage_options + merged_options = dict(storage_options) + if describe_response.storage_options: + merged_options.update(describe_response.storage_options) initial_describe_count = namespace.get_describe_call_count() # First read should work without needing refresh reader = LanceFileReader( file_uri, - storage_options=namespace_storage_options, + storage_options=merged_options, storage_options_provider=provider, ) result = reader.read_all(batch_size=1024) @@ -693,7 +731,7 @@ def test_file_reader_with_storage_options_provider(s3_bucket: str): writer2 = LanceFileWriter( file_uri2, schema=schema, - storage_options=namespace_storage_options, + storage_options=merged_options, ) batch2 = pa.RecordBatch.from_pydict( {"x": [100, 200], "y": [300, 400]}, schema=schema @@ -704,7 +742,7 @@ def test_file_reader_with_storage_options_provider(s3_bucket: str): # Second read should trigger credential refresh reader2 = LanceFileReader( file_uri2, - storage_options=namespace_storage_options, + storage_options=merged_options, storage_options_provider=provider, ) result2 = reader2.read_all(batch_size=1024) @@ -736,14 +774,20 @@ def test_file_session_with_storage_options_provider(s3_bucket: str): table_id = ["test_ns", table_name] ds = lance.write_dataset( - table1, namespace=namespace, table_id=table_id, mode="create" + table1, + namespace=namespace, + table_id=table_id, + mode="create", + storage_options=storage_options, ) assert ds.count_rows() == 2 describe_response = namespace.describe_table( DescribeTableRequest(id=table_id, version=None) ) - namespace_storage_options = describe_response.storage_options + merged_options = dict(storage_options) + if describe_response.storage_options: + merged_options.update(describe_response.storage_options) provider = LanceNamespaceStorageOptionsProvider( namespace=namespace, table_id=table_id @@ -754,7 +798,7 @@ def test_file_session_with_storage_options_provider(s3_bucket: str): # Create session with storage_options_provider session = LanceFileSession( f"s3://{s3_bucket}/{table_name}_session", - storage_options=namespace_storage_options, + storage_options=merged_options, storage_options_provider=provider, ) @@ -867,9 +911,13 @@ def test_basic_create_and_drop_on_s3(s3_bucket: str): table_data = create_test_table_data() table_id = ["test_ns", table_name] - # Create table using lance.write_dataset (same as other passing tests) + # Create table using lance.write_dataset ds = lance.write_dataset( - table_data, namespace=namespace, table_id=table_id, mode="create" + table_data, + namespace=namespace, + table_id=table_id, + mode="create", + storage_options=storage_options, ) assert ds is not None assert ds.count_rows() == 3 diff --git a/rust/lance-io/src/object_store/providers/aws.rs b/rust/lance-io/src/object_store/providers/aws.rs index 18165c11598..2055a7144d2 100644 --- a/rust/lance-io/src/object_store/providers/aws.rs +++ b/rust/lance-io/src/object_store/providers/aws.rs @@ -260,7 +260,9 @@ pub async fn build_aws_credential( let storage_options_credentials = storage_options.and_then(extract_static_s3_credentials); - // If accessor has a provider, use DynamicStorageOptionsCredentialProvider + // If accessor has a provider, check whether it vends credentials. + // If it does, use DynamicStorageOptionsCredentialProvider for ongoing + // refresh. If not, fall through to the default credentials chain. if let Some(accessor) = storage_options_accessor && accessor.has_provider() { @@ -268,11 +270,21 @@ pub async fn build_aws_credential( if let Some(creds) = credentials { return Ok((creds, region)); } - // Use accessor for dynamic credential refresh - return Ok(( - Arc::new(DynamicStorageOptionsCredentialProvider::new(accessor)), - region, - )); + + // Check if the accessor's storage options contain credentials + let opts = accessor.get_storage_options().await?; + let s3_options = opts.as_s3_options(); + if extract_static_s3_credentials(&s3_options).is_some() { + return Ok(( + Arc::new(DynamicStorageOptionsCredentialProvider::new(accessor)), + region, + )); + } + + log::debug!( + "Storage options from provider do not contain explicit AWS credentials, \ + falling back to default AWS credentials chain." + ); } // Fall back to existing logic for static credentials diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index a9e5b4cc112..73906198961 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -819,13 +819,16 @@ impl DirectoryNamespace { async fn get_storage_options_for_table( &self, table_uri: &str, + vend_credentials: bool, identity: Option<&Identity>, ) -> Result>> { - if let Some(ref vendor) = self.credential_vendor { + if vend_credentials && let Some(ref vendor) = self.credential_vendor { let vended = vendor.vend_credentials(table_uri, identity).await?; return Ok(Some(vended.storage_options)); } - Ok(self.storage_options.clone()) + // When no credential vendor is configured, return None to avoid + // leaking the namespace's own static credentials to clients. + Ok(None) } /// Migrate directory-based tables to the manifest. @@ -1064,16 +1067,13 @@ impl LanceNamespace for DirectoryNamespace { if let Some(ref manifest_ns) = self.manifest_ns { match manifest_ns.describe_table(request.clone()).await { Ok(mut response) => { - // Only apply identity-based credential vending when explicitly requested - if request.vend_credentials == Some(true) && self.credential_vendor.is_some() { - if let Some(ref table_uri) = response.table_uri { - let identity = request.identity.as_deref(); - response.storage_options = self - .get_storage_options_for_table(table_uri, identity) - .await?; - } - } else if request.vend_credentials == Some(false) { - response.storage_options = None; + if let Some(ref table_uri) = response.table_uri { + // For backwards compatibility, only skip vending credentials when explicitly set to false + let vend = request.vend_credentials.unwrap_or(true); + let identity = request.identity.as_deref(); + response.storage_options = self + .get_storage_options_for_table(table_uri, vend, identity) + .await?; } // Set managed_versioning flag when table_version_tracking_enabled if self.table_version_tracking_enabled { @@ -1116,12 +1116,9 @@ impl LanceNamespace for DirectoryNamespace { // If not loading detailed metadata, return minimal response with just location if !load_detailed_metadata { - let storage_options = if vend_credentials { - self.get_storage_options_for_table(&table_uri, identity) - .await? - } else { - None - }; + let storage_options = self + .get_storage_options_for_table(&table_uri, vend_credentials, identity) + .await?; return Ok(DescribeTableResponse { table: Some(table_name), namespace: request.id.as_ref().map(|id| { @@ -1163,12 +1160,9 @@ impl LanceNamespace for DirectoryNamespace { let lance_schema = dataset.schema(); let arrow_schema: arrow_schema::Schema = lance_schema.into(); let json_schema = arrow_schema_to_json(&arrow_schema)?; - let storage_options = if vend_credentials { - self.get_storage_options_for_table(&table_uri, identity) - .await? - } else { - None - }; + let storage_options = self + .get_storage_options_for_table(&table_uri, vend_credentials, identity) + .await?; // Convert BTreeMap to HashMap for the response let metadata: std::collections::HashMap = @@ -1200,12 +1194,9 @@ impl LanceNamespace for DirectoryNamespace { Err(err) => { // Use the reserved file status from the atomic check if status.has_reserved_file { - let storage_options = if vend_credentials { - self.get_storage_options_for_table(&table_uri, identity) - .await? - } else { - None - }; + let storage_options = self + .get_storage_options_for_table(&table_uri, vend_credentials, identity) + .await?; Ok(DescribeTableResponse { table: Some(table_name), namespace: request.id.as_ref().map(|id| { @@ -1369,16 +1360,13 @@ impl LanceNamespace for DirectoryNamespace { async fn declare_table(&self, request: DeclareTableRequest) -> Result { if let Some(ref manifest_ns) = self.manifest_ns { let mut response = manifest_ns.declare_table(request.clone()).await?; - // Only apply identity-based credential vending when explicitly requested - if request.vend_credentials == Some(true) && self.credential_vendor.is_some() { - if let Some(ref location) = response.location { - let identity = request.identity.as_deref(); - response.storage_options = self - .get_storage_options_for_table(location, identity) - .await?; - } - } else if request.vend_credentials == Some(false) { - response.storage_options = None; + if let Some(ref location) = response.location { + // For backwards compatibility, only skip vending credentials when explicitly set to false + let vend = request.vend_credentials.unwrap_or(true); + let identity = request.identity.as_deref(); + response.storage_options = self + .get_storage_options_for_table(location, vend, identity) + .await?; } // Set managed_versioning when table_version_tracking_enabled if self.table_version_tracking_enabled { @@ -1427,12 +1415,9 @@ impl LanceNamespace for DirectoryNamespace { // For backwards compatibility, only skip vending credentials when explicitly set to false let vend_credentials = request.vend_credentials.unwrap_or(true); let identity = request.identity.as_deref(); - let storage_options = if vend_credentials { - self.get_storage_options_for_table(&table_uri, identity) - .await? - } else { - None - }; + let storage_options = self + .get_storage_options_for_table(&table_uri, vend_credentials, identity) + .await?; Ok(DeclareTableResponse { location: Some(table_uri), @@ -2281,6 +2266,79 @@ mod tests { assert_eq!(storage_options.get("option2"), Some(&"value2".to_string())); } + /// When no credential vendor is configured, `describe_table` and + /// `declare_table` must strip credential keys from storage options + /// while preserving non-credential config (region, endpoint, etc.). + #[tokio::test] + async fn test_no_storage_options_without_vendor() { + use lance_namespace::models::DeclareTableRequest; + + let temp_dir = TempStdDir::default(); + + // No manifest, no credential vendor, but storage options with credentials + let namespace = DirectoryNamespaceBuilder::new(temp_dir.to_str().unwrap()) + .manifest_enabled(false) + .storage_option("aws_access_key_id", "AKID") + .storage_option("aws_secret_access_key", "SECRET") + .storage_option("region", "us-east-1") + .build() + .await + .unwrap(); + + let schema = create_test_schema(); + let ipc_data = create_test_ipc_data(&schema); + + // create_table + let mut create_req = CreateTableRequest::new(); + create_req.id = Some(vec!["t1".to_string()]); + namespace + .create_table(create_req, bytes::Bytes::from(ipc_data)) + .await + .unwrap(); + + // describe_table should not return storage options without a vendor + let mut desc_req = DescribeTableRequest::new(); + desc_req.id = Some(vec!["t1".to_string()]); + let resp = namespace.describe_table(desc_req).await.unwrap(); + assert!(resp.storage_options.is_none()); + + // declare_table should not return storage options without a vendor + let mut decl_req = DeclareTableRequest::new(); + decl_req.id = Some(vec!["t2".to_string()]); + let resp = namespace.declare_table(decl_req).await.unwrap(); + assert!(resp.storage_options.is_none()); + } + + /// Same test with manifest mode enabled. + #[tokio::test] + async fn test_no_storage_options_without_vendor_manifest() { + let temp_dir = TempStdDir::default(); + + let namespace = DirectoryNamespaceBuilder::new(temp_dir.to_str().unwrap()) + .storage_option("aws_access_key_id", "AKID") + .storage_option("aws_secret_access_key", "SECRET") + .storage_option("region", "us-east-1") + .build() + .await + .unwrap(); + + let schema = create_test_schema(); + let ipc_data = create_test_ipc_data(&schema); + + let mut create_req = CreateTableRequest::new(); + create_req.id = Some(vec!["t1".to_string()]); + namespace + .create_table(create_req, bytes::Bytes::from(ipc_data)) + .await + .unwrap(); + + // describe_table through manifest should not return storage options without a vendor + let mut desc_req = DescribeTableRequest::new(); + desc_req.id = Some(vec!["t1".to_string()]); + let resp = namespace.describe_table(desc_req).await.unwrap(); + assert!(resp.storage_options.is_none()); + } + #[tokio::test] async fn test_from_properties_manifest_enabled() { let temp_dir = TempStdDir::default(); From 1e7b7252881261f290740d8d5a487c053f05039e Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Fri, 13 Mar 2026 21:39:59 +0000 Subject: [PATCH 028/206] chore: release beta version 4.0.0-beta.12 --- .bumpversion.toml | 2 +- Cargo.lock | 42 +++++++++++++++++++-------------------- Cargo.toml | 40 ++++++++++++++++++------------------- java/lance-jni/Cargo.lock | 34 +++++++++++++++---------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 34 +++++++++++++++---------------- python/Cargo.toml | 2 +- 8 files changed, 79 insertions(+), 79 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index be5c13de3a8..a51e7b34c8a 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.0.0-beta.11" +current_version = "4.0.0-beta.12" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index c29e11c681c..f3fb73786b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3451,7 +3451,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "lance-datagen", @@ -4743,7 +4743,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "all_asserts", "approx", @@ -4838,7 +4838,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "arrow-buffer", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrayref", "paste", @@ -4883,7 +4883,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "arrow-buffer", @@ -4923,7 +4923,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", @@ -4957,7 +4957,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", @@ -4977,7 +4977,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-arith", "arrow-array", @@ -5024,7 +5024,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "all_asserts", "arrow", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-arith", "arrow-array", @@ -5093,7 +5093,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "datafusion", "geo-traits", @@ -5107,7 +5107,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "approx", "arrow", @@ -5187,7 +5187,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-arith", @@ -5235,7 +5235,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "approx", "arrow-array", @@ -5256,7 +5256,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "async-trait", @@ -5270,7 +5270,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", @@ -5288,7 +5288,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-ipc", @@ -5346,7 +5346,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "proc-macro2", "quote", @@ -5401,7 +5401,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "arrow-schema", @@ -5412,7 +5412,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "clap", "lance-core", diff --git a/Cargo.toml b/Cargo.toml index 55d3968e88e..82c36edea52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.0.0-beta.11", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.0.0-beta.11", path = "./rust/lance-arrow" } -lance-core = { version = "=4.0.0-beta.11", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.0.0-beta.11", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.0.0-beta.11", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.0.0-beta.11", path = "./rust/lance-encoding" } -lance-file = { version = "=4.0.0-beta.11", path = "./rust/lance-file" } -lance-geo = { version = "=4.0.0-beta.11", path = "./rust/lance-geo" } -lance-index = { version = "=4.0.0-beta.11", path = "./rust/lance-index" } -lance-io = { version = "=4.0.0-beta.11", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.0.0-beta.11", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.0.0-beta.11", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.0.0-beta.11", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.0.0-beta.11", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=4.0.0-beta.12", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=4.0.0-beta.12", path = "./rust/lance-arrow" } +lance-core = { version = "=4.0.0-beta.12", path = "./rust/lance-core" } +lance-datafusion = { version = "=4.0.0-beta.12", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=4.0.0-beta.12", path = "./rust/lance-datagen" } +lance-encoding = { version = "=4.0.0-beta.12", path = "./rust/lance-encoding" } +lance-file = { version = "=4.0.0-beta.12", path = "./rust/lance-file" } +lance-geo = { version = "=4.0.0-beta.12", path = "./rust/lance-geo" } +lance-index = { version = "=4.0.0-beta.12", path = "./rust/lance-index" } +lance-io = { version = "=4.0.0-beta.12", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=4.0.0-beta.12", path = "./rust/lance-linalg" } +lance-namespace = { version = "=4.0.0-beta.12", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=4.0.0-beta.12", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=4.0.0-beta.12", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.5.2" -lance-table = { version = "=4.0.0-beta.11", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.0.0-beta.11", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.0.0-beta.11", path = "./rust/lance-testing" } +lance-table = { version = "=4.0.0-beta.12", path = "./rust/lance-table" } +lance-test-macros = { version = "=4.0.0-beta.12", path = "./rust/lance-test-macros" } +lance-testing = { version = "=4.0.0-beta.12", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.0.0-beta.11", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=4.0.0-beta.12", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.0.0-beta.11", path = "./rust/compression/fsst" } +fsst = { version = "=4.0.0-beta.12", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 38e1afd6866..9beaccc254a 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3353,7 +3353,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-arith", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "arrow-buffer", @@ -3439,7 +3439,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrayref", "paste", @@ -3448,7 +3448,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "arrow-buffer", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", @@ -3518,7 +3518,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", @@ -3536,7 +3536,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-arith", "arrow-array", @@ -3573,7 +3573,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-arith", "arrow-array", @@ -3605,7 +3605,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "datafusion", "geo-traits", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-arith", @@ -3686,7 +3686,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-arith", @@ -3727,7 +3727,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-schema", @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "arrow-buffer", @@ -3777,7 +3777,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "async-trait", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-ipc", @@ -3833,7 +3833,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index ef170b2b4c6..e839028d5f0 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 07a1af03e44..50cf798f184 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.0.0-beta.11 + 4.0.0-beta.12 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index 6108efd9705..38579810684 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2824,7 +2824,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3911,7 +3911,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-arith", @@ -3978,7 +3978,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "arrow-buffer", @@ -3998,7 +3998,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrayref", "paste", @@ -4007,7 +4007,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "arrow-buffer", @@ -4044,7 +4044,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", @@ -4077,7 +4077,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-arith", "arrow-array", @@ -4132,7 +4132,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-arith", "arrow-array", @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "datafusion", "geo-traits", @@ -4178,7 +4178,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-arith", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-arith", @@ -4289,7 +4289,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow-array", "arrow-buffer", @@ -4305,7 +4305,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "async-trait", @@ -4317,7 +4317,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-ipc", @@ -4361,7 +4361,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" dependencies = [ "arrow", "arrow-array", diff --git a/python/Cargo.toml b/python/Cargo.toml index 5863787d5c8..0d5a3c23e80 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.0.0-beta.11" +version = "4.0.0-beta.12" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From f0160c7cd7f10001a40e36a2f4e658c974afe7fe Mon Sep 17 00:00:00 2001 From: Wyatt Alt Date: Sat, 14 Mar 2026 06:54:26 -0700 Subject: [PATCH 029/206] feat: enable HNSW-accelerated partition assignment for fp16 vectors (#6119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SimpleIndex (HNSW over centroids) previously only supported fp32 centroids, causing fp16 vector workloads to fall back to brute-force partition assignment — O(K×D) per vector instead of O(log K × D). For 31K centroids × 1024 dims this is a ~600x difference per vector. Cast fp16 centroids to fp32 at HNSW construction time (one-time cost) and cast fp16 query vectors at search time (1024 floats per query, negligible vs the distance computations saved). --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Xuanwo --- rust/lance-index/src/vector/utils.rs | 49 +++++++++++++++++++++++----- rust/lance/src/io/exec/fts.rs | 2 +- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/rust/lance-index/src/vector/utils.rs b/rust/lance-index/src/vector/utils.rs index 0ec4eba4f2b..0f2cae636f4 100644 --- a/rust/lance-index/src/vector/utils.rs +++ b/rust/lance-index/src/vector/utils.rs @@ -3,6 +3,7 @@ use arrow::{ array::AsArray, + compute::cast, datatypes::{Float16Type, Float32Type, Float64Type}, }; use arrow_array::{Array, ArrayRef, BooleanArray, FixedSizeListArray}; @@ -78,18 +79,19 @@ impl SimpleIndex { _ => {} } - match centroids.data_type() { - DataType::Float32 => { - let fsl = - FixedSizeListArray::try_new_from_values(centroids.clone(), dimension as i32)?; - let store = FlatFloatStorage::new(fsl, distance_type); - Self::try_new(store).map(Some) + let f32_centroids = match centroids.data_type() { + DataType::Float16 | DataType::Float32 => { + cast(¢roids, &DataType::Float32).map_err(|e| Error::index(e.to_string()))? } - _ => Ok(None), - } + _ => return Ok(None), + }; + let fsl = FixedSizeListArray::try_new_from_values(f32_centroids, dimension as i32)?; + let store = FlatFloatStorage::new(fsl, distance_type); + Self::try_new(store).map(Some) } pub(crate) fn search(&self, query: ArrayRef) -> Result<(u32, f32)> { + let query = cast(&query, &DataType::Float32).map_err(|e| Error::index(e.to_string()))?; let res = self.index.search_basic( query, 1, @@ -290,6 +292,37 @@ mod tests { use lance_arrow::FixedSizeListArrayExt; use num_traits::identities::Zero; + use arrow::compute::cast; + use rstest::rstest; + + fn build_index(centroids: ArrayRef, dim: usize) -> SimpleIndex { + let f32_centroids = cast(¢roids, &DataType::Float32).unwrap(); + let fsl = FixedSizeListArray::try_new_from_values(f32_centroids, dim as i32).unwrap(); + let store = FlatFloatStorage::new(fsl, DistanceType::L2); + SimpleIndex::try_new(store).unwrap() + } + + #[rstest] + #[case::f16(Arc::new(Float16Array::from( + (0..100).flat_map(|i| std::iter::repeat_n(f16::from_f32(i as f32), 16)).collect::>(), + )) as ArrayRef)] + #[case::f32(Arc::new(Float32Array::from( + (0..100).flat_map(|i| std::iter::repeat_n(i as f32, 16)).collect::>(), + )) as ArrayRef)] + fn test_simple_index_nearest_centroid(#[case] centroids: ArrayRef) { + let index = build_index(centroids, 16); + let query: ArrayRef = Arc::new(Float32Array::from(vec![42.1f32; 16])); + let (id, _) = index.search(query).unwrap(); + assert_eq!(id, 42); + } + + #[test] + fn test_simple_index_rejects_f64() { + let centroids: ArrayRef = Arc::new(Float64Array::from(vec![0.0; 1600])); + let result = SimpleIndex::may_train_index(centroids, 16, DistanceType::L2).unwrap(); + assert!(result.is_none()); + } + #[test] fn test_fsl_to_tensor() { let fsl = diff --git a/rust/lance/src/io/exec/fts.rs b/rust/lance/src/io/exec/fts.rs index 91d46b9d495..6e129841c76 100644 --- a/rust/lance/src/io/exec/fts.rs +++ b/rust/lance/src/io/exec/fts.rs @@ -442,7 +442,7 @@ impl FlatMatchFilterExec { )))?; let mut tokenizer = Self::load_tokenizer(&dataset, column, &metrics.index_metrics).await?; let query_tokens = Arc::new(collect_query_tokens(&query.terms, &mut tokenizer)); - let column = column.to_string(); + let column = column.clone(); Ok(input.map(move |batch| -> DataFusionResult<_> { let batch = batch?; From 79ba63287e9e2072f875343ed8ff12dd617562e6 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Mon, 16 Mar 2026 06:58:39 -0700 Subject: [PATCH 030/206] refactor: use the dataset file version to determine index file version (#6142) Previously we would use the default file version when creating new index files. This was originally done to get some testing of the 2.0 format before it was made the default. However, this led to a bit of a potential compatibility problem. If we change the default file version then the files created by the new release would become unreadable on very old versions that didn't know how to read that file, even if the dataset itself had an older file version and the old version knew how to handle the index otherwise. To avoid this we change things in this PR so that new index files use the same format version as the dataset. This should mean the indexes are always readable if the dataset is readable, regardless of what version was used to write the index. --- Parts of this PR were written with Claude (Opus 4.6) and I take full responsibility for its contents. --- rust/lance-index/src/scalar/lance_format.rs | 23 ++- .../src/vector/distributed/index_merger.rs | 46 ++++- rust/lance-index/src/vector/ivf/shuffler.rs | 14 +- rust/lance-index/src/vector/v3/shuffler.rs | 16 +- rust/lance/src/dataset/index.rs | 23 ++- rust/lance/src/dataset/tests/dataset_index.rs | 191 ++++++++++++++++++ rust/lance/src/index/vector.rs | 19 +- rust/lance/src/index/vector/builder.rs | 19 +- rust/lance/src/index/vector/ivf.rs | 18 +- 9 files changed, 346 insertions(+), 23 deletions(-) diff --git a/rust/lance-index/src/scalar/lance_format.rs b/rust/lance-index/src/scalar/lance_format.rs index 078f00bc8e4..fc499eb7e3f 100644 --- a/rust/lance-index/src/scalar/lance_format.rs +++ b/rust/lance-index/src/scalar/lance_format.rs @@ -11,6 +11,7 @@ use deepsize::DeepSizeOf; use futures::TryStreamExt; use lance_core::{Error, Result, cache::LanceCache}; use lance_encoding::decoder::{DecoderPlugins, FilterExpression}; +use lance_encoding::version::LanceFileVersion; use lance_file::previous::{ reader::FileReader as PreviousFileReader, writer::{FileWriter as PreviousFileWriter, ManifestProvider as PreviousManifestProvider}, @@ -37,6 +38,7 @@ pub struct LanceIndexStore { index_dir: Path, metadata_cache: Arc, scheduler: Arc, + format_version: LanceFileVersion, } impl DeepSizeOf for LanceIndexStore { @@ -53,6 +55,21 @@ impl LanceIndexStore { object_store: Arc, index_dir: Path, metadata_cache: Arc, + ) -> Self { + Self::with_format_version( + object_store, + index_dir, + metadata_cache, + LanceFileVersion::V2_0, + ) + } + + /// Create a new index store at the given directory with a specific format version + pub fn with_format_version( + object_store: Arc, + index_dir: Path, + metadata_cache: Arc, + format_version: LanceFileVersion, ) -> Self { let scheduler = ScanScheduler::new( object_store.clone(), @@ -63,6 +80,7 @@ impl LanceIndexStore { index_dir, metadata_cache, scheduler, + format_version, } } } @@ -216,7 +234,10 @@ impl IndexStore for LanceIndexStore { let writer = current_writer::FileWriter::try_new( writer, schema, - current_writer::FileWriterOptions::default(), + current_writer::FileWriterOptions { + format_version: Some(self.format_version), + ..Default::default() + }, )?; Ok(Box::new(writer)) } diff --git a/rust/lance-index/src/vector/distributed/index_merger.rs b/rust/lance-index/src/vector/distributed/index_merger.rs index 59add308476..0632a693342 100755 --- a/rust/lance-index/src/vector/distributed/index_merger.rs +++ b/rust/lance-index/src/vector/distributed/index_merger.rs @@ -29,6 +29,7 @@ use crate::{INDEX_AUXILIARY_FILE_NAME, INDEX_METADATA_SCHEMA_KEY}; use arrow_schema::{DataType, Field, Schema as ArrowSchema}; use bytes::Bytes; use lance_core::datatypes::Schema as LanceSchema; +use lance_encoding::version::LanceFileVersion; use lance_file::reader::{FileReader as V2Reader, FileReaderOptions as V2ReaderOptions}; use lance_file::writer::{FileWriter as V2Writer, FileWriter, FileWriterOptions}; use lance_io::scheduler::{ScanScheduler, SchedulerConfig}; @@ -171,6 +172,7 @@ pub async fn init_writer_for_flat( aux_out: &object_store::path::Path, d0: usize, dt: DistanceType, + format_version: LanceFileVersion, ) -> Result { let arrow_schema = ArrowSchema::new(vec![ (*ROW_ID_FIELD).clone(), @@ -187,7 +189,10 @@ pub async fn init_writer_for_flat( let mut w = FileWriter::try_new( writer, LanceSchema::try_from(&arrow_schema)?, - FileWriterOptions::default(), + FileWriterOptions { + format_version: Some(format_version), + ..Default::default() + }, )?; let meta_json = serde_json::to_string(&FlatMetadata { dim: d0 })?; init_writer_for_storage(&mut w, dt, &meta_json, "")?; @@ -203,6 +208,7 @@ pub async fn init_writer_for_pq( aux_out: &object_store::path::Path, dt: DistanceType, pm: &ProductQuantizationMetadata, + format_version: LanceFileVersion, ) -> Result { let num_bytes = if pm.nbits == 4 { pm.num_sub_vectors / 2 @@ -224,7 +230,10 @@ pub async fn init_writer_for_pq( let mut w = FileWriter::try_new( writer, LanceSchema::try_from(&arrow_schema)?, - FileWriterOptions::default(), + FileWriterOptions { + format_version: Some(format_version), + ..Default::default() + }, )?; let mut pm_init = pm.clone(); let cb = pm_init @@ -246,6 +255,7 @@ pub async fn init_writer_for_sq( aux_out: &object_store::path::Path, dt: DistanceType, sq_meta: &ScalarQuantizationMetadata, + format_version: LanceFileVersion, ) -> Result { let d0 = sq_meta.dim; let arrow_schema = ArrowSchema::new(vec![ @@ -263,7 +273,10 @@ pub async fn init_writer_for_sq( let mut w = FileWriter::try_new( writer, LanceSchema::try_from(&arrow_schema)?, - FileWriterOptions::default(), + FileWriterOptions { + format_version: Some(format_version), + ..Default::default() + }, )?; let meta_json = serde_json::to_string(sq_meta)?; init_writer_for_storage(&mut w, dt, &meta_json, SQ_METADATA_KEY)?; @@ -644,6 +657,8 @@ pub async fn merge_partial_vector_auxiliary_files( let mut sq_meta: Option = None; let mut dim: Option = None; let mut detected_index_type: Option = None; + // Inherit file format version from the first shard (set on first iteration) + let mut format_version: Option = None; // Prepare output path; we'll create writer once when we know schema let aux_out = index_dir.child(INDEX_AUXILIARY_FILE_NAME); @@ -679,6 +694,11 @@ pub async fn merge_partial_vector_auxiliary_files( .await?; let meta = reader.metadata(); + // Inherit format version from the first shard file + if format_version.is_none() { + format_version = Some(meta.version()); + } + // Read distance type let dt = meta .file_schema @@ -788,6 +808,10 @@ pub async fn merge_partial_vector_auxiliary_files( // Handle logic based on detected index type let idx_type = detected_index_type .ok_or_else(|| Error::index("Unable to detect index type".to_string()))?; + + // Compute format version once; defaults to V2_0 if no shards processed yet + let fv = format_version.unwrap_or(LanceFileVersion::V2_0); + match idx_type { SupportedIvfIndexType::IvfSq => { // Handle Scalar Quantization (SQ) storage for IVF_SQ @@ -841,7 +865,8 @@ pub async fn merge_partial_vector_auxiliary_files( sq_meta = Some(sq_meta_parsed.clone()); } if v2w_opt.is_none() { - let w = init_writer_for_sq(object_store, &aux_out, dt, &sq_meta_parsed).await?; + let w = + init_writer_for_sq(object_store, &aux_out, dt, &sq_meta_parsed, fv).await?; v2w_opt = Some(w); } } @@ -942,7 +967,8 @@ pub async fn merge_partial_vector_auxiliary_files( if v2w_opt.is_none() { let mut pm_for_unified = pm.clone(); pm_for_unified.transposed = true; - let w = init_writer_for_pq(object_store, &aux_out, dt, &pm_for_unified).await?; + let w = + init_writer_for_pq(object_store, &aux_out, dt, &pm_for_unified, fv).await?; v2w_opt = Some(w); } } @@ -966,7 +992,7 @@ pub async fn merge_partial_vector_auxiliary_files( return Err(Error::index("Dimension mismatch across shards".to_string())); } if v2w_opt.is_none() { - let w = init_writer_for_flat(object_store, &aux_out, d0, dt).await?; + let w = init_writer_for_flat(object_store, &aux_out, d0, dt, fv).await?; v2w_opt = Some(w); } } @@ -1023,7 +1049,7 @@ pub async fn merge_partial_vector_auxiliary_files( return Err(Error::index("Dimension mismatch across shards".to_string())); } if v2w_opt.is_none() { - let w = init_writer_for_flat(object_store, &aux_out, d0, dt).await?; + let w = init_writer_for_flat(object_store, &aux_out, d0, dt, fv).await?; v2w_opt = Some(w); } } @@ -1120,7 +1146,8 @@ pub async fn merge_partial_vector_auxiliary_files( if v2w_opt.is_none() { let mut pm_for_unified = pm.clone(); pm_for_unified.transposed = true; - let w = init_writer_for_pq(object_store, &aux_out, dt, &pm_for_unified).await?; + let w = + init_writer_for_pq(object_store, &aux_out, dt, &pm_for_unified, fv).await?; v2w_opt = Some(w); } } @@ -1171,7 +1198,8 @@ pub async fn merge_partial_vector_auxiliary_files( sq_meta = Some(sq_meta_parsed.clone()); } if v2w_opt.is_none() { - let w = init_writer_for_sq(object_store, &aux_out, dt, &sq_meta_parsed).await?; + let w = + init_writer_for_sq(object_store, &aux_out, dt, &sq_meta_parsed, fv).await?; v2w_opt = Some(w); } } diff --git a/rust/lance-index/src/vector/ivf/shuffler.rs b/rust/lance-index/src/vector/ivf/shuffler.rs index 24928a147d8..f4e03c8f036 100644 --- a/rust/lance-index/src/vector/ivf/shuffler.rs +++ b/rust/lance-index/src/vector/ivf/shuffler.rs @@ -31,6 +31,7 @@ use lance_core::cache::LanceCache; use lance_core::utils::tokio::get_num_compute_intensive_cpus; use lance_core::{Error, ROW_ID, Result, datatypes::Schema}; use lance_encoding::decoder::{DecoderPlugins, FilterExpression}; +use lance_encoding::version::LanceFileVersion; use lance_file::previous::reader::FileReader as PreviousFileReader; use lance_file::previous::writer::FileWriter as PreviousFileWriter; use lance_file::reader::{FileReader as Lancev2FileReader, FileReaderOptions}; @@ -388,6 +389,8 @@ pub struct IvfShuffler { is_legacy: bool, shuffle_output_root_filename: String, + + format_version: LanceFileVersion, } /// Represents a range of batches in a file that should be shuffled @@ -423,9 +426,15 @@ impl IvfShuffler { unsorted_buffers: vec![], is_legacy, shuffle_output_root_filename, + format_version: LanceFileVersion::V2_0, }) } + pub fn with_format_version(mut self, format_version: LanceFileVersion) -> Self { + self.format_version = format_version; + self + } + /// Set the unsorted buffers to be shuffled. /// /// # Safety @@ -778,7 +787,10 @@ impl IvfShuffler { let mut file_writer = lance_file::writer::FileWriter::try_new( writer, lance_schema, - FileWriterOptions::default(), + FileWriterOptions { + format_version: Some(this.format_version), + ..Default::default() + }, )?; for partition_and_idx in shuffled.into_iter().enumerate() { diff --git a/rust/lance-index/src/vector/v3/shuffler.rs b/rust/lance-index/src/vector/v3/shuffler.rs index 7f92670f858..6f91b0572c4 100644 --- a/rust/lance-index/src/vector/v3/shuffler.rs +++ b/rust/lance-index/src/vector/v3/shuffler.rs @@ -17,8 +17,9 @@ use lance_core::{ utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu}, }; use lance_encoding::decoder::{DecoderPlugins, FilterExpression}; +use lance_encoding::version::LanceFileVersion; use lance_file::reader::{FileReader, FileReaderOptions}; -use lance_file::writer::FileWriter; +use lance_file::writer::{FileWriter, FileWriterOptions}; use lance_io::{ object_store::ObjectStore, scheduler::{ScanScheduler, SchedulerConfig}, @@ -66,6 +67,7 @@ pub struct IvfShuffler { object_store: Arc, output_dir: Path, num_partitions: usize, + format_version: LanceFileVersion, // options precomputed_shuffle_buffers: Option>, @@ -78,11 +80,17 @@ impl IvfShuffler { object_store: Arc::new(ObjectStore::local()), output_dir, num_partitions, + format_version: LanceFileVersion::V2_0, precomputed_shuffle_buffers: None, progress: crate::progress::noop_progress(), } } + pub fn with_format_version(mut self, format_version: LanceFileVersion) -> Self { + self.format_version = format_version; + self + } + pub fn with_progress(mut self, progress: Arc) -> Self { self.progress = progress; self @@ -112,12 +120,16 @@ impl Shuffler for IvfShuffler { let spill_path = self.output_dir.child(format!("ivf_{}.spill", partition_id)); let object_store = self.object_store.clone(); let schema = schema.clone(); + let format_version = self.format_version; async move { let writer = object_store.create(&part_path).await?; let file_writer = FileWriter::try_new( writer, lance_core::datatypes::Schema::try_from(&schema)?, - Default::default(), + FileWriterOptions { + format_version: Some(format_version), + ..Default::default() + }, )? .with_page_metadata_spill(object_store.clone(), spill_path); Result::Ok(file_writer) diff --git a/rust/lance/src/dataset/index.rs b/rust/lance/src/dataset/index.rs index 404e53dc0be..77634326568 100644 --- a/rust/lance/src/dataset/index.rs +++ b/rust/lance/src/dataset/index.rs @@ -14,6 +14,7 @@ use crate::index::scalar::infer_scalar_index_details; use arrow_schema::DataType; use async_trait::async_trait; use lance_core::{Error, Result}; +use lance_encoding::version::LanceFileVersion; use lance_index::DatasetIndexExt; use lance_index::frag_reuse::FRAG_REUSE_INDEX_NAME; use lance_index::scalar::lance_format::LanceIndexStore; @@ -128,14 +129,30 @@ pub trait LanceIndexStoreExt { Self: Sized; } +/// Extract the lance file version from a dataset, floored at V2_0. +/// +/// Index files should never use the legacy format. If the dataset uses legacy +/// format or doesn't have a version set, V2_0 is used as the minimum. +pub(crate) fn dataset_format_version(dataset: &Dataset) -> LanceFileVersion { + dataset + .manifest + .data_storage_format + .lance_file_version() + .ok() + .map(|v| v.resolve().max(LanceFileVersion::V2_0)) + .unwrap_or(LanceFileVersion::V2_0) +} + impl LanceIndexStoreExt for LanceIndexStore { fn from_dataset_for_new(dataset: &Dataset, uuid: &str) -> Result { let index_dir = dataset.indices_dir().child(uuid); let cache = dataset.metadata_cache.file_metadata_cache(&index_dir); - Ok(Self::new( + let format_version = dataset_format_version(dataset); + Ok(Self::with_format_version( dataset.object_store.clone(), index_dir, Arc::new(cache), + format_version, )) } @@ -144,10 +161,12 @@ impl LanceIndexStoreExt for LanceIndexStore { .indice_files_dir(index)? .child(index.uuid.to_string()); let cache = dataset.metadata_cache.file_metadata_cache(&index_dir); - Ok(Self::new( + let format_version = dataset_format_version(dataset); + Ok(Self::with_format_version( dataset.object_store.clone(), index_dir, Arc::new(cache), + format_version, )) } } diff --git a/rust/lance/src/dataset/tests/dataset_index.rs b/rust/lance/src/dataset/tests/dataset_index.rs index 562252ea8e3..c3aac4493d4 100644 --- a/rust/lance/src/dataset/tests/dataset_index.rs +++ b/rust/lance/src/dataset/tests/dataset_index.rs @@ -26,8 +26,10 @@ use arrow_schema::{ DataType, Field as ArrowField, Field, Fields as ArrowFields, Schema as ArrowSchema, }; use lance_arrow::ARROW_EXT_NAME_KEY; +use lance_core::cache::LanceCache; use lance_core::utils::tempfile::TempStrDir; use lance_datagen::{BatchCount, Dimension, RowCount, array, gen_batch}; +use lance_file::reader::{FileReader, FileReaderOptions}; use lance_file::version::LanceFileVersion; use lance_index::DatasetIndexExt; use lance_index::scalar::FullTextSearchQuery; @@ -36,6 +38,8 @@ use lance_index::scalar::inverted::{ tokenizer::InvertedIndexParams, }; use lance_index::{IndexType, scalar::ScalarIndexParams, vector::DIST_COL}; +use lance_io::scheduler::{ScanScheduler, SchedulerConfig}; +use lance_io::utils::CachedFileSize; use lance_linalg::distance::MetricType; use datafusion::common::{assert_contains, assert_not_contains}; @@ -2510,3 +2514,190 @@ async fn test_auto_infer_lance_tokenizer() { .unwrap(); assert_eq!(1, batch.num_rows()); } + +#[tokio::test] +async fn test_index_inherits_dataset_file_version() { + // Test that index files use the same format version as the dataset + let test_uri = TempStrDir::default(); + + let dimension = 16; + let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new( + "embeddings", + DataType::FixedSizeList( + Arc::new(ArrowField::new("item", DataType::Float32, true)), + dimension, + ), + false, + )])); + + let float_arr = generate_random_array(512 * dimension as usize); + let vectors = Arc::new( + ::try_new_from_values( + float_arr, dimension, + ) + .unwrap(), + ); + let batches = vec![RecordBatch::try_new(schema.clone(), vec![vectors.clone()]).unwrap()]; + + let reader = RecordBatchIterator::new(batches.into_iter().map(Ok), schema.clone()); + + // Create dataset with V2_1 file version + let dataset_version = LanceFileVersion::V2_1; + let mut dataset = Dataset::write( + reader, + &test_uri, + Some(WriteParams { + data_storage_version: Some(dataset_version), + ..Default::default() + }), + ) + .await + .unwrap(); + + // Create a vector index + let params = VectorIndexParams::ivf_pq(10, 8, 2, MetricType::L2, 50); + let index_meta = dataset + .create_index(&["embeddings"], IndexType::Vector, None, ¶ms, true) + .await + .unwrap(); + + // Get the index directory + let index_dir = dataset.indices_dir().child(index_meta.uuid.to_string()); + + // Open the index file and check its version + let index_path = index_dir.child("index.idx"); + let scheduler = ScanScheduler::new( + dataset.object_store.clone(), + SchedulerConfig::max_bandwidth(&dataset.object_store), + ); + + let file_handle = scheduler + .open_file(&index_path, &CachedFileSize::unknown()) + .await + .unwrap(); + + let index_reader = FileReader::try_open( + file_handle, + None, + Arc::default(), + &LanceCache::no_cache(), + FileReaderOptions::default(), + ) + .await + .unwrap(); + + // Verify that the index file uses the same version as the dataset + assert_eq!( + index_reader.metadata().version(), + dataset_version, + "Index file should use the same format version as the dataset" + ); + + // Also check the auxiliary file if it exists + let aux_path = index_dir.child("auxiliary.idx"); + if dataset + .object_store + .exists(&aux_path) + .await + .unwrap_or(false) + { + let aux_handle = scheduler + .open_file(&aux_path, &CachedFileSize::unknown()) + .await + .unwrap(); + + let aux_reader = FileReader::try_open( + aux_handle, + None, + Arc::default(), + &LanceCache::no_cache(), + FileReaderOptions::default(), + ) + .await + .unwrap(); + + assert_eq!( + aux_reader.metadata().version(), + dataset_version, + "Auxiliary index file should use the same format version as the dataset" + ); + } +} + +#[tokio::test] +async fn test_legacy_dataset_uses_v2_0_for_indexes() { + // Test that datasets with legacy format still use V2_0 for indexes (not legacy) + let test_uri = TempStrDir::default(); + + let dimension = 16; + let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new( + "embeddings", + DataType::FixedSizeList( + Arc::new(ArrowField::new("item", DataType::Float32, true)), + dimension, + ), + false, + )])); + + let float_arr = generate_random_array(512 * dimension as usize); + let vectors = Arc::new( + ::try_new_from_values( + float_arr, dimension, + ) + .unwrap(), + ); + let batches = vec![RecordBatch::try_new(schema.clone(), vec![vectors.clone()]).unwrap()]; + + let reader = RecordBatchIterator::new(batches.into_iter().map(Ok), schema.clone()); + + // Create dataset with legacy file version + let mut dataset = Dataset::write( + reader, + &test_uri, + Some(WriteParams { + data_storage_version: Some(LanceFileVersion::Legacy), + ..Default::default() + }), + ) + .await + .unwrap(); + + // Create a vector index + let params = VectorIndexParams::ivf_pq(10, 8, 2, MetricType::L2, 50); + let index_meta = dataset + .create_index(&["embeddings"], IndexType::Vector, None, ¶ms, true) + .await + .unwrap(); + + // Get the index directory + let index_dir = dataset.indices_dir().child(index_meta.uuid.to_string()); + + // Open the index file and check its version + let index_path = index_dir.child("index.idx"); + let scheduler = ScanScheduler::new( + dataset.object_store.clone(), + SchedulerConfig::max_bandwidth(&dataset.object_store), + ); + + let file_handle = scheduler + .open_file(&index_path, &CachedFileSize::unknown()) + .await + .unwrap(); + + let index_reader = FileReader::try_open( + file_handle, + None, + Arc::default(), + &LanceCache::no_cache(), + FileReaderOptions::default(), + ) + .await + .unwrap(); + + // Verify that the index file uses V2_0 (not legacy) + assert_eq!( + index_reader.metadata().version(), + LanceFileVersion::V2_0, + "Index files should never use legacy format, even for legacy datasets" + ); +} diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index 4fc2f910527..32a67561c5c 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -59,6 +59,7 @@ use utils::get_vector_type; use uuid::Uuid; use super::{DatasetIndexInternalExt, IndexParams, pb, vector_index_details}; +use crate::dataset::index::dataset_format_version; use crate::dataset::transaction::{Operation, Transaction}; use crate::{Error, Result, dataset::Dataset, index::pb::vector_index_stage::Stage}; @@ -397,9 +398,13 @@ pub(crate) async fn build_distributed_vector_index( .as_ref() .clone(); + let format_version = dataset_format_version(dataset); + let temp_dir = TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; - let shuffler = IvfShuffler::new(temp_dir_path, num_partitions).with_progress(progress.clone()); + let shuffler = IvfShuffler::new(temp_dir_path, num_partitions) + .with_format_version(format_version) + .with_progress(progress.clone()); let filtered_dataset = dataset.clone(); @@ -737,9 +742,13 @@ pub(crate) async fn build_vector_index( let mut ivf_params = ivf_params.clone(); ivf_params.num_partitions = Some(num_partitions); + let format_version = dataset_format_version(dataset); + let temp_dir = TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; - let shuffler = IvfShuffler::new(temp_dir_path, num_partitions).with_progress(progress.clone()); + let shuffler = IvfShuffler::new(temp_dir_path, num_partitions) + .with_format_version(format_version) + .with_progress(progress.clone()); match index_type { IndexType::IvfFlat => match element_type { DataType::Float16 | DataType::Float32 | DataType::Float64 => { @@ -1014,10 +1023,14 @@ pub(crate) async fn build_vector_index_incremental( ))); } + let format_version = dataset_format_version(dataset); + let temp_dir = TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; let shuffler = Box::new( - IvfShuffler::new(temp_dir_path, ivf_model.num_partitions()).with_progress(progress.clone()), + IvfShuffler::new(temp_dir_path, ivf_model.num_partitions()) + .with_format_version(format_version) + .with_progress(progress.clone()), ); let index_dir = dataset.indices_dir().child(uuid); diff --git a/rust/lance/src/index/vector/builder.rs b/rust/lance/src/index/vector/builder.rs index cf659df4499..1f871046b71 100644 --- a/rust/lance/src/index/vector/builder.rs +++ b/rust/lance/src/index/vector/builder.rs @@ -28,7 +28,8 @@ use lance_core::datatypes::Schema; use lance_core::utils::tempfile::TempStdDir; use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu}; use lance_core::{Error, ROW_ID_FIELD, Result}; -use lance_file::writer::FileWriter; +use lance_encoding::version::LanceFileVersion; +use lance_file::writer::{FileWriter, FileWriterOptions}; use lance_index::frag_reuse::FragReuseIndex; use lance_index::metrics::NoOpMetricsCollector; use lance_index::optimize::OptimizeOptions; @@ -77,6 +78,7 @@ use tracing::{Level, instrument, span}; use crate::Dataset; use crate::dataset::ProjectionRequest; +use crate::dataset::index::dataset_format_version; use crate::index::vector::ivf::v2::PartitionEntry; use crate::index::vector::utils::{infer_vector_dim, infer_vector_element_type}; @@ -149,6 +151,9 @@ pub struct IvfIndexBuilder { // whether to transpose codes when building storage transpose_codes: bool, + // lance file version for writing index files + format_version: LanceFileVersion, + progress: Arc, } @@ -170,6 +175,7 @@ impl IvfIndexBuilder ) -> Result { let temp_dir = TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; + let format_version = dataset_format_version(&dataset); Ok(Self { store: dataset.object_store().clone(), column, @@ -192,6 +198,7 @@ impl IvfIndexBuilder optimize_options: None, merged_num: 0, transpose_codes: true, + format_version, progress: Arc::new(NoopIndexBuildProgress), }) } @@ -235,6 +242,7 @@ impl IvfIndexBuilder let temp_dir = TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; + let format_version = dataset_format_version(&dataset); Ok(Self { store: dataset.object_store().clone(), column, @@ -256,6 +264,7 @@ impl IvfIndexBuilder optimize_options: None, merged_num: 0, transpose_codes: true, + format_version, progress: Arc::new(NoopIndexBuildProgress), }) } @@ -1046,15 +1055,19 @@ impl IvfIndexBuilder let mut fields = vec![ROW_ID_FIELD.clone(), quantizer.field()]; fields.extend(quantizer.extra_fields()); let storage_schema: Schema = (&arrow_schema::Schema::new(fields)).try_into()?; + let writer_options = FileWriterOptions { + format_version: Some(self.format_version), + ..Default::default() + }; let mut storage_writer = FileWriter::try_new( self.store.create(&storage_path).await?, storage_schema.clone(), - Default::default(), + writer_options.clone(), )?; let mut index_writer = FileWriter::try_new( self.store.create(&index_path).await?, S::schema().as_ref().try_into()?, - Default::default(), + writer_options, )?; // maintain the IVF partitions diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 3d73d52fb2a..23017c67a84 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -8,6 +8,7 @@ use super::{ pq::{PQIndex, build_pq_model}, utils::{filter_finite_training_data, maybe_sample_training_data}, }; +use crate::dataset::index::dataset_format_version; use crate::index::DatasetIndexInternalExt; use crate::index::vector::utils::{get_vector_dim, get_vector_type}; use crate::{ @@ -381,9 +382,13 @@ pub(crate) async fn optimize_vector_indices_v2( let index_type = existing_indices[0].sub_index_type(); let frag_reuse_index = dataset.open_frag_reuse_index(&NoOpMetricsCollector).await?; + let format_version = dataset_format_version(dataset); + let temp_dir = lance_core::utils::tempfile::TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; - let shuffler = Box::new(IvfShuffler::new(temp_dir_path, num_partitions)); + let shuffler = Box::new( + IvfShuffler::new(temp_dir_path, num_partitions).with_format_version(format_version), + ); let (_, element_type) = get_vector_type(dataset.schema(), vector_column)?; let merged_num = match index_type { @@ -1895,6 +1900,8 @@ pub async fn finalize_distributed_merge( .await?; let meta = aux_reader.metadata(); + // Inherit file format version from the unified auxiliary (which inherited it from shards) + let format_version = meta.version(); let ivf_buf_idx: u32 = meta .file_schema .metadata @@ -1987,7 +1994,14 @@ pub async fn finalize_distributed_merge( // Schema for HNSW sub-index: include neighbors/dist fields; empty batch is fine. let arrow_schema = HNSW::schema(); let schema = lance_core::datatypes::Schema::try_from(arrow_schema.as_ref())?; - let mut v2_writer = V2Writer::try_new(obj_writer, schema, V2WriterOptions::default())?; + let mut v2_writer = V2Writer::try_new( + obj_writer, + schema, + V2WriterOptions { + format_version: Some(format_version), + ..Default::default() + }, + )?; // Attach precise index metadata (type + distance). v2_writer.add_schema_metadata(INDEX_METADATA_SCHEMA_KEY, &index_meta_json); From 44b3bd256ed3a6050a3d92b766bce29590583892 Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Mon, 16 Mar 2026 11:43:23 -0500 Subject: [PATCH 031/206] feat: add compaction options in manifest config (#6170) We have an unstructured config in the dataset manifest that currently only stores auto-cleanup metadata. This PR adds compaction options which allows users to set these once and every call to compact will be executed with them (unless explicitly overridden). --------- Signed-off-by: Daniel Rammer --- java/lance-jni/src/blocking_dataset.rs | 9 +- java/lance-jni/src/optimize.rs | 18 ++ java/lance-jni/src/utils.rs | 3 +- python/python/lance/dataset.py | 78 ++++-- python/src/dataset/optimize.rs | 14 +- rust/lance/src/dataset/optimize.rs | 369 ++++++++++++++++++++++++- 6 files changed, 458 insertions(+), 33 deletions(-) diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index 5edfffe7112..7831489c7e9 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -2541,7 +2541,12 @@ fn inner_compact( java_dataset: JObject, compaction_options: JObject, // CompactionOptions ) -> Result<()> { - let rust_options = convert_java_compaction_options_to_rust(env, compaction_options)?; + let config = { + let dataset = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&java_dataset, NATIVE_DATASET) }?; + dataset.inner.manifest.config.clone() + }; + let rust_options = convert_java_compaction_options_to_rust(env, compaction_options, &config)?; let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; dataset_guard.compact(rust_options)?; @@ -2551,6 +2556,7 @@ fn inner_compact( fn convert_java_compaction_options_to_rust( env: &mut JNIEnv, java_options: JObject, + config: &std::collections::HashMap, ) -> Result { let target_rows_per_fragment = env .call_method( @@ -2640,6 +2646,7 @@ fn convert_java_compaction_options_to_rust( &defer_index_remap, &compaction_mode, &binary_copy_read_batch_bytes, + config, ) } diff --git a/java/lance-jni/src/optimize.rs b/java/lance-jni/src/optimize.rs index 9797812ef75..174283e243e 100644 --- a/java/lance-jni/src/optimize.rs +++ b/java/lance-jni/src/optimize.rs @@ -81,6 +81,11 @@ fn inner_plan_compaction<'local>( compaction_mode: JObject, // Optional binary_copy_read_batch_bytes: JObject, // Optional ) -> Result> { + let config = { + let dataset = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&java_dataset, NATIVE_DATASET) }?; + dataset.inner.manifest.config.clone() + }; let compaction_options = build_compaction_options( env, &target_rows_per_fragment, @@ -93,6 +98,7 @@ fn inner_plan_compaction<'local>( &defer_index_remap, &compaction_mode, &binary_copy_read_batch_bytes, + &config, )?; let plan = { @@ -157,6 +163,11 @@ fn inner_commit_compaction<'local>( compaction_mode: JObject, // Optional binary_copy_read_batch_bytes: JObject, // Optional ) -> Result> { + let config = { + let dataset = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&java_dataset, NATIVE_DATASET) }?; + dataset.inner.manifest.config.clone() + }; let compaction_options = build_compaction_options( env, &target_rows_per_fragment, @@ -169,6 +180,7 @@ fn inner_commit_compaction<'local>( &defer_index_remap, &compaction_mode, &binary_copy_read_batch_bytes, + &config, )?; let completed_tasks = import_vec_to_rust(env, &rewrite_results, |env, rewrite_result| { rewrite_result.extract_object(env) @@ -245,6 +257,11 @@ fn inner_execute_task<'local>( binary_copy_read_batch_bytes: JObject, // Optional ) -> Result> { let task_data: TaskData = task_data.extract_object(env)?; + let config = { + let dataset = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&java_dataset, NATIVE_DATASET) }?; + dataset.inner.manifest.config.clone() + }; let compaction_options = build_compaction_options( env, &target_rows_per_fragment, @@ -257,6 +274,7 @@ fn inner_execute_task<'local>( &defer_index_remap, &compaction_mode, &binary_copy_read_batch_bytes, + &config, )?; let compaction_task = CompactionTask { task: task_data, diff --git a/java/lance-jni/src/utils.rs b/java/lance-jni/src/utils.rs index 2b342c99ef7..1e3c8c6aeb2 100644 --- a/java/lance-jni/src/utils.rs +++ b/java/lance-jni/src/utils.rs @@ -141,8 +141,9 @@ pub fn build_compaction_options( defer_index_remap: &JObject, // Optional compaction_mode: &JObject, // Optional binary_copy_read_batch_bytes: &JObject, // Optional + config: &std::collections::HashMap, ) -> Result { - let mut compaction_options = CompactionOptions::default(); + let mut compaction_options = CompactionOptions::from_dataset_config(config)?; if let Some(target_rows_per_fragment_val) = env.get_long_opt(target_rows_per_fragment)? { compaction_options.target_rows_per_fragment = target_rows_per_fragment_val as usize; diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index 0cb9ba9458c..d57281d52e7 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -5342,12 +5342,12 @@ def __init__(self, dataset: LanceDataset): def compact_files( self, *, - target_rows_per_fragment: int = 1024 * 1024, - max_rows_per_group: int = 1024, + target_rows_per_fragment: Optional[int] = None, + max_rows_per_group: Optional[int] = None, max_bytes_per_file: Optional[int] = None, - materialize_deletions: bool = True, - materialize_deletions_threshold: float = 0.1, - defer_index_remap: bool = False, + materialize_deletions: Optional[bool] = None, + materialize_deletions_threshold: Optional[float] = None, + defer_index_remap: Optional[bool] = None, num_threads: Optional[int] = None, batch_size: Optional[int] = None, compaction_mode: Optional[ @@ -5369,14 +5369,33 @@ def compact_files( not be compacted because the fragments it is adjacent to do not need compaction. + Default values for these options can be stored in the dataset manifest + config using keys prefixed with ``lance.compaction.``. For example, + setting the config key ``lance.compaction.target_rows_per_fragment`` to + ``"500000"`` will use 500,000 as the default target rows per fragment. + Explicitly provided parameters take precedence over manifest config + values, which in turn take precedence over hardcoded defaults. + + Supported config keys: ``lance.compaction.target_rows_per_fragment``, + ``lance.compaction.max_rows_per_group``, + ``lance.compaction.max_bytes_per_file``, + ``lance.compaction.materialize_deletions``, + ``lance.compaction.materialize_deletions_threshold``, + ``lance.compaction.defer_index_remap``, + ``lance.compaction.batch_size``, + ``lance.compaction.compaction_mode``, + ``lance.compaction.binary_copy_read_batch_bytes``. + Parameters ---------- - target_rows_per_fragment: int, default 1024*1024 + target_rows_per_fragment: int, optional The target number of rows per fragment. This is the number of rows - that will be in each fragment after compaction. - max_rows_per_group: int, default 1024 + that will be in each fragment after compaction. If not specified, + uses the manifest config value, or 1024*1024. + max_rows_per_group: int, optional Max number of rows per group. This does not affect which fragments need compaction, but does affect how they are re-written if selected. + If not specified, uses the manifest config value, or 1024. This setting only affects datasets using the legacy storage format. The newer format does not require row groups. @@ -5387,14 +5406,17 @@ def compact_files( that are smaller than `target_rows_per_fragment`. The default will use the default from ``write_dataset``. - materialize_deletions: bool, default True + materialize_deletions: bool, optional Whether to compact fragments with soft deleted rows so they are no - longer present in the file. - materialize_deletions_threshold: float, default 0.1 + longer present in the file. If not specified, uses the manifest + config value, or True. + materialize_deletions_threshold: float, optional The fraction of original rows that are soft deleted in a fragment - before the fragment is a candidate for compaction. - defer_index_remap: bool, default False - Whether to defer index remapping during compaction. + before the fragment is a candidate for compaction. If not specified, + uses the manifest config value, or 0.1. + defer_index_remap: bool, optional + Whether to defer index remapping during compaction. If not specified, + uses the manifest config value, or False. num_threads: int, optional The number of threads to use when performing compaction. If not specified, defaults to the number of cores on the machine. @@ -5425,18 +5447,22 @@ def compact_files( -------- lance.optimize.Compaction """ - opts = dict( - target_rows_per_fragment=target_rows_per_fragment, - max_rows_per_group=max_rows_per_group, - max_bytes_per_file=max_bytes_per_file, - materialize_deletions=materialize_deletions, - materialize_deletions_threshold=materialize_deletions_threshold, - defer_index_remap=defer_index_remap, - num_threads=num_threads, - batch_size=batch_size, - compaction_mode=compaction_mode, - binary_copy_read_batch_bytes=binary_copy_read_batch_bytes, - ) + opts = { + k: v + for k, v in dict( + target_rows_per_fragment=target_rows_per_fragment, + max_rows_per_group=max_rows_per_group, + max_bytes_per_file=max_bytes_per_file, + materialize_deletions=materialize_deletions, + materialize_deletions_threshold=materialize_deletions_threshold, + defer_index_remap=defer_index_remap, + num_threads=num_threads, + batch_size=batch_size, + compaction_mode=compaction_mode, + binary_copy_read_batch_bytes=binary_copy_read_batch_bytes, + ).items() + if v is not None + } return Compaction.execute(self._dataset, opts) def optimize_indices(self, **kwargs): diff --git a/python/src/dataset/optimize.rs b/python/src/dataset/optimize.rs index d2e60ec0a00..2f6286c0e2a 100644 --- a/python/src/dataset/optimize.rs +++ b/python/src/dataset/optimize.rs @@ -23,8 +23,12 @@ use pyo3::{exceptions::PyNotImplementedError, pyclass::CompareOp, types::PyTuple use super::*; -fn parse_compaction_options(options: &Bound<'_, PyDict>) -> PyResult { - let mut opts = CompactionOptions::default(); +fn parse_compaction_options( + options: &Bound<'_, PyDict>, + config: &std::collections::HashMap, +) -> PyResult { + let mut opts = CompactionOptions::from_dataset_config(config) + .map_err(|e| PyValueError::new_err(e.to_string()))?; for (key, value) in options.into_iter() { let key: String = key.extract()?; @@ -485,7 +489,8 @@ impl PyCompaction { // Make sure we parse the options within a scoped GIL context, so we // aren't holding the GIL while blocking the thread on the operation. let options = options.downcast::()?; - let opts = parse_compaction_options(options)?; + let config = dataset.ds.manifest.config.clone(); + let opts = parse_compaction_options(options, &config)?; let mut new_ds = dataset.ds.as_ref().clone(); let fut = compact_files(&mut new_ds, opts, None); let metrics = rt().block_on(None, async move { @@ -518,7 +523,8 @@ impl PyCompaction { // Make sure we parse the options within a scoped GIL context, so we // aren't holding the GIL while blocking the thread on the operation. let options = options.downcast::()?; - let opts = parse_compaction_options(options)?; + let config = dataset.ds.manifest.config.clone(); + let opts = parse_compaction_options(options, &config)?; let plan = rt() .block_on(None, async move { plan_compaction(dataset.ds.as_ref(), &opts).await diff --git a/rust/lance/src/dataset/optimize.rs b/rust/lance/src/dataset/optimize.rs index b61fd425860..f950487f785 100644 --- a/rust/lance/src/dataset/optimize.rs +++ b/rust/lance/src/dataset/optimize.rs @@ -108,7 +108,7 @@ use lance_index::frag_reuse::FragReuseGroup; use lance_table::format::{Fragment, RowIdMeta}; use roaring::{RoaringBitmap, RoaringTreemap}; use serde::{Deserialize, Serialize}; -use tracing::info; +use tracing::{info, warn}; mod binary_copy; pub mod remapping; @@ -224,8 +224,123 @@ impl Default for CompactionOptions { } } +/// Config key prefix for compaction options stored in the dataset manifest. +pub const COMPACTION_CONFIG_PREFIX: &str = "lance.compaction."; + #[allow(deprecated)] impl CompactionOptions { + /// Create [`CompactionOptions`] by starting with defaults and applying any + /// overrides found in the dataset manifest config. + /// + /// Config keys are prefixed with `lance.compaction.` and map to fields: + /// - `lance.compaction.target_rows_per_fragment` + /// - `lance.compaction.max_rows_per_group` + /// - `lance.compaction.max_bytes_per_file` + /// - `lance.compaction.materialize_deletions` + /// - `lance.compaction.materialize_deletions_threshold` + /// - `lance.compaction.defer_index_remap` + /// - `lance.compaction.batch_size` + /// - `lance.compaction.compaction_mode` + /// - `lance.compaction.binary_copy_read_batch_bytes` + pub fn from_dataset_config(config: &HashMap) -> Result { + let mut opts = Self::default(); + opts.apply_dataset_config(config)?; + Ok(opts) + } + + /// Apply overrides from the dataset manifest config to this options struct. + /// + /// Only fields with corresponding config keys are modified; other fields + /// retain their current values. + pub fn apply_dataset_config(&mut self, config: &HashMap) -> Result<()> { + for (key, value) in config { + let Some(field) = key.strip_prefix(COMPACTION_CONFIG_PREFIX) else { + continue; + }; + match field { + "target_rows_per_fragment" => { + self.target_rows_per_fragment = value.parse().map_err(|_| { + Error::invalid_input(format!( + "Invalid value for {}: '{}' (expected a non-negative integer)", + key, value + )) + })?; + } + "max_rows_per_group" => { + self.max_rows_per_group = value.parse().map_err(|_| { + Error::invalid_input(format!( + "Invalid value for {}: '{}' (expected a non-negative integer)", + key, value + )) + })?; + } + "max_bytes_per_file" => { + self.max_bytes_per_file = Some(value.parse().map_err(|_| { + Error::invalid_input(format!( + "Invalid value for {}: '{}' (expected a non-negative integer)", + key, value + )) + })?); + } + "materialize_deletions" => { + self.materialize_deletions = match value.to_lowercase().as_str() { + "true" => true, + "false" => false, + _ => { + return Err(Error::invalid_input(format!( + "Invalid value for {}: '{}' (expected 'true' or 'false')", + key, value + ))); + } + }; + } + "materialize_deletions_threshold" => { + self.materialize_deletions_threshold = value.parse().map_err(|_| { + Error::invalid_input(format!( + "Invalid value for {}: '{}' (expected a float between 0.0 and 1.0)", + key, value + )) + })?; + } + "defer_index_remap" => { + self.defer_index_remap = match value.to_lowercase().as_str() { + "true" => true, + "false" => false, + _ => { + return Err(Error::invalid_input(format!( + "Invalid value for {}: '{}' (expected 'true' or 'false')", + key, value + ))); + } + }; + } + "batch_size" => { + self.batch_size = Some(value.parse().map_err(|_| { + Error::invalid_input(format!( + "Invalid value for {}: '{}' (expected a non-negative integer)", + key, value + )) + })?); + } + "compaction_mode" => { + self.compaction_mode = Some(CompactionMode::try_from(value.as_str())?); + } + "binary_copy_read_batch_bytes" => { + self.binary_copy_read_batch_bytes = Some(value.parse().map_err(|_| { + Error::invalid_input(format!( + "Invalid value for {}: '{}' (expected a non-negative integer)", + key, value + )) + })?); + } + _ => { + warn!("Ignoring unknown compaction config key: {}", key); + } + } + } + Ok(()) + } + pub fn validate(&mut self) { // If threshold is 100%, same as turning off deletion materialization. if self.materialize_deletions && self.materialize_deletions_threshold >= 1.0 { @@ -4043,4 +4158,256 @@ mod tests { // make sure options.validate() worked assert!(!plan.options.materialize_deletions); } + + #[test] + fn test_from_dataset_config() { + let config = HashMap::from([ + ( + "lance.compaction.target_rows_per_fragment".to_string(), + "500000".to_string(), + ), + ( + "lance.compaction.max_rows_per_group".to_string(), + "2048".to_string(), + ), + ( + "lance.compaction.max_bytes_per_file".to_string(), + "1000000".to_string(), + ), + ( + "lance.compaction.materialize_deletions".to_string(), + "false".to_string(), + ), + ( + "lance.compaction.materialize_deletions_threshold".to_string(), + "0.25".to_string(), + ), + ( + "lance.compaction.defer_index_remap".to_string(), + "true".to_string(), + ), + ( + "lance.compaction.batch_size".to_string(), + "4096".to_string(), + ), + ( + "lance.compaction.compaction_mode".to_string(), + "try_binary_copy".to_string(), + ), + ( + "lance.compaction.binary_copy_read_batch_bytes".to_string(), + "8388608".to_string(), + ), + ]); + + let opts = CompactionOptions::from_dataset_config(&config).unwrap(); + assert_eq!(opts.target_rows_per_fragment, 500_000); + assert_eq!(opts.max_rows_per_group, 2048); + assert_eq!(opts.max_bytes_per_file, Some(1_000_000)); + assert!(!opts.materialize_deletions); + assert!((opts.materialize_deletions_threshold - 0.25).abs() < f32::EPSILON); + assert!(opts.defer_index_remap); + assert_eq!(opts.batch_size, Some(4096)); + assert_eq!(opts.compaction_mode, Some(CompactionMode::TryBinaryCopy)); + assert_eq!(opts.binary_copy_read_batch_bytes, Some(8_388_608)); + } + + #[test] + fn test_from_dataset_config_empty() { + let config = HashMap::new(); + let opts = CompactionOptions::from_dataset_config(&config).unwrap(); + let defaults = CompactionOptions::default(); + assert_eq!( + opts.target_rows_per_fragment, + defaults.target_rows_per_fragment + ); + assert_eq!(opts.max_rows_per_group, defaults.max_rows_per_group); + assert_eq!(opts.max_bytes_per_file, defaults.max_bytes_per_file); + assert_eq!(opts.materialize_deletions, defaults.materialize_deletions); + assert_eq!( + opts.materialize_deletions_threshold, + defaults.materialize_deletions_threshold + ); + assert_eq!(opts.defer_index_remap, defaults.defer_index_remap); + assert_eq!(opts.batch_size, defaults.batch_size); + assert_eq!(opts.compaction_mode, defaults.compaction_mode); + assert_eq!( + opts.binary_copy_read_batch_bytes, + defaults.binary_copy_read_batch_bytes + ); + } + + #[test] + fn test_from_dataset_config_partial() { + let config = HashMap::from([( + "lance.compaction.target_rows_per_fragment".to_string(), + "500000".to_string(), + )]); + + let opts = CompactionOptions::from_dataset_config(&config).unwrap(); + assert_eq!(opts.target_rows_per_fragment, 500_000); + // Other fields should remain at defaults + let defaults = CompactionOptions::default(); + assert_eq!(opts.max_rows_per_group, defaults.max_rows_per_group); + assert_eq!(opts.max_bytes_per_file, defaults.max_bytes_per_file); + assert_eq!(opts.materialize_deletions, defaults.materialize_deletions); + assert_eq!(opts.defer_index_remap, defaults.defer_index_remap); + assert_eq!(opts.batch_size, defaults.batch_size); + assert_eq!(opts.compaction_mode, defaults.compaction_mode); + assert_eq!( + opts.binary_copy_read_batch_bytes, + defaults.binary_copy_read_batch_bytes + ); + } + + #[test] + fn test_from_dataset_config_ignores_other_keys() { + let config = HashMap::from([ + ( + "lance.compaction.target_rows_per_fragment".to_string(), + "500000".to_string(), + ), + ( + "lance.auto_cleanup.interval".to_string(), + "3600".to_string(), + ), + ("some.other.key".to_string(), "value".to_string()), + ]); + + let opts = CompactionOptions::from_dataset_config(&config).unwrap(); + assert_eq!(opts.target_rows_per_fragment, 500_000); + } + + #[test] + fn test_from_dataset_config_invalid_value() { + let config = HashMap::from([( + "lance.compaction.target_rows_per_fragment".to_string(), + "not_a_number".to_string(), + )]); + + let result = CompactionOptions::from_dataset_config(&config); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("target_rows_per_fragment")); + assert!(err_msg.contains("not_a_number")); + } + + #[test] + fn test_from_dataset_config_invalid_bool() { + let config = HashMap::from([( + "lance.compaction.materialize_deletions".to_string(), + "yes".to_string(), + )]); + + let result = CompactionOptions::from_dataset_config(&config); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("materialize_deletions")); + assert!(err_msg.contains("yes")); + } + + #[test] + fn test_from_dataset_config_unknown_compaction_key() { + // Unknown keys should be ignored (with a warning) for forwards compatibility + let config = HashMap::from([( + "lance.compaction.unknown_key".to_string(), + "value".to_string(), + )]); + + let opts = CompactionOptions::from_dataset_config(&config).unwrap(); + // Should return defaults since the unknown key is skipped + let defaults = CompactionOptions::default(); + assert_eq!( + opts.target_rows_per_fragment, + defaults.target_rows_per_fragment + ); + } + + #[test] + fn test_from_dataset_config_invalid_compaction_mode() { + let config = HashMap::from([( + "lance.compaction.compaction_mode".to_string(), + "invalid_mode".to_string(), + )]); + + let result = CompactionOptions::from_dataset_config(&config); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("invalid_mode")); + } + + #[test] + fn test_apply_dataset_config_overrides() { + let config = HashMap::from([( + "lance.compaction.target_rows_per_fragment".to_string(), + "500000".to_string(), + )]); + + let mut opts = CompactionOptions { + max_rows_per_group: 4096, + ..Default::default() + }; + opts.apply_dataset_config(&config).unwrap(); + + // Config value should be applied + assert_eq!(opts.target_rows_per_fragment, 500_000); + // Explicitly set value should be preserved (config didn't have this key) + assert_eq!(opts.max_rows_per_group, 4096); + } + + #[test] + fn test_apply_dataset_config_overwrites_matching_field() { + let config = HashMap::from([( + "lance.compaction.max_rows_per_group".to_string(), + "2048".to_string(), + )]); + + let mut opts = CompactionOptions { + max_rows_per_group: 4096, + ..Default::default() + }; + opts.apply_dataset_config(&config).unwrap(); + + // Config value should overwrite the pre-set value + assert_eq!(opts.max_rows_per_group, 2048); + } + + #[tokio::test] + async fn test_compaction_uses_manifest_config() { + let test_dir = TempStrDir::default(); + let test_uri = &test_dir; + + let data = sample_data(); + let schema = data.schema(); + + // Create dataset with small fragments + let reader = RecordBatchIterator::new(vec![Ok(data.clone())], schema.clone()); + let write_params = WriteParams { + max_rows_per_file: 2000, + ..Default::default() + }; + let mut dataset = Dataset::write(reader, test_uri, Some(write_params)) + .await + .unwrap(); + + assert_eq!(dataset.get_fragments().len(), 5); + + // Set compaction config in manifest + dataset + .update_config([ + ("lance.compaction.target_rows_per_fragment", "5000"), + ("lance.compaction.materialize_deletions_threshold", "2.0"), + ]) + .await + .unwrap(); + + // Build options from the dataset config (as the bindings do) + let opts = CompactionOptions::from_dataset_config(&dataset.manifest.config).unwrap(); + assert_eq!(opts.target_rows_per_fragment, 5000); + assert!((opts.materialize_deletions_threshold - 2.0).abs() < f32::EPSILON); + + // Verify the config flows through plan_compaction + let plan = plan_compaction(&dataset, &opts).await.unwrap(); + assert!(!plan.tasks.is_empty()); + assert_eq!(plan.options.target_rows_per_fragment, 5000); + // validate() should have turned off materialize_deletions since threshold >= 1.0 + assert!(!plan.options.materialize_deletions); + } } From bfe7f56cac76287eb9715a922605960bfed039e9 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Mon, 16 Mar 2026 09:53:33 -0700 Subject: [PATCH 032/206] feat: reduce open file handles during IVF training (#6169) The previous shuffler used one open file per partition. At large scales this meant tedious re-adjusting of OS limits. The new shuffler uses two open files. We potentially introduce a bit more random access in the later read phase but the overall performance hasn't changed significantly. --- rust/lance-index/src/vector/v3/shuffler.rs | 645 ++++++++++++++++++++- rust/lance/src/index/vector.rs | 59 +- rust/lance/src/index/vector/ivf.rs | 6 +- 3 files changed, 666 insertions(+), 44 deletions(-) diff --git a/rust/lance-index/src/vector/v3/shuffler.rs b/rust/lance-index/src/vector/v3/shuffler.rs index 6f91b0572c4..0bf714df237 100644 --- a/rust/lance-index/src/vector/v3/shuffler.rs +++ b/rust/lance-index/src/vector/v3/shuffler.rs @@ -4,12 +4,17 @@ //! Shuffler is a component that takes a stream of record batches and shuffles them into //! the corresponding IVF partitions. -use std::sync::Arc; +use std::ops::Range; +use std::sync::atomic::AtomicU64; +use std::sync::{Arc, Mutex}; +use arrow::compute::concat_batches; +use arrow::datatypes::UInt64Type; use arrow::{array::AsArray, compute::sort_to_indices}; -use arrow_array::{RecordBatch, UInt32Array}; -use arrow_schema::Schema; +use arrow_array::{RecordBatch, UInt32Array, UInt64Array}; +use arrow_schema::{DataType, Field, Schema}; use futures::{future::try_join_all, prelude::*}; +use lance_arrow::stream::rechunk_stream_by_size; use lance_arrow::{RecordBatchExt, SchemaExt}; use lance_core::{ Error, Result, @@ -21,6 +26,7 @@ use lance_encoding::version::LanceFileVersion; use lance_file::reader::{FileReader, FileReaderOptions}; use lance_file::writer::{FileWriter, FileWriterOptions}; use lance_io::{ + ReadBatchParams, object_store::ObjectStore, scheduler::{ScanScheduler, SchedulerConfig}, stream::{RecordBatchStream, RecordBatchStreamAdapter}, @@ -69,8 +75,6 @@ pub struct IvfShuffler { num_partitions: usize, format_version: LanceFileVersion, - // options - precomputed_shuffle_buffers: Option>, progress: Arc, } @@ -81,7 +85,6 @@ impl IvfShuffler { output_dir, num_partitions, format_version: LanceFileVersion::V2_0, - precomputed_shuffle_buffers: None, progress: crate::progress::noop_progress(), } } @@ -95,14 +98,6 @@ impl IvfShuffler { self.progress = progress; self } - - pub fn with_precomputed_shuffle_buffers( - mut self, - precomputed_shuffle_buffers: Option>, - ) -> Self { - self.precomputed_shuffle_buffers = precomputed_shuffle_buffers; - self - } } #[async_trait::async_trait] @@ -299,3 +294,625 @@ impl ShuffleReader for EmptyReader { None } } + +/// Create an IVF shuffler. Uses [`TwoFileShuffler`] by default, which writes +/// all data to just two files (data + offsets) instead of one file per partition. +/// Set `LANCE_LEGACY_SHUFFLER=1` to fall back to [`IvfShuffler`], which opens +/// one file per partition. +/// +/// An optional `progress` callback can be provided to receive shuffle progress +/// updates. +pub fn create_ivf_shuffler( + output_dir: Path, + num_partitions: usize, + format_version: LanceFileVersion, + progress: Option>, +) -> Box { + let use_legacy = std::env::var("LANCE_LEGACY_SHUFFLER") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + if use_legacy { + let mut shuffler = + IvfShuffler::new(output_dir, num_partitions).with_format_version(format_version); + if let Some(progress) = progress { + shuffler = shuffler.with_progress(progress); + } + Box::new(shuffler) + } else { + let mut shuffler = TwoFileShuffler::new(output_dir, num_partitions); + if let Some(progress) = progress { + shuffler = shuffler.with_progress(progress); + } + Box::new(shuffler) + } +} + +const DEFAULT_SHUFFLE_BATCH_BYTES: usize = 128 * 1024 * 1024; + +/// Limit of how much transformed data we accumulate before spilling to disk. +/// +/// A larger value will use more RAM but require less random access during the +/// read phase. +/// +/// This default is likely to be fine for most use cases. +fn shuffle_batch_bytes() -> usize { + let batch_size = std::env::var("LANCE_SHUFFLE_BATCH_BYTES") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_SHUFFLE_BATCH_BYTES); + if batch_size == 0 { + log::warn!( + "LANCE_SHUFFLE_BATCH_BYTES is 0, using default of {}", + DEFAULT_SHUFFLE_BATCH_BYTES + ); + DEFAULT_SHUFFLE_BATCH_BYTES + } else { + batch_size + } +} + +/// A shuffler that writes all data to just two files (data + offsets) instead +/// of one file per partition. This avoids hitting OS file descriptor limits +/// when there are many partitions. +/// +/// First we accumulate data in memory until we reach the batch size limit. +/// Then we sort the data by partition ID and compute an offset per partition. +/// Then we write the data to a data file and the offsets to an offsets file. +/// +/// To read the data back, we read every Nth value from the offsets file to get +/// the start and end of each partition. +/// +/// Then we read those ranges from the data file. +pub struct TwoFileShuffler { + object_store: Arc, + output_dir: Path, + num_partitions: usize, + batch_size_bytes: usize, + + progress: Arc, +} + +impl TwoFileShuffler { + pub fn new(output_dir: Path, num_partitions: usize) -> Self { + Self { + object_store: Arc::new(ObjectStore::local()), + output_dir, + num_partitions, + batch_size_bytes: shuffle_batch_bytes(), + progress: crate::progress::noop_progress(), + } + } + + pub fn with_progress(mut self, progress: Arc) -> Self { + self.progress = progress; + self + } + + #[cfg(test)] + fn with_batch_size_bytes(mut self, batch_size_bytes: usize) -> Self { + self.batch_size_bytes = batch_size_bytes; + self + } +} + +#[async_trait::async_trait] +impl Shuffler for TwoFileShuffler { + async fn shuffle( + &self, + data: Box, + ) -> Result> { + let num_partitions = self.num_partitions; + let full_schema = Arc::new(data.schema().as_ref().clone()); + // No need to write partition ids since we can infer this + let schema = data.schema().without_column(PART_ID_COLUMN); + let offsets_schema = Arc::new(Schema::new(vec![Field::new( + "offset", + DataType::UInt64, + false, + )])); + let batch_size_bytes = self.batch_size_bytes; + + // Extract loss from batch metadata before rechunking (concat_batches drops metadata) + let total_loss = Arc::new(Mutex::new(0.0f64)); + let loss_ref = total_loss.clone(); + let loss_stream = data.map(move |result| { + result.inspect(|batch| { + let loss = batch + .metadata() + .get(LOSS_METADATA_KEY) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0); + *loss_ref.lock().unwrap() += loss; + }) + }); + + // Rechunk to target batch size + let rechunked = rechunk_stream_by_size( + loss_stream, + full_schema, + batch_size_bytes, + batch_size_bytes * 2, + ); + + // Create data file writer + let data_path = self.output_dir.child("shuffle_data.lance"); + let spill_path = self.output_dir.child("shuffle_data.spill"); + let writer = self.object_store.create(&data_path).await?; + let mut file_writer = FileWriter::try_new( + writer, + lance_core::datatypes::Schema::try_from(&schema)?, + Default::default(), + )? + .with_page_metadata_spill(self.object_store.clone(), spill_path); + + // Create offsets file writer + let offsets_path = self.output_dir.child("shuffle_offsets.lance"); + let spill_path = self.output_dir.child("shuffle_offsets.spill"); + let writer = self.object_store.create(&offsets_path).await?; + let mut offsets_writer = FileWriter::try_new( + writer, + lance_core::datatypes::Schema::try_from(offsets_schema.as_ref())?, + Default::default(), + )? + .with_page_metadata_spill(self.object_store.clone(), spill_path); + + let num_batches = Arc::new(AtomicU64::new(0)); + let num_batches_ref = num_batches.clone(); + let mut partition_counts: Vec = vec![0; num_partitions]; + let mut global_row_count: u64 = 0; + let mut rows_processed: u64 = 0; + + let mut rechunked = std::pin::pin!(rechunked); + while let Some(batch) = rechunked.next().await { + num_batches_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let batch = batch?; + let np = num_partitions; + let num_rows = batch.num_rows() as u64; + + // Sort by partition ID and compute offsets on CPU + let (sorted_batch, batch_offsets) = spawn_cpu(move || { + let part_ids: &UInt32Array = batch[PART_ID_COLUMN].as_primitive(); + let indices = sort_to_indices(part_ids, None, None)?; + let batch = batch.take(&indices)?; + + let part_ids: &UInt32Array = batch[PART_ID_COLUMN].as_primitive(); + let batch = batch.drop_column(PART_ID_COLUMN)?; + + // Count rows per partition by scanning sorted part IDs + let mut partition_counts = vec![0u64; np]; + for i in 0..part_ids.len() { + let pid = part_ids.value(i) as usize; + if pid < np { + partition_counts[pid] += 1; + } else { + log::warn!("Partition ID {} is out of range [0, {})", pid, np); + } + } + + // Build cumulative offsets (end positions) for this batch + let mut batch_offsets = Vec::with_capacity(np); + let mut running = 0u64; + for count in &partition_counts { + running += count; + batch_offsets.push(running); + } + + Ok::<(RecordBatch, Vec), Error>((batch, batch_offsets)) + }) + .await?; + + // Write sorted batch to data file + file_writer.write_batch(&sorted_batch).await?; + + // Record offsets adjusted by global row count + let mut adjusted_offsets = Vec::with_capacity(batch_offsets.len()); + let mut last_offset = 0; + for (idx, offset) in batch_offsets.iter().enumerate() { + adjusted_offsets.push(global_row_count + offset); + partition_counts[idx] += offset - last_offset; + last_offset = *offset; + } + global_row_count += sorted_batch.num_rows() as u64; + + // Write offsets to offsets file + let offsets_batch = RecordBatch::try_new( + offsets_schema.clone(), + vec![Arc::new(UInt64Array::from(adjusted_offsets))], + )?; + offsets_writer.write_batch(&offsets_batch).await?; + + rows_processed += num_rows; + self.progress + .stage_progress("shuffle", rows_processed) + .await?; + } + + // Finish files + file_writer.finish().await?; + offsets_writer.finish().await?; + + let num_batches = num_batches.load(std::sync::atomic::Ordering::Relaxed); + + let total_loss_val = *total_loss.lock().unwrap(); + + TwoFileShuffleReader::try_new( + self.object_store.clone(), + self.output_dir.clone(), + num_partitions, + num_batches, + partition_counts, + total_loss_val, + ) + .await + } +} + +pub struct TwoFileShuffleReader { + _scheduler: Arc, + file_reader: FileReader, + offsets_reader: FileReader, + num_partitions: usize, + num_batches: u64, + partition_counts: Vec, + total_loss: f64, +} + +impl TwoFileShuffleReader { + async fn try_new( + object_store: Arc, + output_dir: Path, + num_partitions: usize, + num_batches: u64, + partition_counts: Vec, + total_loss: f64, + ) -> Result> { + if num_batches == 0 { + return Ok(Box::new(EmptyReader)); + } + + let scheduler_config = SchedulerConfig::max_bandwidth(&object_store); + let scheduler = ScanScheduler::new(object_store, scheduler_config); + + let data_path = output_dir.child("shuffle_data.lance"); + let file_reader = FileReader::try_open( + scheduler + .open_file(&data_path, &CachedFileSize::unknown()) + .await?, + None, + Arc::::default(), + &LanceCache::no_cache(), + FileReaderOptions::default(), + ) + .await?; + + let offsets_path = output_dir.child("shuffle_offsets.lance"); + let offsets_reader = FileReader::try_open( + scheduler + .open_file(&offsets_path, &CachedFileSize::unknown()) + .await?, + None, + Arc::::default(), + &LanceCache::no_cache(), + FileReaderOptions::default(), + ) + .await?; + + Ok(Box::new(Self { + _scheduler: scheduler, + file_reader, + offsets_reader, + num_partitions, + num_batches, + partition_counts, + total_loss, + })) + } + + async fn partition_ranges(&self, partition_id: usize) -> Result>> { + let mut positions = Vec::with_capacity(self.num_batches as usize * 2); + for batch_idx in 0..self.num_batches { + let end_pos = u32::try_from(batch_idx as usize * self.num_partitions + partition_id) + .map_err(|_| Error::invalid_input("There are more than 2^32 partition offsets in the spill file. Need to support 64-bit take"))?; + if end_pos != 0 { + positions.push(end_pos - 1); + } + positions.push(end_pos); + } + let positions = UInt32Array::from(positions); + let num_positions = positions.len() as u32; + let offsets_stream = self.offsets_reader.read_stream( + ReadBatchParams::Indices(positions), + num_positions, + 1, + FilterExpression::no_filter(), + )?; + let schema = offsets_stream.schema().clone(); + let offsets = offsets_stream.try_collect::>().await?; + let offsets = if offsets.is_empty() { + // We should not hit this path if there is no batches + unreachable!() + } else if offsets.len() == 1 { + offsets.into_iter().next().unwrap() + } else { + concat_batches(&schema, &offsets)? + }; + + let offsets = offsets.column(0).as_primitive::(); + let mut offsets_iter = offsets.values().iter().copied(); + + let mut ranges = Vec::with_capacity(self.num_batches as usize); + for batch_idx in 0..self.num_batches { + if batch_idx == 0 && partition_id == 0 { + // Implicit 0 for start-of-file + ranges.push(0..offsets_iter.next().unwrap()); + } else { + ranges.push(offsets_iter.next().unwrap()..offsets_iter.next().unwrap()); + } + } + Ok(ranges) + } +} + +#[async_trait::async_trait] +impl ShuffleReader for TwoFileShuffleReader { + async fn read_partition( + &self, + partition_id: usize, + ) -> Result>> { + if partition_id >= self.num_partitions { + return Ok(None); + } + if self.partition_counts[partition_id] == 0 { + return Ok(None); + } + + let ranges = self.partition_ranges(partition_id).await?; + if ranges.is_empty() { + return Ok(None); + } + + let schema: Schema = self.file_reader.schema().as_ref().into(); + Ok(Some(Box::new(RecordBatchStreamAdapter::new( + Arc::new(schema), + self.file_reader.read_stream( + ReadBatchParams::Ranges(ranges.into()), + u32::MAX, + 16, + FilterExpression::no_filter(), + )?, + )))) + } + + fn partition_size(&self, partition_id: usize) -> Result { + Ok(self + .partition_counts + .get(partition_id) + .copied() + .unwrap_or(0) as usize) + } + + fn total_loss(&self) -> Option { + Some(self.total_loss) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use arrow_array::{Int32Array, RecordBatch, UInt32Array}; + use arrow_schema::{DataType, Field, Schema as ArrowSchema}; + use futures::stream; + use lance_arrow::RecordBatchExt; + use lance_core::utils::tempfile::TempStrDir; + use lance_io::stream::RecordBatchStreamAdapter; + + use crate::vector::{LOSS_METADATA_KEY, PART_ID_COLUMN}; + + /// Create a test batch with partition IDs, an int column, and optional loss metadata. + fn make_batch(part_ids: &[u32], values: &[i32], loss: Option) -> RecordBatch { + let schema = Arc::new(ArrowSchema::new(vec![ + Field::new(PART_ID_COLUMN, DataType::UInt32, false), + Field::new("val", DataType::Int32, false), + ])); + let batch = RecordBatch::try_new( + schema, + vec![ + Arc::new(UInt32Array::from(part_ids.to_vec())), + Arc::new(Int32Array::from(values.to_vec())), + ], + ) + .unwrap(); + if let Some(loss_val) = loss { + batch + .add_metadata(LOSS_METADATA_KEY.to_owned(), loss_val.to_string()) + .unwrap() + } else { + batch + } + } + + fn batches_to_stream( + batches: Vec, + ) -> Box { + let schema = batches[0].schema(); + let stream = stream::iter(batches.into_iter().map(Ok)); + Box::new(RecordBatchStreamAdapter::new(schema, stream)) + } + + /// Collect all rows from a partition into a single RecordBatch. + async fn collect_partition( + reader: &dyn ShuffleReader, + partition_id: usize, + ) -> Option { + let stream = reader.read_partition(partition_id).await.unwrap()?; + let batches: Vec = stream.try_collect().await.unwrap(); + if batches.is_empty() { + return None; + } + Some(arrow::compute::concat_batches(&batches[0].schema(), &batches).unwrap()) + } + + #[tokio::test] + async fn test_two_file_shuffler_round_trip() { + let dir = TempStrDir::default(); + let output_dir = Path::from(dir.as_ref()); + let num_partitions = 3; + + // Partition 0: rows with values 10, 40 + // Partition 1: rows with values 20, 50 + // Partition 2: rows with values 30 + let batch = make_batch(&[0, 1, 2, 0, 1], &[10, 20, 30, 40, 50], None); + + let shuffler = TwoFileShuffler::new(output_dir, num_partitions); + let stream = batches_to_stream(vec![batch]); + let reader = shuffler.shuffle(stream).await.unwrap(); + + // Verify partition sizes + assert_eq!(reader.partition_size(0).unwrap(), 2); + assert_eq!(reader.partition_size(1).unwrap(), 2); + assert_eq!(reader.partition_size(2).unwrap(), 1); + + // Verify partition 0 data + let p0 = collect_partition(reader.as_ref(), 0).await.unwrap(); + let vals: &Int32Array = p0.column_by_name("val").unwrap().as_primitive(); + let mut v: Vec = vals.iter().map(|x| x.unwrap()).collect(); + v.sort(); + assert_eq!(v, vec![10, 40]); + + // Verify partition 1 data + let p1 = collect_partition(reader.as_ref(), 1).await.unwrap(); + let vals: &Int32Array = p1.column_by_name("val").unwrap().as_primitive(); + let mut v: Vec = vals.iter().map(|x| x.unwrap()).collect(); + v.sort(); + assert_eq!(v, vec![20, 50]); + + // Verify partition 2 data + let p2 = collect_partition(reader.as_ref(), 2).await.unwrap(); + let vals: &Int32Array = p2.column_by_name("val").unwrap().as_primitive(); + let v: Vec = vals.iter().map(|x| x.unwrap()).collect(); + assert_eq!(v, vec![30]); + + // Out of range partition returns None + assert!(reader.read_partition(3).await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_two_file_shuffler_empty_partitions() { + let dir = TempStrDir::default(); + let output_dir = Path::from(dir.as_ref()); + let num_partitions = 5; + + // Only use partitions 0 and 3, leaving 1, 2, 4 empty + let batch = make_batch(&[0, 3, 0, 3], &[10, 20, 30, 40], None); + + let shuffler = TwoFileShuffler::new(output_dir, num_partitions); + let stream = batches_to_stream(vec![batch]); + let reader = shuffler.shuffle(stream).await.unwrap(); + + assert_eq!(reader.partition_size(0).unwrap(), 2); + assert_eq!(reader.partition_size(1).unwrap(), 0); + assert_eq!(reader.partition_size(2).unwrap(), 0); + assert_eq!(reader.partition_size(3).unwrap(), 2); + assert_eq!(reader.partition_size(4).unwrap(), 0); + + assert!(reader.read_partition(1).await.unwrap().is_none()); + assert!(reader.read_partition(2).await.unwrap().is_none()); + assert!(reader.read_partition(4).await.unwrap().is_none()); + + let p0 = collect_partition(reader.as_ref(), 0).await.unwrap(); + assert_eq!(p0.num_rows(), 2); + let p3 = collect_partition(reader.as_ref(), 3).await.unwrap(); + assert_eq!(p3.num_rows(), 2); + } + + #[tokio::test] + async fn test_two_file_shuffler_loss_tracking() { + let dir = TempStrDir::default(); + let output_dir = Path::from(dir.as_ref()); + let num_partitions = 2; + + let batch1 = make_batch(&[0, 1], &[10, 20], Some(1.5)); + let batch2 = make_batch(&[0, 1], &[30, 40], Some(2.5)); + let batch3 = make_batch(&[0], &[50], Some(0.25)); + + let shuffler = TwoFileShuffler::new(output_dir, num_partitions); + let stream = batches_to_stream(vec![batch1, batch2, batch3]); + let reader = shuffler.shuffle(stream).await.unwrap(); + + let loss = reader.total_loss().unwrap(); + assert!((loss - 4.25).abs() < 1e-10, "expected 4.25, got {}", loss); + } + + #[tokio::test] + async fn test_two_file_shuffler_single_batch() { + let dir = TempStrDir::default(); + let output_dir = Path::from(dir.as_ref()); + let num_partitions = 2; + + let batch = make_batch(&[1, 0], &[100, 200], Some(3.0)); + + let shuffler = TwoFileShuffler::new(output_dir, num_partitions); + let stream = batches_to_stream(vec![batch]); + let reader = shuffler.shuffle(stream).await.unwrap(); + + assert_eq!(reader.partition_size(0).unwrap(), 1); + assert_eq!(reader.partition_size(1).unwrap(), 1); + + let p0 = collect_partition(reader.as_ref(), 0).await.unwrap(); + let vals: &Int32Array = p0.column_by_name("val").unwrap().as_primitive(); + assert_eq!(vals.value(0), 200); + + let p1 = collect_partition(reader.as_ref(), 1).await.unwrap(); + let vals: &Int32Array = p1.column_by_name("val").unwrap().as_primitive(); + assert_eq!(vals.value(0), 100); + + assert!((reader.total_loss().unwrap() - 3.0).abs() < 1e-10); + } + + #[tokio::test] + async fn test_two_file_shuffler_multiple_batches() { + let dir = TempStrDir::default(); + let output_dir = Path::from(dir.as_ref()); + let num_partitions = 3; + + // Use a very small batch size to force multiple write batches + // Each i32 is 4 bytes, each u32 is 4 bytes, so ~8 bytes/row. + // With a small batch_size_bytes, we get multiple rechunked batches. + let batch1 = make_batch(&[0, 1, 2], &[10, 20, 30], Some(1.0)); + let batch2 = make_batch(&[2, 0, 1], &[40, 50, 60], Some(2.0)); + let batch3 = make_batch(&[1, 2, 0], &[70, 80, 90], Some(3.0)); + + let shuffler = TwoFileShuffler::new(output_dir, num_partitions) + // Set very small batch size to force multiple batches + .with_batch_size_bytes(16); + let stream = batches_to_stream(vec![batch1, batch2, batch3]); + let reader = shuffler.shuffle(stream).await.unwrap(); + + // Partition 0 should have values: 10, 50, 90 + assert_eq!(reader.partition_size(0).unwrap(), 3); + let p0 = collect_partition(reader.as_ref(), 0).await.unwrap(); + let vals: &Int32Array = p0.column_by_name("val").unwrap().as_primitive(); + let mut v: Vec = vals.iter().map(|x| x.unwrap()).collect(); + v.sort(); + assert_eq!(v, vec![10, 50, 90]); + + // Partition 1 should have values: 20, 60, 70 + assert_eq!(reader.partition_size(1).unwrap(), 3); + let p1 = collect_partition(reader.as_ref(), 1).await.unwrap(); + let vals: &Int32Array = p1.column_by_name("val").unwrap().as_primitive(); + let mut v: Vec = vals.iter().map(|x| x.unwrap()).collect(); + v.sort(); + assert_eq!(v, vec![20, 60, 70]); + + // Partition 2 should have values: 30, 40, 80 + assert_eq!(reader.partition_size(2).unwrap(), 3); + let p2 = collect_partition(reader.as_ref(), 2).await.unwrap(); + let vals: &Int32Array = p2.column_by_name("val").unwrap().as_primitive(); + let mut v: Vec = vals.iter().map(|x| x.unwrap()).collect(); + v.sort(); + assert_eq!(v, vec![30, 40, 80]); + + assert!((reader.total_loss().unwrap() - 6.0).abs() < 1e-10); + } +} diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index 32a67561c5c..3d4eb895f1e 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -35,7 +35,7 @@ use object_store::path::Path; use lance_arrow::FixedSizeListArrayExt; use lance_index::vector::pq::ProductQuantizer; use lance_index::vector::quantizer::QuantizationType; -use lance_index::vector::v3::shuffler::IvfShuffler; +use lance_index::vector::v3::shuffler::create_ivf_shuffler; use lance_index::vector::v3::subindex::SubIndexType; use lance_index::vector::{ VectorIndex, @@ -402,9 +402,12 @@ pub(crate) async fn build_distributed_vector_index( let temp_dir = TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; - let shuffler = IvfShuffler::new(temp_dir_path, num_partitions) - .with_format_version(format_version) - .with_progress(progress.clone()); + let shuffler = create_ivf_shuffler( + temp_dir_path, + num_partitions, + format_version, + Some(progress.clone()), + ); let filtered_dataset = dataset.clone(); @@ -463,7 +466,7 @@ pub(crate) async fn build_distributed_vector_index( column.to_owned(), index_dir, params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(()), (), @@ -484,7 +487,7 @@ pub(crate) async fn build_distributed_vector_index( column.to_owned(), index_dir, params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(()), (), @@ -529,7 +532,7 @@ pub(crate) async fn build_distributed_vector_index( column.to_owned(), index_dir, params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(pq_params.clone()), (), @@ -563,7 +566,7 @@ pub(crate) async fn build_distributed_vector_index( column.to_owned(), index_dir, params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(sq_params.clone()), (), @@ -590,7 +593,7 @@ pub(crate) async fn build_distributed_vector_index( column.to_owned(), index_dir, params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(()), hnsw_params.clone(), @@ -625,7 +628,7 @@ pub(crate) async fn build_distributed_vector_index( column.to_owned(), index_dir, params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(pq_params.clone()), hnsw_params.clone(), @@ -663,7 +666,7 @@ pub(crate) async fn build_distributed_vector_index( column.to_owned(), index_dir, params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(sq_params.clone()), hnsw_params.clone(), @@ -746,9 +749,12 @@ pub(crate) async fn build_vector_index( let temp_dir = TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; - let shuffler = IvfShuffler::new(temp_dir_path, num_partitions) - .with_format_version(format_version) - .with_progress(progress.clone()); + let shuffler = create_ivf_shuffler( + temp_dir_path, + num_partitions, + format_version, + Some(progress.clone()), + ); match index_type { IndexType::IvfFlat => match element_type { DataType::Float16 | DataType::Float32 | DataType::Float64 => { @@ -757,7 +763,7 @@ pub(crate) async fn build_vector_index( column.to_owned(), dataset.indices_dir().child(uuid), params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(()), (), @@ -773,7 +779,7 @@ pub(crate) async fn build_vector_index( column.to_owned(), dataset.indices_dir().child(uuid), params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(()), (), @@ -819,7 +825,7 @@ pub(crate) async fn build_vector_index( column.to_owned(), dataset.indices_dir().child(uuid), params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(pq_params.clone()), (), @@ -847,7 +853,7 @@ pub(crate) async fn build_vector_index( column.to_owned(), dataset.indices_dir().child(uuid), params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(sq_params.clone()), (), @@ -870,7 +876,7 @@ pub(crate) async fn build_vector_index( column.to_owned(), dataset.indices_dir().child(uuid), params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(rq_params.clone()), (), @@ -895,7 +901,7 @@ pub(crate) async fn build_vector_index( column.to_owned(), dataset.indices_dir().child(uuid), params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(()), hnsw_params.clone(), @@ -923,7 +929,7 @@ pub(crate) async fn build_vector_index( column.to_owned(), dataset.indices_dir().child(uuid), params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(pq_params.clone()), hnsw_params.clone(), @@ -951,7 +957,7 @@ pub(crate) async fn build_vector_index( column.to_owned(), dataset.indices_dir().child(uuid), params.metric_type, - Box::new(shuffler), + shuffler, Some(ivf_params), Some(sq_params.clone()), hnsw_params.clone(), @@ -1027,10 +1033,11 @@ pub(crate) async fn build_vector_index_incremental( let temp_dir = TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; - let shuffler = Box::new( - IvfShuffler::new(temp_dir_path, ivf_model.num_partitions()) - .with_format_version(format_version) - .with_progress(progress.clone()), + let shuffler = create_ivf_shuffler( + temp_dir_path, + ivf_model.num_partitions(), + format_version, + Some(progress.clone()), ); let index_dir = dataset.indices_dir().child(uuid); diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 23017c67a84..908564db4aa 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -60,7 +60,7 @@ use lance_index::vector::ivf::storage::{IVF_METADATA_KEY, IvfModel}; use lance_index::vector::kmeans::KMeansParams; use lance_index::vector::pq::storage::transpose; use lance_index::vector::quantizer::QuantizationType; -use lance_index::vector::v3::shuffler::IvfShuffler; +use lance_index::vector::v3::shuffler::create_ivf_shuffler; use lance_index::vector::v3::subindex::{IvfSubIndex, SubIndexType}; use lance_index::{ INDEX_AUXILIARY_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, Index, IndexMetadata, IndexType, @@ -386,9 +386,7 @@ pub(crate) async fn optimize_vector_indices_v2( let temp_dir = lance_core::utils::tempfile::TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; - let shuffler = Box::new( - IvfShuffler::new(temp_dir_path, num_partitions).with_format_version(format_version), - ); + let shuffler = create_ivf_shuffler(temp_dir_path, num_partitions, format_version, None); let (_, element_type) = get_vector_type(dataset.schema(), vector_column)?; let merged_num = match index_type { From 4110d04f6dcb457746955b8a7c53f78e22262188 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Tue, 17 Mar 2026 01:29:46 +0800 Subject: [PATCH 033/206] fix: preserve create index transaction semantics (#6204) This fixes the transaction semantics behind `CreateIndex` so we can precisely add and remove index UUIDs without relying on implicit name-based replacement. It also closes the rebase correctness hole where concurrent same-name `CreateIndex` commits could survive with stale `removed_indices` and leave overlapping entries in the manifest. This keeps the existing uniqueness and `replace=true` contract intact while unblocking follow-up multi-segment index work. This PR is part of https://github.com/lance-format/lance/issues/6180 --- rust/lance/src/dataset/transaction.rs | 126 ++++++++++++++-- rust/lance/src/index.rs | 7 - rust/lance/src/index/create.rs | 130 ++++++++++++++-- rust/lance/src/index/vector/ivf/v2.rs | 1 + rust/lance/src/io/commit.rs | 20 +-- rust/lance/src/io/commit/conflict_resolver.rs | 139 +++++++++++++++++- 6 files changed, 377 insertions(+), 46 deletions(-) diff --git a/rust/lance/src/dataset/transaction.rs b/rust/lance/src/dataset/transaction.rs index c94e4e39364..689f63c659b 100644 --- a/rust/lance/src/dataset/transaction.rs +++ b/rust/lance/src/dataset/transaction.rs @@ -1935,13 +1935,17 @@ impl Transaction { removed_indices, } => { final_fragments.extend(maybe_existing_fragments?.clone()); + let removed_uuids = removed_indices + .iter() + .map(|old_index| old_index.uuid) + .collect::>(); + let new_uuids = new_indices + .iter() + .map(|new_index| new_index.uuid) + .collect::>(); final_indices.retain(|existing_index| { - !new_indices - .iter() - .any(|new_index| new_index.name == existing_index.name) - && !removed_indices - .iter() - .any(|old_index| old_index.uuid == existing_index.uuid) + !removed_uuids.contains(&existing_index.uuid) + && !new_uuids.contains(&existing_index.uuid) }); final_indices.extend(new_indices.clone()); } @@ -3441,7 +3445,36 @@ fn merge_fragments_valid(manifest: &Manifest, new_fragments: &[Fragment]) -> Res #[cfg(test)] mod tests { use super::*; + use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; + use chrono::Utc; + use lance_core::datatypes::Schema as LanceSchema; use lance_io::utils::CachedFileSize; + use std::sync::Arc; + use uuid::Uuid; + + fn sample_manifest() -> Manifest { + let schema = ArrowSchema::new(vec![ArrowField::new("id", DataType::Int32, false)]); + Manifest::new( + LanceSchema::try_from(&schema).unwrap(), + Arc::new(vec![Fragment::new(0)]), + DataStorageFormat::new(LanceFileVersion::V2_0), + HashMap::new(), + ) + } + + fn sample_index_metadata(name: &str) -> IndexMetadata { + IndexMetadata { + uuid: Uuid::new_v4(), + fields: vec![0], + name: name.to_string(), + dataset_version: 0, + fragment_bitmap: Some([0].into_iter().collect()), + index_details: None, + index_version: 1, + created_at: Some(Utc::now()), + base_id: None, + } + } #[test] fn test_rewrite_fragments() { @@ -3496,11 +3529,6 @@ mod tests { #[test] fn test_merge_fragments_valid() { - use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; - use lance_core::datatypes::Schema as LanceSchema; - use lance_table::format::Manifest; - use std::sync::Arc; - // Create a simple schema for testing let schema = ArrowSchema::new(vec![ ArrowField::new("id", DataType::Int32, false), @@ -3577,6 +3605,82 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_create_index_build_manifest_keeps_unremoved_same_name_indices() { + let manifest = sample_manifest(); + let first_index = sample_index_metadata("vector_idx"); + let second_index = sample_index_metadata("vector_idx"); + let third_index = sample_index_metadata("vector_idx"); + + let transaction = Transaction::new( + manifest.version, + Operation::CreateIndex { + new_indices: vec![third_index.clone()], + removed_indices: vec![second_index.clone()], + }, + None, + ); + + let (_, final_indices) = transaction + .build_manifest( + Some(&manifest), + vec![first_index.clone(), second_index.clone()], + "txn", + &ManifestWriteConfig::default(), + ) + .unwrap(); + + assert_eq!(final_indices.len(), 2); + assert!(final_indices.iter().any(|idx| idx.uuid == first_index.uuid)); + assert!(final_indices.iter().any(|idx| idx.uuid == third_index.uuid)); + assert!( + !final_indices + .iter() + .any(|idx| idx.uuid == second_index.uuid) + ); + } + + #[test] + fn test_create_index_build_manifest_deduplicates_relisted_indices_by_uuid() { + let manifest = sample_manifest(); + let first_index = sample_index_metadata("vector_idx"); + let second_index = sample_index_metadata("vector_idx"); + let third_index = sample_index_metadata("vector_idx"); + + let transaction = Transaction::new( + manifest.version, + Operation::CreateIndex { + new_indices: vec![first_index.clone(), third_index.clone()], + removed_indices: vec![second_index.clone()], + }, + None, + ); + + let (_, final_indices) = transaction + .build_manifest( + Some(&manifest), + vec![first_index.clone(), second_index.clone()], + "txn", + &ManifestWriteConfig::default(), + ) + .unwrap(); + + assert_eq!(final_indices.len(), 2); + assert_eq!( + final_indices + .iter() + .filter(|idx| idx.uuid == first_index.uuid) + .count(), + 1 + ); + assert!(final_indices.iter().any(|idx| idx.uuid == third_index.uuid)); + assert!( + !final_indices + .iter() + .any(|idx| idx.uuid == second_index.uuid) + ); + } + #[test] fn test_remove_tombstoned_data_files() { // Create a fragment with mixed data files: some normal, some fully tombstoned diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index f4cd31e44b8..ab3876b7977 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -900,13 +900,6 @@ impl DatasetIndexExt for Dataset { base_id: None, // Mew merged index file locates in the cloned dataset. }; removed_indices.extend(res.removed_indices.iter().map(|&idx| idx.clone())); - if deltas.len() > res.removed_indices.len() { - new_indices.extend( - deltas[0..(deltas.len() - res.removed_indices.len())] - .iter() - .map(|&idx| idx.clone()), - ); - } new_indices.push(new_idx); } diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index be76b9a1878..d5c11c84694 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -171,19 +171,24 @@ impl<'a> CreateIndexBuilder<'a> { } candidate }; - if let Some(idx) = indices.iter().find(|i| i.name == index_name) { - if idx.fields == [field.id] && !self.replace { - return Err(Error::index(format!( - "Index name '{index_name} already exists, \ - please specify a different name or use replace=True" - ))); - }; - if idx.fields != [field.id] { - return Err(Error::index(format!( - "Index name '{index_name} already exists with different fields, \ - please specify a different name" - ))); - } + let existing_named_indices = indices + .iter() + .filter(|idx| idx.name == index_name) + .collect::>(); + if existing_named_indices + .iter() + .any(|idx| idx.fields != [field.id]) + { + return Err(Error::index(format!( + "Index name '{index_name}' already exists with different fields, \ + please specify a different name" + ))); + } + if !existing_named_indices.is_empty() && !self.replace { + return Err(Error::index(format!( + "Index name '{index_name}' already exists, \ + please specify a different name or use replace=True" + ))); } let index_id = match &self.index_uuid { @@ -435,11 +440,22 @@ impl<'a> CreateIndexBuilder<'a> { async fn execute(mut self) -> Result { let new_idx = self.execute_uncommitted().await?; let index_uuid = new_idx.uuid; + let removed_indices = if self.replace { + self.dataset + .load_indices() + .await? + .iter() + .filter(|idx| idx.name == new_idx.name) + .cloned() + .collect() + } else { + vec![] + }; let transaction = Transaction::new( new_idx.dataset_version, Operation::CreateIndex { new_indices: vec![new_idx], - removed_indices: vec![], + removed_indices, }, None, ); @@ -628,6 +644,92 @@ mod tests { assert!(err.to_string().contains("already exists")); } + #[tokio::test] + async fn test_concurrent_create_index_same_name_returns_retryable_conflict() { + let tmpdir = TempStrDir::default(); + let dataset_uri = format!("file://{}", tmpdir.as_str()); + let reader = gen_batch() + .col("a", lance_datagen::array::step::()) + .into_reader_rows( + lance_datagen::RowCount::from(100), + lance_datagen::BatchCount::from(1), + ); + let dataset = Dataset::write(reader, &dataset_uri, None).await.unwrap(); + + let params = ScalarIndexParams::for_builtin(lance_index::scalar::BuiltinIndexType::BTree); + let read_version = dataset.manifest.version; + let mut reader1 = dataset.checkout_version(read_version).await.unwrap(); + let mut reader2 = dataset.checkout_version(read_version).await.unwrap(); + + let first = CreateIndexBuilder::new(&mut reader1, &["a"], IndexType::BTree, ¶ms) + .name("a_idx".to_string()) + .execute() + .await; + assert!( + first.is_ok(), + "first create_index should succeed: {first:?}" + ); + + let second = CreateIndexBuilder::new(&mut reader2, &["a"], IndexType::BTree, ¶ms) + .name("a_idx".to_string()) + .execute() + .await; + assert!( + matches!(second, Err(Error::RetryableCommitConflict { .. })), + "second concurrent create_index should be retryable, got {second:?}" + ); + + let latest_indices = reader1.load_indices_by_name("a_idx").await.unwrap(); + assert_eq!(latest_indices.len(), 1); + } + + #[tokio::test] + async fn test_concurrent_replace_index_same_name_returns_retryable_conflict() { + let tmpdir = TempStrDir::default(); + let dataset_uri = format!("file://{}", tmpdir.as_str()); + let reader = gen_batch() + .col("a", lance_datagen::array::step::()) + .into_reader_rows( + lance_datagen::RowCount::from(100), + lance_datagen::BatchCount::from(1), + ); + let mut dataset = Dataset::write(reader, &dataset_uri, None).await.unwrap(); + + let params = ScalarIndexParams::for_builtin(lance_index::scalar::BuiltinIndexType::BTree); + let original = CreateIndexBuilder::new(&mut dataset, &["a"], IndexType::BTree, ¶ms) + .name("a_idx".to_string()) + .execute() + .await + .unwrap(); + + let read_version = dataset.manifest.version; + let mut reader1 = dataset.checkout_version(read_version).await.unwrap(); + let mut reader2 = dataset.checkout_version(read_version).await.unwrap(); + + let replacement = CreateIndexBuilder::new(&mut reader1, &["a"], IndexType::BTree, ¶ms) + .name("a_idx".to_string()) + .replace(true) + .execute() + .await + .unwrap(); + assert_ne!(replacement.uuid, original.uuid); + + let second = CreateIndexBuilder::new(&mut reader2, &["a"], IndexType::BTree, ¶ms) + .name("a_idx".to_string()) + .replace(true) + .execute() + .await; + assert!( + matches!(second, Err(Error::RetryableCommitConflict { .. })), + "second concurrent replace should be retryable, got {second:?}" + ); + + let latest_indices = reader1.load_indices_by_name("a_idx").await.unwrap(); + assert_eq!(latest_indices.len(), 1); + assert_eq!(latest_indices[0].uuid, replacement.uuid); + assert_ne!(latest_indices[0].uuid, original.uuid); + } + // Helper function to create test data with text field suitable for inverted index fn create_text_batch(start: i32, end: i32) -> RecordBatch { let schema = Arc::new(ArrowSchema::new(vec![ diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 96be37e18ed..34e33e42e52 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -2367,6 +2367,7 @@ mod tests { Some((dataset.clone(), vectors.clone())), ) .await; + dataset.checkout_latest().await.unwrap(); // retest with v3 params on the same dataset test_index( v3_params, diff --git a/rust/lance/src/io/commit.rs b/rust/lance/src/io/commit.rs index ebab7eb6b4d..7dbc472e6b4 100644 --- a/rust/lance/src/io/commit.rs +++ b/rust/lance/src/io/commit.rs @@ -1216,9 +1216,16 @@ mod tests { .collect(); let results = join_all(futures).await; - for result in results { - assert!(matches!(result, Ok(Ok(_))), "{:?}", result); - } + let success_count = results + .iter() + .filter(|result| matches!(result, Ok(Ok(_)))) + .count(); + let retryable_count = results + .iter() + .filter(|result| matches!(result, Ok(Err(Error::RetryableCommitConflict { .. })))) + .count(); + assert_eq!(success_count, 2, "{results:?}"); + assert_eq!(retryable_count, 1, "{results:?}"); // Validate that each version has the anticipated number of indexes let dataset = dataset.checkout_version(1).await.unwrap(); @@ -1241,12 +1248,7 @@ mod tests { assert_eq!(indices[0].fields, vec![0]); } - let dataset = dataset.checkout_version(4).await.unwrap(); - let indices = dataset.load_indices().await.unwrap(); - assert_eq!(indices.len(), 2); - let mut fields: Vec = indices.iter().flat_map(|i| i.fields.clone()).collect(); - fields.sort(); - assert_eq!(fields, vec![0, 1]); + assert!(dataset.checkout_version(4).await.is_err()); } #[tokio::test] diff --git a/rust/lance/src/io/commit/conflict_resolver.rs b/rust/lance/src/io/commit/conflict_resolver.rs index 450bd03a964..da044bac43d 100644 --- a/rust/lance/src/io/commit/conflict_resolver.rs +++ b/rust/lance/src/io/commit/conflict_resolver.rs @@ -14,6 +14,7 @@ use lance_core::{ Error, Result, utils::{deletion::DeletionVector, mask::RowAddrTreeMap}, }; +use lance_index::DatasetIndexExt; use lance_index::frag_reuse::FRAG_REUSE_INDEX_NAME; use lance_index::mem_wal::{MEM_WAL_INDEX_NAME, MergedGeneration}; use lance_table::format::IndexMetadata; @@ -501,8 +502,6 @@ impl<'a> TransactionRebase<'a> { Operation::Append { .. } | Operation::Clone { .. } | Operation::UpdateBases { .. } => Ok(()), - // Indices are identified by UUIDs, so they shouldn't conflict. - // unless it is the same frag reuse index or MemWAL index Operation::CreateIndex { new_indices: created_indices, .. @@ -518,9 +517,20 @@ impl<'a> TransactionRebase<'a> { let other_has_mem_wal = created_indices .iter() .any(|idx| idx.name == MEM_WAL_INDEX_NAME); + let has_regular_name_conflict = new_indices + .iter() + .filter(|idx| { + idx.name != FRAG_REUSE_INDEX_NAME && idx.name != MEM_WAL_INDEX_NAME + }) + .any(|new_index| { + created_indices + .iter() + .any(|created_index| created_index.name == new_index.name) + }); if (self_has_frag_reuse && other_has_frag_reuse) || (self_has_mem_wal && other_has_mem_wal) + || has_regular_name_conflict { Err(self.retryable_conflict_err(other_transaction, other_version)) } else { @@ -1440,7 +1450,11 @@ impl<'a> TransactionRebase<'a> { } async fn finish_create_index(mut self, dataset: &Dataset) -> Result { - if let Operation::CreateIndex { new_indices, .. } = &mut self.transaction.operation { + if let Operation::CreateIndex { + new_indices, + removed_indices, + } = &mut self.transaction.operation + { // Handle FRAG_REUSE_INDEX rebasing let has_frag_reuse = new_indices .iter() @@ -1522,6 +1536,25 @@ impl<'a> TransactionRebase<'a> { new_indices.push(new_meta); } + for singleton_name in [FRAG_REUSE_INDEX_NAME, MEM_WAL_INDEX_NAME] { + if new_indices.iter().any(|idx| idx.name == singleton_name) { + for existing_idx in dataset + .load_indices() + .await? + .iter() + .filter(|idx| idx.name == singleton_name) + .cloned() + { + if !removed_indices + .iter() + .any(|removed_idx| removed_idx.uuid == existing_idx.uuid) + { + removed_indices.push(existing_idx); + } + } + } + } + Ok(self.transaction) } else { Err(wrong_operation_err(&self.transaction.operation)) @@ -2296,10 +2329,10 @@ mod tests { new_indices: vec![index0.clone()], removed_indices: vec![index0], }, - // Will only conflict with operations that modify row ids. + // Conflicts with row-id-changing operations and same-name CreateIndex. [ Compatible, // append - Compatible, // create index + Retryable, // create index Compatible, // delete Compatible, // merge NotCompatible, // overwrite @@ -2629,6 +2662,102 @@ mod tests { } } + #[test] + fn test_create_index_conflicts_only_on_same_name() { + let index0 = IndexMetadata { + uuid: uuid::Uuid::new_v4(), + name: "test".to_string(), + fields: vec![0], + dataset_version: 1, + fragment_bitmap: None, + index_details: None, + index_version: 0, + created_at: None, + base_id: None, + }; + let index1 = IndexMetadata { + uuid: uuid::Uuid::new_v4(), + name: "other".to_string(), + ..index0.clone() + }; + + let txn = Transaction::new( + 0, + Operation::CreateIndex { + new_indices: vec![index0.clone()], + removed_indices: vec![], + }, + None, + ); + let mut rebase = TransactionRebase { + transaction: txn, + initial_fragments: HashMap::new(), + modified_fragment_ids: HashSet::new(), + affected_rows: None, + conflicting_frag_reuse_indices: Vec::new(), + conflicting_mem_wal_merged_gens: Vec::new(), + }; + + let same_name = Transaction::new( + 0, + Operation::CreateIndex { + new_indices: vec![IndexMetadata { + uuid: uuid::Uuid::new_v4(), + ..index0 + }], + removed_indices: vec![], + }, + None, + ); + let different_name = Transaction::new( + 0, + Operation::CreateIndex { + new_indices: vec![index1], + removed_indices: vec![], + }, + None, + ); + + let same_name_result = rebase.check_txn(&same_name, 1); + assert!( + matches!(same_name_result, Err(Error::RetryableCommitConflict { .. })), + "Expected retryable conflict for same-name CreateIndex, got {:?}", + same_name_result + ); + + let mut rebase = TransactionRebase { + transaction: Transaction::new( + 0, + Operation::CreateIndex { + new_indices: vec![IndexMetadata { + uuid: uuid::Uuid::new_v4(), + name: "test".to_string(), + fields: vec![0], + dataset_version: 1, + fragment_bitmap: None, + index_details: None, + index_version: 0, + created_at: None, + base_id: None, + }], + removed_indices: vec![], + }, + None, + ), + initial_fragments: HashMap::new(), + modified_fragment_ids: HashSet::new(), + affected_rows: None, + conflicting_frag_reuse_indices: Vec::new(), + conflicting_mem_wal_merged_gens: Vec::new(), + }; + let different_name_result = rebase.check_txn(&different_name, 1); + assert!( + different_name_result.is_ok(), + "Expected compatibility for different-name CreateIndex, got {:?}", + different_name_result + ); + } + #[tokio::test] async fn test_add_bases_non_conflicting() { let dataset = test_dataset(10, 2).await; From 50c778e26bb7e916c3077277410a0bfb22948780 Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Mon, 16 Mar 2026 12:34:33 -0500 Subject: [PATCH 034/206] fix: allow same field name with different type in dataset overwrites (#6206) [This PR](https://github.com/lance-format/lance/pull/5824) updated the dataset overwrite logic which caused a small regression that when overwriting a dataset we will error if there are identical field names with different types. Since this is an `OVERWRITE` operation, that should not cause a failure. Signed-off-by: Daniel Rammer --- java/lance-jni/src/transaction.rs | 5 +- .../org/lance/operation/OverwriteTest.java | 70 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/java/lance-jni/src/transaction.rs b/java/lance-jni/src/transaction.rs index 68d5425a389..936834b588d 100644 --- a/java/lance-jni/src/transaction.rs +++ b/java/lance-jni/src/transaction.rs @@ -1107,6 +1107,9 @@ fn convert_to_rust_operation( to_rust_map(env, &config_upsert_values) }, )?; + // Pass None for dataset so that the new schema is not validated + // against the old schema. Overwrite replaces the entire dataset, + // so fields with the same name but different types are allowed. let schema = convert_schema_from_operation( env, java_operation, @@ -1115,7 +1118,7 @@ fn convert_to_rust_operation( "BufferAllocator is required for Overwrite operations".to_string(), ) })?, - dataset, + None, read_version, )?; Operation::Overwrite { diff --git a/java/src/test/java/org/lance/operation/OverwriteTest.java b/java/src/test/java/org/lance/operation/OverwriteTest.java index 3af691c5009..c1def711edc 100644 --- a/java/src/test/java/org/lance/operation/OverwriteTest.java +++ b/java/src/test/java/org/lance/operation/OverwriteTest.java @@ -19,15 +19,22 @@ import org.lance.FragmentMetadata; import org.lance.TestUtils; import org.lance.Transaction; +import org.lance.WriteParams; import org.lance.ipc.LanceScanner; import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.BigIntVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; import org.apache.arrow.vector.types.pojo.Schema; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; +import java.util.Arrays; import java.util.Collections; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -121,4 +128,67 @@ void testOverwrite(@TempDir Path tempDir) throws Exception { } } } + + @Test + void testOverwriteWithDifferentFieldTypes(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("testOverwriteFieldTypes").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + // Create initial dataset with schema: id (int32), name (utf8) + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + FragmentMetadata fragmentMeta = testDataset.createNewFragment(10); + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + Overwrite.builder() + .fragments(Collections.singletonList(fragmentMeta)) + .schema(testDataset.getSchema()) + .build()) + .build()) { + dataset = new CommitBuilder(this.dataset).execute(txn); + } + assertEquals(2, dataset.version()); + assertEquals(10, dataset.countRows()); + + // Overwrite with a new schema where "id" changes from int32 to int64 + // and "name" changes from utf8 to int64 + Schema newSchema = + new Schema( + Arrays.asList( + Field.nullable("id", new ArrowType.Int(64, true)), + Field.nullable("name", new ArrowType.Int(64, true)))); + + int newRowCount = 5; + List newFragments; + try (VectorSchemaRoot root = VectorSchemaRoot.create(newSchema, allocator)) { + root.allocateNew(); + BigIntVector idVector = (BigIntVector) root.getVector("id"); + BigIntVector nameVector = (BigIntVector) root.getVector("name"); + for (int i = 0; i < newRowCount; i++) { + idVector.setSafe(i, (long) i * 100); + nameVector.setSafe(i, (long) i * 200); + } + root.setRowCount(newRowCount); + newFragments = + Fragment.create(datasetPath, allocator, root, new WriteParams.Builder().build()); + } + + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Overwrite.builder().fragments(newFragments).schema(newSchema).build()) + .build()) { + try (Dataset overwritten = new CommitBuilder(this.dataset).execute(txn)) { + assertEquals(3, overwritten.version()); + assertEquals(newRowCount, overwritten.countRows()); + + // Verify the schema has the new types + Schema resultSchema = overwritten.getSchema(); + assertEquals(newSchema, resultSchema); + } + } + } + } } From 7790f2cc2d4ff9593246858d80df061c3c7142df Mon Sep 17 00:00:00 2001 From: Prashanth Rao <35005448+prrao87@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:42:19 -0400 Subject: [PATCH 035/206] docs: add example to show how to index JSON column (#6208) Users have asked if it's possible to create an index on a JSON column in Lance. The update shows how to create a scalar and and FTS index on a JSON column, per the code in the test suite. --- docs/src/guide/json.md | 71 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/src/guide/json.md b/docs/src/guide/json.md index 667b2596a37..7246c8fe08a 100644 --- a/docs/src/guide/json.md +++ b/docs/src/guide/json.md @@ -235,6 +235,75 @@ result = dataset.to_table( ) ``` +## JSON Indexing + +Lance supports indexing JSON columns to accelerate filters on frequently queried paths. + +### Scalar Index on a JSON Path + +For `pa.json_()` columns, create a scalar index with `IndexConfig` and specify the JSON +path to index. The query should use the same path literal that was indexed. + +```python +import json +import lance +import pyarrow as pa +from lance.indices import IndexConfig + +table = pa.table({ + "id": [1, 2, 3, 4], + "data": pa.array([ + json.dumps({"x": 7, "y": 10}), + json.dumps({"x": 11, "y": 22}), + json.dumps({"y": 0}), + json.dumps({"x": 10}), + ], type=pa.json_()), +}) + +lance.write_dataset(table, "json-index.lance") +dataset = lance.dataset("json-index.lance") + +dataset.create_scalar_index( + "data", + IndexConfig( + index_type="json", + parameters={ + "target_index_type": "btree", + "path": "x", + }, + ), +) + +result = dataset.to_table(filter="json_get_int(data, 'x') = 10") +``` + +!!! note + The JSON index matches queries by path literal. For example, if the index is built + with `path="x"`, then the filter should also use `"x"` with a function such as + `json_get_int(data, 'x')`. If the index is built with `path="$.user.name"`, then + the filter should use `json_extract(data, '$.user.name')`. + +### Full-Text Search on JSON Documents + +If you want text search over the contents of a JSON document instead of scalar filtering +on a single path, create an `INVERTED` index on the JSON column. + +```python +dataset.create_scalar_index( + "data", + index_type="INVERTED", + base_tokenizer="simple", + lower_case=True, + stem=True, + remove_stop_words=True, +) +``` + +!!! note + JSON columns and nested struct columns are indexed differently. For nested struct + fields, use dot notation such as `meta.lang`. For `pa.json_()` columns, use the JSON + index shown above and query with `json_get_*` or `json_extract`. + ## Usage Examples ### Working with Nested JSON @@ -348,7 +417,7 @@ complex_projects = dataset.to_table( ## Performance Considerations 1. **Choose the right function**: Use `json_get_*` functions for direct field access and type conversion; use `json_extract` for complex JSONPath queries. -2. **Index frequently queried paths**: Consider creating computed columns for frequently accessed JSON paths to improve query performance. +2. **Index frequently queried paths**: Use a JSON scalar index on frequently filtered paths before creating computed columns for the same fields. 3. **Minimize deep nesting**: While Lance supports arbitrary nesting, flatter structures generally perform better. 4. **Understand type conversion**: The `json_get_*` functions use strict type conversion, which may fail if types don't match. Plan your schema accordingly. 5. **Array access**: When working with JSON arrays, you can access elements by index using numeric strings (e.g., "0", "1") with `json_get` functions. From 147b95714edecfda219f6404067cf3a8ddd4a4a1 Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Tue, 17 Mar 2026 15:21:36 +0800 Subject: [PATCH 036/206] perf: reuse distance calculator at selecting candidates (#6202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` HNSW_FLAT create: 41.102s -> 6.401s,-84.4% HNSW_SQ create: 6.9698s -> 5.3778s,-22.8% HNSW_PQ create: 41.167s -> 40.098s,-2.6% ``` --------- Signed-off-by: BubbleCal --- rust/lance-index/benches/hnsw.rs | 112 +++++- rust/lance-index/src/vector/graph.rs | 373 +++++++++++++++----- rust/lance-index/src/vector/hnsw.rs | 8 +- rust/lance-index/src/vector/hnsw/builder.rs | 184 +++++++--- rust/lance-index/src/vector/pq/storage.rs | 7 + rust/lance-index/src/vector/storage.rs | 9 + 6 files changed, 550 insertions(+), 143 deletions(-) diff --git a/rust/lance-index/benches/hnsw.rs b/rust/lance-index/benches/hnsw.rs index a2731d71eb8..f1e22c5acc8 100644 --- a/rust/lance-index/benches/hnsw.rs +++ b/rust/lance-index/benches/hnsw.rs @@ -14,11 +14,13 @@ use lance_arrow::FixedSizeListArrayExt; use lance_index::vector::v3::subindex::IvfSubIndex; #[cfg(target_os = "linux")] use pprof::criterion::{Output, PProfProfiler}; +use rayon::ThreadPoolBuilder; use lance_core::ROW_ID_FIELD; use lance_index::vector::{ flat::storage::FlatFloatStorage, hnsw::builder::{HNSW, HnswBuildParams, HnswQueryParams}, + pq::{PQBuildParams, ProductQuantizer}, quantizer::Quantization, sq::{ScalarQuantizer, builder::SQBuildParams}, storage::StorageBuilder, @@ -64,7 +66,10 @@ fn bench_hnsw(c: &mut Criterion) { }) }); - let hnsw = HNSW::index_vectors(vectors.as_ref(), HnswBuildParams::default()).unwrap(); + let search_build_pool = ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let hnsw = search_build_pool + .install(|| HNSW::index_vectors(vectors.as_ref(), HnswBuildParams::default())) + .unwrap(); c.bench_function(format!("search_hnsw{TOTAL}x{DIMENSION}").as_str(), |b| { b.to_async(&rt).iter(|| async { let uids: HashSet = hnsw @@ -154,7 +159,10 @@ fn bench_hnsw_sq(c: &mut Criterion) { }, ); - let hnsw = HNSW::index_vectors(vectors.as_ref(), HnswBuildParams::default()).unwrap(); + let search_build_pool = ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let hnsw = search_build_pool + .install(|| HNSW::index_vectors(vectors.as_ref(), HnswBuildParams::default())) + .unwrap(); c.bench_function(format!("search_hnsw_sq{TOTAL}x{DIMENSION}").as_str(), |b| { b.to_async(&rt).iter(|| async { let uids: HashSet = hnsw @@ -180,6 +188,102 @@ fn bench_hnsw_sq(c: &mut Criterion) { }); } +fn bench_hnsw_pq(c: &mut Criterion) { + const DIMENSION: usize = 128; + const TOTAL: usize = 100_000; + const SEED: [u8; 32] = [42; 32]; + const K: usize = 100; + + let rt = tokio::runtime::Runtime::new().unwrap(); + + let data = generate_random_array_with_seed::(TOTAL * DIMENSION, SEED); + let fsl = FixedSizeListArray::try_new_from_values(data, DIMENSION as i32).unwrap(); + let quantizer = ::build( + &fsl, + DistanceType::L2, + &PQBuildParams::new(16, 8), + ) + .unwrap(); + + let schema = Arc::new(Schema::new(vec![ + Field::new( + "vector", + DataType::FixedSizeList( + Field::new_list_field(DataType::Float32, true).into(), + DIMENSION as i32, + ), + true, + ), + ROW_ID_FIELD.clone(), + ])); + let row_ids = UInt64Array::from_iter_values((0..TOTAL).map(|v| v as u64)); + let batch = + RecordBatch::try_new(schema, vec![Arc::new(fsl.clone()), Arc::new(row_ids)]).unwrap(); + let pq_storage = StorageBuilder::new("vector".to_owned(), DistanceType::L2, quantizer, None) + .unwrap() + .build(vec![batch]) + .unwrap(); + let vectors = Arc::new(pq_storage); + + let query = fsl.value(0); + c.bench_function( + format!("create_hnsw_pq({TOTAL}x{DIMENSION})").as_str(), + |b| { + b.to_async(&rt).iter(|| async { + let hnsw = + HNSW::index_vectors(vectors.as_ref(), HnswBuildParams::default()).unwrap(); + let uids: HashSet = hnsw + .search_basic( + query.clone(), + K, + &HnswQueryParams { + ef: 300, + lower_bound: None, + upper_bound: None, + dist_q_c: 0.0, + }, + None, + vectors.as_ref(), + ) + .unwrap() + .iter() + .map(|node| node.id) + .collect(); + + assert_eq!(uids.len(), K); + }) + }, + ); + + let search_build_pool = ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let hnsw = search_build_pool + .install(|| HNSW::index_vectors(vectors.as_ref(), HnswBuildParams::default())) + .unwrap(); + c.bench_function(format!("search_hnsw_pq{TOTAL}x{DIMENSION}").as_str(), |b| { + b.to_async(&rt).iter(|| async { + let uids: HashSet = hnsw + .search_basic( + query.clone(), + K, + &HnswQueryParams { + ef: 300, + lower_bound: None, + upper_bound: None, + dist_q_c: 0.0, + }, + None, + vectors.as_ref(), + ) + .unwrap() + .iter() + .map(|node| node.id) + .collect(); + + assert_eq!(uids.len(), K); + }) + }); +} + #[cfg(target_os = "linux")] criterion_group!( name=benches; @@ -187,7 +291,7 @@ criterion_group!( .measurement_time(Duration::from_secs(10)) .sample_size(10) .with_profiler(PProfProfiler::new(100, Output::Flamegraph(None))); - targets = bench_hnsw, bench_hnsw_sq); + targets = bench_hnsw, bench_hnsw_sq, bench_hnsw_pq); // Non-linux version does not support pprof. #[cfg(not(target_os = "linux"))] @@ -196,6 +300,6 @@ criterion_group!( config = Criterion::default() .measurement_time(Duration::from_secs(10)) .sample_size(10); - targets = bench_hnsw, bench_hnsw_sq); + targets = bench_hnsw, bench_hnsw_sq, bench_hnsw_pq); criterion_main!(benches); diff --git a/rust/lance-index/src/vector/graph.rs b/rust/lance-index/src/vector/graph.rs index 2dac8a23f7e..b79ce4e9947 100644 --- a/rust/lance-index/src/vector/graph.rs +++ b/rust/lance-index/src/vector/graph.rs @@ -9,7 +9,6 @@ use std::collections::BinaryHeap; use std::sync::Arc; use arrow_schema::{DataType, Field}; -use bitvec::vec::BitVec; use deepsize::DeepSizeOf; use crate::vector::hnsw::builder::HnswQueryParams; @@ -161,40 +160,64 @@ pub trait Graph { fn neighbors(&self, key: u32) -> Arc>; } -/// Array-based visited list (faster than HashSet) +pub trait BorrowingGraph { + /// Get the number of nodes in the graph. + fn len(&self) -> usize; + + /// Returns true if the graph is empty. + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Borrow the neighbors of a graph node, identified by the index. + fn neighbors(&self, key: u32) -> &[u32]; +} + +const WORD_BITS: usize = usize::BITS as usize; + +/// Compact visited list for graph traversals. pub struct Visited<'a> { - visited: &'a mut BitVec, - recently_visited: Vec, + visited: &'a mut Vec, + recently_visited: &'a mut Vec, } impl Visited<'_> { pub fn insert(&mut self, node_id: u32) { let node_id_usize = node_id as usize; - if !self.visited[node_id_usize] { - self.visited.set(node_id_usize, true); + let word_index = node_id_usize / WORD_BITS; + let mask = 1usize << (node_id_usize % WORD_BITS); + if self.visited[word_index] & mask == 0 { + self.visited[word_index] |= mask; self.recently_visited.push(node_id); } } pub fn contains(&self, node_id: u32) -> bool { let node_id_usize = node_id as usize; - self.visited[node_id_usize] + let word_index = node_id_usize / WORD_BITS; + let mask = 1usize << (node_id_usize % WORD_BITS); + self.visited[word_index] & mask != 0 } #[inline(always)] pub fn iter_ones(&self) -> impl Iterator + '_ { - self.visited.iter_ones() + self.recently_visited + .iter() + .map(|node_id| *node_id as usize) } pub fn count_ones(&self) -> usize { - self.visited.count_ones() + self.recently_visited.len() } } impl Drop for Visited<'_> { fn drop(&mut self) { - for node_id in self.recently_visited.iter() { - self.visited.set(*node_id as usize, false); + for node_id in self.recently_visited.iter().copied() { + let node_id_usize = node_id as usize; + let word_index = node_id_usize / WORD_BITS; + let mask = 1usize << (node_id_usize % WORD_BITS); + self.visited[word_index] &= !mask; } self.recently_visited.clear(); } @@ -202,14 +225,16 @@ impl Drop for Visited<'_> { #[derive(Debug, Clone)] pub struct VisitedGenerator { - visited: BitVec, + visited: Vec, + recently_visited: Vec, capacity: usize, } impl VisitedGenerator { pub fn new(capacity: usize) -> Self { Self { - visited: BitVec::repeat(false, capacity), + visited: vec![0; capacity.div_ceil(WORD_BITS)], + recently_visited: Vec::new(), capacity, } } @@ -217,12 +242,12 @@ impl VisitedGenerator { pub fn generate(&mut self, node_count: usize) -> Visited<'_> { if node_count > self.capacity { let new_capacity = self.capacity.max(node_count).next_power_of_two(); - self.visited.resize(new_capacity, false); + self.visited.resize(new_capacity.div_ceil(WORD_BITS), 0); self.capacity = new_capacity; } Visited { visited: &mut self.visited, - recently_visited: Vec::new(), + recently_visited: &mut self.recently_visited, } } } @@ -253,6 +278,89 @@ fn process_neighbors_with_look_ahead( } } +#[inline] +fn furthest_distance(results: &BinaryHeap) -> OrderedFloat { + results + .peek() + .map(|node| node.dist) + .unwrap_or(OrderedFloat(f32::INFINITY)) +} + +#[inline] +fn push_result(results: &mut BinaryHeap, candidate: OrderedNode, k: usize) { + if results.len() < k { + results.push(candidate); + } else if candidate.dist < results.peek().unwrap().dist { + results.pop(); + results.push(candidate); + } +} + +macro_rules! beam_search_loop { + ( + $candidates:ident, + $results:ident, + $visited:ident, + $k:expr, + $dist_calc:expr, + $prefetch_distance:expr, + $accepts_result:expr, + |$current:ident, $process_neighbor:ident| $visit_neighbors:block + ) => {{ + while !$candidates.is_empty() { + let $current = $candidates.pop().expect("candidates is empty").0; + let furthest = furthest_distance(&$results); + + if $current.dist > furthest && $results.len() == $k { + break; + } + + let $process_neighbor = |neighbor: u32| { + if $visited.contains(neighbor) { + return; + } + $visited.insert(neighbor); + let dist: OrderedFloat = $dist_calc.distance(neighbor).into(); + if dist <= furthest || $results.len() < $k { + if $accepts_result(neighbor, dist) { + push_result(&mut $results, (dist, neighbor).into(), $k); + } + $candidates.push(Reverse((dist, neighbor).into())); + } + }; + $visit_neighbors + } + }}; +} + +macro_rules! greedy_search_loop { + ( + $current:ident, + $closest_dist:ident, + $dist_calc:expr, + $prefetch_distance:expr, + |$process_neighbor:ident| $visit_neighbors:block + ) => {{ + loop { + let mut next = None; + let $process_neighbor = |neighbor: u32| { + let dist = $dist_calc.distance(neighbor); + if dist < $closest_dist { + $closest_dist = dist; + next = Some(neighbor); + } + }; + $visit_neighbors + + if let Some(next) = next { + $current = next; + } else { + break; + } + } + }}; +} + /// Beam search over a graph /// /// This is the same as ``search-layer`` in HNSW. @@ -291,12 +399,38 @@ pub fn beam_search( visited.insert(ep.id); candidates.push(Reverse(ep.clone())); + let mut results = BinaryHeap::with_capacity(k); + let no_filter = + bitset.is_none() && params.lower_bound.is_none() && params.upper_bound.is_none(); + + if no_filter { + results.push(ep.clone()); + let accepts_result = |_: u32, _: OrderedFloat| true; + beam_search_loop!( + candidates, + results, + visited, + k, + dist_calc, + prefetch_distance, + accepts_result, + |current, process_neighbor| { + let neighbors = graph.neighbors(current.id); + process_neighbors_with_look_ahead( + &neighbors, + process_neighbor, + prefetch_distance, + dist_calc, + ); + } + ); + return results.into_sorted_vec(); + } + // add range search support let lower_bound: OrderedFloat = params.lower_bound.unwrap_or(f32::MIN).into(); let upper_bound: OrderedFloat = params.upper_bound.unwrap_or(f32::MAX).into(); - let mut results = BinaryHeap::with_capacity(k); - if bitset.map(|bitset| bitset.contains(ep.id)).unwrap_or(true) && ep.dist >= lower_bound && ep.dist < upper_bound @@ -304,54 +438,111 @@ pub fn beam_search( results.push(ep.clone()); } - while !candidates.is_empty() { - let current = candidates.pop().expect("candidates is empty").0; - let furthest = results - .peek() - .map(|node| node.dist) - .unwrap_or(OrderedFloat(f32::INFINITY)); - - // TODO: add an option to ignore the second condition for better performance. - if current.dist > furthest && results.len() == k { - break; + let accepts_result = |node_id: u32, dist: OrderedFloat| { + bitset + .map(|bitset| bitset.contains(node_id)) + .unwrap_or(true) + && dist >= lower_bound + && dist < upper_bound + }; + beam_search_loop!( + candidates, + results, + visited, + k, + dist_calc, + prefetch_distance, + accepts_result, + |current, process_neighbor| { + let neighbors = graph.neighbors(current.id); + process_neighbors_with_look_ahead( + &neighbors, + process_neighbor, + prefetch_distance, + dist_calc, + ); } - let furthest = results - .peek() - .map(|node| node.dist) - .unwrap_or(OrderedFloat(f32::INFINITY)); - - let process_neighbor = |neighbor: u32| { - if visited.contains(neighbor) { - return; - } - visited.insert(neighbor); - let dist: OrderedFloat = dist_calc.distance(neighbor).into(); - if dist <= furthest || results.len() < k { - if bitset - .map(|bitset| bitset.contains(neighbor)) - .unwrap_or(true) - && dist >= lower_bound - && dist < upper_bound - { - if results.len() < k { - results.push((dist, neighbor).into()); - } else if results.len() == k && dist < results.peek().unwrap().dist { - results.pop(); - results.push((dist, neighbor).into()); - } - } - candidates.push(Reverse((dist, neighbor).into())); - } - }; - let neighbors = graph.neighbors(current.id); - process_neighbors_with_look_ahead( - &neighbors, - process_neighbor, - prefetch_distance, + ); + results.into_sorted_vec() +} + +pub fn beam_search_borrowed( + graph: &impl BorrowingGraph, + ep: &OrderedNode, + params: &HnswQueryParams, + dist_calc: &impl DistCalculator, + bitset: Option<&Visited>, + prefetch_distance: Option, + visited: &mut Visited, +) -> Vec { + let k = params.ef; + let mut candidates = BinaryHeap::with_capacity(k); + visited.insert(ep.id); + candidates.push(Reverse(ep.clone())); + + let mut results = BinaryHeap::with_capacity(k); + let no_filter = + bitset.is_none() && params.lower_bound.is_none() && params.upper_bound.is_none(); + + if no_filter { + results.push(ep.clone()); + let accepts_result = |_: u32, _: OrderedFloat| true; + beam_search_loop!( + candidates, + results, + visited, + k, dist_calc, + prefetch_distance, + accepts_result, + |current, process_neighbor| { + let neighbors = graph.neighbors(current.id); + process_neighbors_with_look_ahead( + neighbors, + process_neighbor, + prefetch_distance, + dist_calc, + ); + } ); + return results.into_sorted_vec(); + } + + let lower_bound: OrderedFloat = params.lower_bound.unwrap_or(f32::MIN).into(); + let upper_bound: OrderedFloat = params.upper_bound.unwrap_or(f32::MAX).into(); + + if bitset.map(|bitset| bitset.contains(ep.id)).unwrap_or(true) + && ep.dist >= lower_bound + && ep.dist < upper_bound + { + results.push(ep.clone()); } + let accepts_result = |node_id: u32, dist: OrderedFloat| { + bitset + .map(|bitset| bitset.contains(node_id)) + .unwrap_or(true) + && dist >= lower_bound + && dist < upper_bound + }; + beam_search_loop!( + candidates, + results, + visited, + k, + dist_calc, + prefetch_distance, + accepts_result, + |current, process_neighbor| { + let neighbors = graph.neighbors(current.id); + process_neighbors_with_look_ahead( + neighbors, + process_neighbor, + prefetch_distance, + dist_calc, + ); + } + ); results.into_sorted_vec() } @@ -381,31 +572,47 @@ pub fn greedy_search( ) -> OrderedNode { let mut current = start.id; let mut closest_dist = start.dist.0; - loop { - let neighbors = graph.neighbors(current); - let mut next = None; - - let process_neighbor = |neighbor: u32| { - let dist = dist_calc.distance(neighbor); - if dist < closest_dist { - closest_dist = dist; - next = Some(neighbor); - } - }; - process_neighbors_with_look_ahead( - &neighbors, - process_neighbor, - prefetch_distance, - dist_calc, - ); - - if let Some(next) = next { - current = next; - } else { - break; + greedy_search_loop!( + current, + closest_dist, + dist_calc, + prefetch_distance, + |process_neighbor| { + let neighbors = graph.neighbors(current); + process_neighbors_with_look_ahead( + &neighbors, + process_neighbor, + prefetch_distance, + dist_calc, + ); } - } + ); + OrderedNode::new(current, closest_dist.into()) +} +pub fn greedy_search_borrowed( + graph: &impl BorrowingGraph, + start: OrderedNode, + dist_calc: &impl DistCalculator, + prefetch_distance: Option, +) -> OrderedNode { + let mut current = start.id; + let mut closest_dist = start.dist.0; + greedy_search_loop!( + current, + closest_dist, + dist_calc, + prefetch_distance, + |process_neighbor| { + let neighbors = graph.neighbors(current); + process_neighbors_with_look_ahead( + neighbors, + process_neighbor, + prefetch_distance, + dist_calc, + ); + } + ); OrderedNode::new(current, closest_dist.into()) } diff --git a/rust/lance-index/src/vector/hnsw.rs b/rust/lance-index/src/vector/hnsw.rs index 73d111cf56c..28960bf6ba3 100644 --- a/rust/lance-index/src/vector/hnsw.rs +++ b/rust/lance-index/src/vector/hnsw.rs @@ -12,7 +12,7 @@ use itertools::Itertools; use serde::{Deserialize, Serialize}; use self::builder::HnswBuildParams; -use super::graph::{OrderedFloat, OrderedNode}; +use super::graph::OrderedNode; use super::storage::VectorStore; pub mod builder; @@ -76,11 +76,7 @@ fn select_neighbors_heuristic( break; } - if results.is_empty() - || results - .iter() - .all(|v| u.dist < OrderedFloat(storage.dist_between(u.id, v.id))) - { + if results.is_empty() || storage.prefers_candidate(u, &results) { results.push(u.clone()); } } diff --git a/rust/lance-index/src/vector/hnsw/builder.rs b/rust/lance-index/src/vector/hnsw/builder.rs index 06b533b2301..af4283c69f9 100644 --- a/rust/lance-index/src/vector/hnsw/builder.rs +++ b/rust/lance-index/src/vector/hnsw/builder.rs @@ -34,9 +34,10 @@ use crate::prefilter::PreFilter; use crate::vector::flat::storage::FlatFloatStorage; use crate::vector::graph::builder::GraphBuilderNode; use crate::vector::graph::{ - DISTS_FIELD, Graph, NEIGHBORS_COL, NEIGHBORS_FIELD, OrderedFloat, OrderedNode, VisitedGenerator, + BorrowingGraph, DISTS_FIELD, Graph, NEIGHBORS_COL, NEIGHBORS_FIELD, OrderedFloat, OrderedNode, + VisitedGenerator, }; -use crate::vector::graph::{Visited, greedy_search}; +use crate::vector::graph::{Visited, beam_search_borrowed, greedy_search, greedy_search_borrowed}; use crate::vector::storage::{DistCalculator, VectorStore}; use crate::vector::v3::subindex::IvfSubIndex; use crate::vector::{DIST_COL, Query, VECTOR_RESULT_SCHEMA}; @@ -117,7 +118,38 @@ impl HnswBuildParams { /// Each node in the graph has a global ID which is the index on the base layer. #[derive(Clone, DeepSizeOf)] pub struct HNSW { - inner: Arc, + inner: Arc, +} + +struct HnswCore { + params: HnswBuildParams, + nodes: Arc>, + level_count: Vec, + entry_point: u32, + visited_generator_queue: Arc>, +} + +impl DeepSizeOf for HnswCore { + fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize { + self.params.deep_size_of_children(context) + + self.nodes.deep_size_of_children(context) + + self.level_count.deep_size_of_children(context) + // Skipping the visited_generator_queue + } +} + +impl HnswCore { + fn max_level(&self) -> u16 { + self.params.max_level + } + + fn num_nodes(&self, level: usize) -> usize { + self.level_count[level] + } + + fn nodes(&self) -> Arc> { + self.nodes.clone() + } } impl Debug for HNSW { @@ -129,7 +161,7 @@ impl Debug for HNSW { impl HNSW { pub fn empty() -> Self { Self { - inner: Arc::new(HnswBuilder { + inner: Arc::new(HnswCore { params: HnswBuildParams::default(), nodes: Arc::new(Vec::new()), level_count: Vec::new(), @@ -155,7 +187,7 @@ impl HNSW { self.inner.num_nodes(level) } - pub fn nodes(&self) -> Arc>> { + pub fn nodes(&self) -> Arc> { self.inner.nodes() } @@ -172,10 +204,10 @@ impl HNSW { ) -> Result> { let dist_calc = storage.dist_calculator(query, params.dist_q_c); let mut ep = OrderedNode::new(0, dist_calc.distance(0).into()); - let nodes = &self.nodes(); + let nodes = self.inner.nodes.as_ref(); for level in (0..self.max_level()).rev() { - let cur_level = HnswLevelView::new(level, nodes); - ep = greedy_search( + let cur_level = ImmutableHnswLevelView::new(level, nodes); + ep = greedy_search_borrowed( &cur_level, ep, &dist_calc, @@ -183,9 +215,9 @@ impl HNSW { ); } - let bottom_level = HnswBottomView::new(nodes); + let bottom_level = ImmutableHnswBottomView::new(nodes); let mut visited = visited_generator.generate(storage.len()); - Ok(beam_search( + Ok(beam_search_borrowed( &bottom_level, &ep, params, @@ -306,10 +338,10 @@ impl HNSW { .inner .level_count .iter() - .chain(iter::once(&AtomicUsize::new(0))) + .chain(iter::once(&0)) .scan(0, |state, x| { let start = *state; - *state += x.load(Ordering::Relaxed); + *state += *x; Some(start) }) .collect(); @@ -343,16 +375,33 @@ impl DeepSizeOf for HnswBuilder { } impl HnswBuilder { - fn max_level(&self) -> u16 { - self.params.max_level - } + fn finish(self) -> HNSW { + let nodes = match Arc::try_unwrap(self.nodes) { + Ok(nodes) => nodes + .into_iter() + .map(|node| node.into_inner().expect("builder lock poisoned")) + .collect(), + Err(nodes) => nodes + .iter() + .map(|node| node.read().expect("builder lock poisoned").clone()) + .collect(), + }; - fn num_nodes(&self, level: usize) -> usize { - self.level_count[level].load(Ordering::Relaxed) - } + let level_count = self + .level_count + .into_iter() + .map(|count| count.load(Ordering::Relaxed)) + .collect(); - fn nodes(&self) -> Arc>> { - self.nodes.clone() + HNSW { + inner: Arc::new(HnswCore { + params: self.params, + nodes: Arc::new(nodes), + level_count, + entry_point: self.entry_point, + visited_generator_queue: self.visited_generator_queue, + }), + } } /// Create a new [`HNSWBuilder`] with prepared params and in memory vector storage. @@ -387,10 +436,11 @@ impl HnswBuilder { if len > 0 { nodes.push(RwLock::new(GraphBuilderNode::new(0, max_level as usize))); } + let mut level_rng = rng(); for i in 1..len { nodes.push(RwLock::new(GraphBuilderNode::new( i as u32, - builder.random_level() as usize + 1, + builder.random_level(&mut level_rng) as usize + 1, ))); } } @@ -402,8 +452,7 @@ impl HnswBuilder { /// New node's level /// /// See paper `Algorithm 1` - fn random_level(&self) -> u16 { - let mut rng = rng(); + fn random_level(&self, rng: &mut R) -> u16 { let ml = 1.0 / (self.params.m as f32).ln(); min( (-rng.random::().ln() * ml) as u16, @@ -486,7 +535,7 @@ impl HnswBuilder { ep: &OrderedNode, level: u16, dist_calc: &impl DistCalculator, - nodes: &Vec>, + nodes: &[RwLock], visited_generator: &mut VisitedGenerator, ) -> Vec { let cur_level = HnswLevelView::new(level, nodes); @@ -518,7 +567,6 @@ impl HnswBuilder { if level_neighbors.len() <= m_max { builder_node.update_from_ranked_neighbors(level); return; - //return level_neighbors; } *neighbors_ranked = select_neighbors_heuristic(storage, &level_neighbors, m_max); @@ -530,11 +578,11 @@ impl HnswBuilder { // This is used to iterate over neighbors in a specific level. pub(crate) struct HnswLevelView<'a> { level: u16, - nodes: &'a Vec>, + nodes: &'a [RwLock], } impl<'a> HnswLevelView<'a> { - pub fn new(level: u16, nodes: &'a Vec>) -> Self { + pub fn new(level: u16, nodes: &'a [RwLock]) -> Self { Self { level, nodes } } } @@ -550,24 +598,64 @@ impl Graph for HnswLevelView<'_> { } } -pub(crate) struct HnswBottomView<'a> { - nodes: &'a Vec>, +pub(crate) struct ImmutableHnswLevelView<'a> { + level: u16, + nodes: &'a [GraphBuilderNode], +} + +impl<'a> ImmutableHnswLevelView<'a> { + pub fn new(level: u16, nodes: &'a [GraphBuilderNode]) -> Self { + Self { level, nodes } + } +} + +impl Graph for ImmutableHnswLevelView<'_> { + fn len(&self) -> usize { + self.nodes.len() + } + + fn neighbors(&self, key: u32) -> Arc> { + self.nodes[key as usize].level_neighbors[self.level as usize].clone() + } } -impl<'a> HnswBottomView<'a> { - pub fn new(nodes: &'a Vec>) -> Self { +impl BorrowingGraph for ImmutableHnswLevelView<'_> { + fn len(&self) -> usize { + self.nodes.len() + } + + fn neighbors(&self, key: u32) -> &[u32] { + self.nodes[key as usize].level_neighbors[self.level as usize].as_slice() + } +} + +pub(crate) struct ImmutableHnswBottomView<'a> { + nodes: &'a [GraphBuilderNode], +} + +impl<'a> ImmutableHnswBottomView<'a> { + pub fn new(nodes: &'a [GraphBuilderNode]) -> Self { Self { nodes } } } -impl Graph for HnswBottomView<'_> { +impl Graph for ImmutableHnswBottomView<'_> { fn len(&self) -> usize { self.nodes.len() } fn neighbors(&self, key: u32) -> Arc> { - let node = &self.nodes[key as usize]; - node.read().unwrap().bottom_neighbors.clone() + self.nodes[key as usize].bottom_neighbors.clone() + } +} + +impl BorrowingGraph for ImmutableHnswBottomView<'_> { + fn len(&self) -> usize { + self.nodes.len() + } + + fn neighbors(&self, key: u32) -> &[u32] { + self.nodes[key as usize].bottom_neighbors.as_slice() } } @@ -657,10 +745,10 @@ impl IvfSubIndex for HNSW { .push(VisitedGenerator::new(0)) .unwrap(); } - let inner = HnswBuilder { + let inner = HnswCore { params: hnsw_metadata.params, - nodes: Arc::new(nodes.into_iter().map(RwLock::new).collect()), - level_count: level_count.into_iter().map(AtomicUsize::new).collect(), + nodes: Arc::new(nodes), + level_count, entry_point: hnsw_metadata.entry_point, visited_generator_queue, }; @@ -756,35 +844,32 @@ impl IvfSubIndex for HNSW { where Self: Sized, { - let inner = HnswBuilder::with_params(params, storage); - let hnsw = Self { - inner: Arc::new(inner), - }; + let builder = HnswBuilder::with_params(params, storage); log::debug!( "Building HNSW graph: num={}, max_levels={}, m={}, ef_construction={}, distance_type:{}", storage.len(), - hnsw.inner.params.max_level, - hnsw.inner.params.m, - hnsw.inner.params.ef_construction, + builder.params.max_level, + builder.params.m, + builder.params.ef_construction, storage.distance_type(), ); if storage.is_empty() { - return Ok(hnsw); + return Ok(builder.finish()); } let len = storage.len(); - hnsw.inner.level_count[0].fetch_add(1, Ordering::Relaxed); + builder.level_count[0].fetch_add(1, Ordering::Relaxed); (1..len).into_par_iter().for_each_init( || VisitedGenerator::new(len), |visited_generator, node| { - hnsw.inner.insert(node as u32, visited_generator, storage); + builder.insert(node as u32, visited_generator, storage); }, ); - assert_eq!(hnsw.inner.level_count[0].load(Ordering::Relaxed), len); - Ok(hnsw) + assert_eq!(builder.level_count[0].load(Ordering::Relaxed), len); + Ok(builder.finish()) } fn remap( @@ -807,7 +892,6 @@ impl IvfSubIndex for HNSW { for level in 0..self.max_level() { let level = level as usize; for (id, node) in self.inner.nodes.iter().enumerate() { - let node = node.read().unwrap(); if level >= node.level_neighbors.len() { continue; } diff --git a/rust/lance-index/src/vector/pq/storage.rs b/rust/lance-index/src/vector/pq/storage.rs index 3495ba8c8c1..841936b8ed8 100644 --- a/rust/lance-index/src/vector/pq/storage.rs +++ b/rust/lance-index/src/vector/pq/storage.rs @@ -34,6 +34,7 @@ use serde::{Deserialize, Serialize}; use super::ProductQuantizer; use super::distance::{build_distance_table_dot, build_distance_table_l2, compute_pq_distance}; use crate::frag_reuse::FragReuseIndex; +use crate::vector::graph::{OrderedFloat, OrderedNode}; use crate::{ INDEX_METADATA_SCHEMA_KEY, IndexMetadata, pb, vector::{ @@ -824,6 +825,12 @@ impl VectorStore for ProductQuantizationStorage { _ => unimplemented!("Unsupported data type: {:?}", codebook.value_type()), } } + + fn prefers_candidate(&self, candidate: &OrderedNode, selected: &[OrderedNode]) -> bool { + selected + .iter() + .all(|other| candidate.dist < OrderedFloat(self.dist_between(candidate.id, other.id))) + } } /// Distance calculator backed by PQ code. diff --git a/rust/lance-index/src/vector/storage.rs b/rust/lance-index/src/vector/storage.rs index 598e8f95ca9..5a1c0e7e6f5 100644 --- a/rust/lance-index/src/vector/storage.rs +++ b/rust/lance-index/src/vector/storage.rs @@ -28,6 +28,8 @@ use crate::{ }; use super::DISTANCE_TYPE_KEY; +use super::graph::OrderedFloat; +use super::graph::OrderedNode; use super::quantizer::{Quantizer, QuantizerMetadata}; ///

@@ -102,6 +104,13 @@ pub trait VectorStore: Send + Sync + Sized + Clone { let dist_cal_u = self.dist_calculator_from_id(u); dist_cal_u.distance(v) } + + fn prefers_candidate(&self, candidate: &OrderedNode, selected: &[OrderedNode]) -> bool { + let dist_cal_candidate = self.dist_calculator_from_id(candidate.id); + selected + .iter() + .all(|other| candidate.dist < OrderedFloat(dist_cal_candidate.distance(other.id))) + } } pub struct StorageBuilder { From 06939088e60a286abd245ff5360999337b7f0e89 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Tue, 17 Mar 2026 17:59:16 +0800 Subject: [PATCH 037/206] chore: bump lz4_flex patch versions (#6212) This updates both transitive `lz4_flex` lines to their latest patch releases so the lockfile no longer carries the yanked `0.11.5` release. The `0.11.x` line comes from `tantivy`, and the `0.12.x` line comes from the Arrow/Parquet/DataFusion stack. Validated with `cargo check -p lance --locked`. --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3fb73786b4..a0a31acdcf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,7 +352,7 @@ dependencies = [ "arrow-schema", "arrow-select", "flatbuffers", - "lz4_flex 0.12.0", + "lz4_flex 0.12.1", "zstd", ] @@ -5796,15 +5796,15 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" [[package]] name = "lz4_flex" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e" +checksum = "98c23545df7ecf1b16c303910a69b079e8e251d60f7dd2cc9b4177f2afaf1746" dependencies = [ "twox-hash", ] @@ -6545,7 +6545,7 @@ dependencies = [ "futures", "half", "hashbrown 0.16.1", - "lz4_flex 0.12.0", + "lz4_flex 0.12.1", "num-bigint", "num-integer", "num-traits", @@ -8626,7 +8626,7 @@ dependencies = [ "levenshtein_automata", "log", "lru 0.12.5", - "lz4_flex 0.11.5", + "lz4_flex 0.11.6", "measure_time", "memmap2", "once_cell", From 0a0b83126ebc6056ddcb97d58cdfa7cb7ab73af9 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Tue, 17 Mar 2026 18:54:31 +0800 Subject: [PATCH 038/206] fix: prewarm all segments for named indices (#6211) This PR fixes `prewarm_index(name)` only prewarming the first UUID when an index name has multiple deltas/segments. It now iterates all index metadata entries for the given name and prewarms each concrete UUID. The change adds regression coverage for both IVF-PQ v3 multi-delta indices and legacy IVF-PQ multi-delta data (via migration fixture), so ANN queries no longer incur first-query index IO after prewarm in multi-segment scenarios. --- rust/lance/src/index.rs | 10 ++- rust/lance/src/index/vector/ivf.rs | 75 +++++++++++++++++ rust/lance/src/index/vector/ivf/v2.rs | 115 ++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 4 deletions(-) diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index ab3876b7977..8c1b2404a42 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -654,10 +654,12 @@ impl DatasetIndexExt for Dataset { return Err(Error::index_not_found(format!("name={}", name))); } - let index = self - .open_generic_index(name, &indices[0].uuid.to_string(), &NoOpMetricsCollector) - .await?; - index.prewarm().await?; + for index_meta in indices { + let index = self + .open_generic_index(name, &index_meta.uuid.to_string(), &NoOpMetricsCollector) + .await?; + index.prewarm().await?; + } Ok(()) } diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 908564db4aa..a39bd58e3c3 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -2274,6 +2274,7 @@ mod tests { use crate::index::vector::IndexFileVersion; use crate::index::vector_index_details; use crate::index::{DatasetIndexExt, DatasetIndexInternalExt, vector::VectorIndexParams}; + use crate::utils::test::copy_test_data_to_tmp; const DIM: usize = 32; @@ -3860,4 +3861,78 @@ mod tests { let stats = dataset.object_store().io_stats_incremental(); assert_io_eq!(stats, read_iops, 0, "second prewarm should not perform IO"); } + + #[tokio::test] + async fn test_prewarm_ivf_legacy_multiple_deltas() { + use lance_io::assert_io_eq; + + let test_dir = copy_test_data_to_tmp("v0.21.0/bad_index_fragment_bitmap").unwrap(); + let test_uri = test_dir.path_str(); + let test_uri = &test_uri; + + // Trigger migration to repair legacy corrupt fragment bitmaps. + let mut dataset = Dataset::open(test_uri).await.unwrap(); + dataset.index_statistics("vector_idx").await.unwrap(); + dataset.checkout_latest().await.unwrap(); + + // Reopen dataset to avoid carrying index state in-memory from migration. + let dataset = Dataset::open(test_uri).await.unwrap(); + let indices = dataset.load_indices_by_name("vector_idx").await.unwrap(); + assert_eq!(indices.len(), 2, "expected two index deltas for vector_idx"); + let unique_uuids: HashSet<_> = indices.iter().map(|meta| meta.uuid).collect(); + assert_eq!(unique_uuids.len(), 2, "expected two unique index UUIDs"); + + let sample_batch = dataset + .scan() + .limit(Some(1), None) + .unwrap() + .project(&["vector"]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let q = sample_batch["vector"] + .as_any() + .downcast_ref::() + .unwrap() + .value(0) + .as_any() + .downcast_ref::() + .unwrap() + .clone(); + + // Reset IO stats after migration and sampling. + dataset.object_store().io_stats_incremental(); + + // Prewarm should perform IO to load all index deltas into cache. + dataset.prewarm_index("vector_idx").await.unwrap(); + let stats = dataset.object_store().io_stats_incremental(); + assert!( + stats.read_iops > 0, + "prewarm should have read from disk, but read_iops was 0" + ); + + // Query should not perform index IO after prewarm of all deltas. + dataset + .scan() + .nearest("vector", &q, 10) + .unwrap() + .project(&["_rowid"]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let stats = dataset.object_store().io_stats_incremental(); + assert_io_eq!( + stats, + read_iops, + 0, + "query should not perform IO after prewarm" + ); + + // Second prewarm should not need IO (already cached). + dataset.prewarm_index("vector_idx").await.unwrap(); + let stats = dataset.object_store().io_stats_incremental(); + assert_io_eq!(stats, read_iops, 0, "second prewarm should not perform IO"); + } } diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 34e33e42e52..d781bb5456c 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -3511,4 +3511,119 @@ mod tests { let stats = dataset.object_store().io_stats_incremental(); assert_io_eq!(stats, read_iops, 0, "second prewarm should not perform IO"); } + + #[tokio::test] + async fn test_prewarm_ivf_pq_multiple_deltas() { + use lance_io::assert_io_eq; + + const INDEX_NAME: &str = "my_idx"; + const BASE_ROWS_PER_PARTITION: usize = 3_000; + const SMALL_APPEND_ROWS: usize = 64; + let offsets = [-50.0, 50.0]; + + let test_dir = TempStrDir::default(); + let test_uri = test_dir.as_str(); + + let (batch, schema) = generate_clustered_batch(BASE_ROWS_PER_PARTITION, offsets); + let batches = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); + let mut dataset = Dataset::write( + batches, + test_uri, + Some(WriteParams { + mode: WriteMode::Overwrite, + ..Default::default() + }), + ) + .await + .unwrap(); + + let centroids = build_centroids_for_offsets(&offsets); + let ivf_params = IvfBuildParams::try_with_centroids(2, centroids).unwrap(); + let params = VectorIndexParams::with_ivf_pq_params( + DistanceType::L2, + ivf_params, + PQBuildParams::default(), + ); + dataset + .create_index( + &["vector"], + IndexType::Vector, + Some(INDEX_NAME.to_string()), + ¶ms, + true, + ) + .await + .unwrap(); + + let template_batch = dataset + .take_rows(&[0], dataset.schema().clone()) + .await + .unwrap(); + let template_values = template_batch["vector"] + .as_fixed_size_list() + .value(0) + .as_primitive::() + .values() + .to_vec(); + let mut append_params = WriteParams { + max_rows_per_file: 32, + max_rows_per_group: 32, + ..Default::default() + }; + append_params.mode = WriteMode::Append; + append_constant_vector_with_params( + &mut dataset, + SMALL_APPEND_ROWS, + &template_values, + Some(append_params), + ) + .await; + + dataset + .optimize_indices(&OptimizeOptions::new()) + .await + .unwrap(); + + // Reopen dataset to avoid carrying index state in-memory from index creation. + let dataset = Dataset::open(test_uri).await.unwrap(); + let indices = dataset.load_indices_by_name(INDEX_NAME).await.unwrap(); + assert_eq!(indices.len(), 2, "expected two index deltas for my_idx"); + let unique_uuids: HashSet<_> = indices.iter().map(|meta| meta.uuid).collect(); + assert_eq!(unique_uuids.len(), 2, "expected two unique index UUIDs"); + + // Reset IO stats after index creation + dataset.object_store().io_stats_incremental(); + + // Prewarm should perform IO to load all index deltas into cache + dataset.prewarm_index(INDEX_NAME).await.unwrap(); + let stats = dataset.object_store().io_stats_incremental(); + assert!( + stats.read_iops > 0, + "prewarm should have read from disk, but read_iops was 0" + ); + + // Query should not perform IO after prewarm of all deltas + let q = Float32Array::from(template_values.clone()); + dataset + .scan() + .nearest("vector", &q, 10) + .unwrap() + .project(&["_rowid"]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let stats = dataset.object_store().io_stats_incremental(); + assert_io_eq!( + stats, + read_iops, + 0, + "query should not perform IO after prewarm" + ); + + // Second prewarm should not need IO (already cached) + dataset.prewarm_index(INDEX_NAME).await.unwrap(); + let stats = dataset.object_store().io_stats_incremental(); + assert_io_eq!(stats, read_iops, 0, "second prewarm should not perform IO"); + } } From 7b42fd5cfa6dc21dfea51885d3a88ee7ff4ccde0 Mon Sep 17 00:00:00 2001 From: Jonathan Hsieh Date: Tue, 17 Mar 2026 14:42:16 -0700 Subject: [PATCH 039/206] fix: prevent duplicate manifest entries from concurrent table creation (#6143) Change conflict_retries from 0 to 5 in insert_into_manifest so that cross-process races are handled correctly. When two processes concurrently insert the same object_id, the second one hits a commit version conflict. With conflict_retries > 0, MergeInsert retries by re-evaluating the full plan against the latest data, where the join detects the existing row and WhenMatched::Fail fires properly. Previously, conflict_retries=0 meant the second operation would fail with a generic TooMuchWriteContention error, but in some cases both commits could succeed creating duplicate manifest entries ("Expected exactly 1 table...found 2"). Add test with two independent ManifestNamespace instances racing on the same directory to verify no duplicates are created. Here's an example I run into occasionally ``` ... File "/home/runner/work/geneva/geneva/src/geneva/state/manager.py", line 35, in __init__ self.table = alter_or_create_table( File "/home/runner/work/geneva/geneva/src/geneva/utils/schema.py", line 138, in alter_or_create_table return db.create_table(table_name, schema=schema, namespace=namespace) File "/home/runner/work/geneva/geneva/src/geneva/db.py", line 403, in create_table return Table(self, name, namespace=namespace, storage_options=storage_options) File "/home/runner/work/geneva/geneva/src/geneva/table.py", line 489, in __init__ self._ltbl # noqa File "/home/runner/.local/share/uv/python/cpython-3.10-linux-x86_64-gnu/lib/python3.10/functools.py", line 981, in __get__ val = self.func(instance) File "/home/runner/work/geneva/geneva/src/geneva/table.py", line 543, in _ltbl tbl = inner.open_table(self.name, namespace=self._namespace) File "/home/runner/work/geneva/geneva/.venv/lib/python3.10/site-packages/lancedb/namespace.py", line 392, in open_table response = self._ns.describe_table(request) File "/home/runner/work/geneva/geneva/.venv/lib/python3.10/site-packages/lance/namespace.py", line 362, in describe_table response_dict = self._inner.describe_table(request.model_dump()) OSError: LanceError(IO): Expected exactly 1 table with id 'default$geneva_manifests', found 2, /home/runner/work/lance/lance/rust/lance-namespace-impls/src/dir/manifest.rs:642:21 ``` --------- Co-authored-by: Claude Opus 4.6 --- .../lance-namespace-impls/src/dir/manifest.rs | 88 ++++++++++++++++++- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index af78e41856c..16947c58290 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -862,9 +862,12 @@ impl ManifestNamespace { merge_builder.when_matched(lance::dataset::WhenMatched::Fail); merge_builder.when_not_matched(lance::dataset::WhenNotMatched::InsertAll); - // conflict_retries=0: no outer loop retry on semantic conflicts (handled by caller) - // commit_retries: inner retry for manifest version conflicts (uses lance default if not set) - merge_builder.conflict_retries(0); + // Use conflict_retries to handle cross-process races on manifest mutations. + // When two processes concurrently insert the same object_id, the second one + // hits a commit conflict. With conflict_retries > 0, the retry re-evaluates + // the full MergeInsert plan against the latest data, where the join detects + // the existing row and WhenMatched::Fail fires, producing a clear error. + merge_builder.conflict_retries(5); // TODO: after BTREE index creation on object_id, has_scalar_index=true causes // MergeInsert to use V1 path which lacks bloom filters for conflict detection. This // results in (Some, None) filter mismatch when rebasing against V2 operations. @@ -2918,4 +2921,83 @@ mod tests { "URL with existing trailing slash should still work" ); } + + /// Test that concurrent create_table calls for the same table name don't + /// create duplicate entries in the manifest. Uses two independent + /// ManifestNamespace instances pointing at the same directory to simulate + /// two separate OS processes racing on table creation. The conflict_retries + /// setting on the MergeInsert ensures the second operation properly detects + /// the duplicate via WhenMatched::Fail after retrying against the latest data. + #[tokio::test] + async fn test_concurrent_create_table_no_duplicates() { + let temp_dir = TempStdDir::default(); + let temp_path = temp_dir.to_str().unwrap(); + + // Two independent namespace instances = two separate "processes" + // sharing the same underlying filesystem directory. + let ns1 = DirectoryNamespaceBuilder::new(temp_path) + .inline_optimization_enabled(false) + .build() + .await + .unwrap(); + let ns2 = DirectoryNamespaceBuilder::new(temp_path) + .inline_optimization_enabled(false) + .build() + .await + .unwrap(); + + let buffer = create_test_ipc_data(); + + let mut req1 = CreateTableRequest::new(); + req1.id = Some(vec!["race_table".to_string()]); + let mut req2 = CreateTableRequest::new(); + req2.id = Some(vec!["race_table".to_string()]); + + // Launch both create_table calls concurrently + let (result1, result2) = tokio::join!( + ns1.create_table(req1, Bytes::from(buffer.clone())), + ns2.create_table(req2, Bytes::from(buffer.clone())), + ); + + // Exactly one should succeed and one should fail + let success_count = [&result1, &result2].iter().filter(|r| r.is_ok()).count(); + let failure_count = [&result1, &result2].iter().filter(|r| r.is_err()).count(); + assert_eq!( + success_count, 1, + "Exactly one create should succeed, got: result1={:?}, result2={:?}", + result1, result2 + ); + assert_eq!( + failure_count, 1, + "Exactly one create should fail, got: result1={:?}, result2={:?}", + result1, result2 + ); + + // Verify only one table entry exists in the manifest + let ns_check = DirectoryNamespaceBuilder::new(temp_path) + .inline_optimization_enabled(false) + .build() + .await + .unwrap(); + let mut list_request = ListTablesRequest::new(); + list_request.id = Some(vec![]); + let response = ns_check.list_tables(list_request).await.unwrap(); + assert_eq!( + response.tables.len(), + 1, + "Should have exactly 1 table, found: {:?}", + response.tables + ); + assert_eq!(response.tables[0], "race_table"); + + // Also verify describe_table works (no "found 2" error) + let mut describe_request = DescribeTableRequest::new(); + describe_request.id = Some(vec!["race_table".to_string()]); + let describe_result = ns_check.describe_table(describe_request).await; + assert!( + describe_result.is_ok(), + "describe_table should not fail with duplicate entries: {:?}", + describe_result + ); + } } From fb1566a04f2896fa8cd3a86393f09250095c36ed Mon Sep 17 00:00:00 2001 From: Gallardot Date: Wed, 18 Mar 2026 17:15:25 +0800 Subject: [PATCH 040/206] fix: add missing type hint for producer function (#6133) Added missing type hint for producer function to indicate it returns an iterator of RecordBatches. --- docs/src/guide/read_and_write.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/guide/read_and_write.md b/docs/src/guide/read_and_write.md index cbfb65a1e4c..ec6cde5173a 100644 --- a/docs/src/guide/read_and_write.md +++ b/docs/src/guide/read_and_write.md @@ -19,6 +19,8 @@ also supports `Iterator` of `pyarrow.RecordBatch` es. You will need to provide a `pyarrow.Schema` for the dataset in this case. ```python +from typing import Iterator + def producer() -> Iterator[pa.RecordBatch]: """An iterator of RecordBatches.""" yield pa.RecordBatch.from_pylist([{"name": "Alice", "age": 20}]) From ae9a2cbf7f590630f905598497504de3badbe5ac Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Wed, 18 Mar 2026 04:29:06 -0500 Subject: [PATCH 041/206] fix: maintaining individual fragment operation when calling take_source (#5844) The `take_source` function does not maintain fragment configuration for the overall scanner. This means that even if instructed to query a single fragment, it will query over the entire dataset. Closes https://github.com/lance-format/lance/issues/5351 --------- Signed-off-by: Daniel Rammer Co-authored-by: Xuanwo --- rust/lance/src/dataset/scanner.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rust/lance/src/dataset/scanner.rs b/rust/lance/src/dataset/scanner.rs index cdaa9408281..6cadcb1f7a7 100644 --- a/rust/lance/src/dataset/scanner.rs +++ b/rust/lance/src/dataset/scanner.rs @@ -2725,9 +2725,15 @@ impl Scanner { } }?; + let mut filtered_read_options = FilteredReadOptions::new(projection); + if let Some(fragment) = self.fragments.as_ref() { + filtered_read_options = + filtered_read_options.with_fragments(Arc::new(fragment.clone())); + } + Ok(Arc::new(FilteredReadExec::try_new( self.dataset.clone(), - FilteredReadOptions::new(projection), + filtered_read_options, Some(input), )?)) } From d59bd23bb83cee7dc7119231d664eeb64c34ccd1 Mon Sep 17 00:00:00 2001 From: Wyatt Alt Date: Wed, 18 Mar 2026 05:25:32 -0700 Subject: [PATCH 042/206] perf: pre-transpose PQ codebook for SIMD-friendly L2 distance (#5923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During PQ quantization we compute L2 distances from each sub-vector to every centroid in a codebook. The codebook is stored in AoS (Array of Structs) layout: AoS codebook [num_centroids][dimension]: centroid 0: [d0 d1 d2 d3 ...] centroid 1: [d0 d1 d2 d3 ...] centroid 2: [d0 d1 d2 d3 ...] ... For each centroid: diff = query - centroid (vector subtract) dist = sum(diff * diff) (horizontal reduction) The horizontal reduction serializes the SIMD pipeline: each centroid must be fully reduced before starting the next. This patch introduces L2Prepared, which transposes the codebook once at construction time to SoA (Structure of Arrays) layout: SoA codebook [dimension][num_centroids]: dim 0: [c0 c1 c2 c3 ...] <- contiguous across centroids dim 1: [c0 c1 c2 c3 ...] dim 2: [c0 c1 c2 c3 ...] ... For each dimension d: diff = query[d] - row[d] (broadcast scalar - packed vector) result += diff * diff (packed FMA into running totals) All centroids accumulate in parallel with no horizontal reduction until the final result is read. LLVM emits vbroadcastss + vsubps + vfmadd231ps (AVX2) or the equivalent on other targets. For typical PQ parameters (256 centroids × 16-dim sub-vectors = 16 KB), the transposed codebook fits in L1 cache, making the SoA access pattern efficient. The transpose is done once when the ProductQuantizer is constructed and amortized over its lifetime. The primary beneficiary is the quantization path (transform_impl), which calls nearest() per sub-vector per vector during index building. The search-time distance table construction (build_l2_distance_table) also uses the transposed layout, though it runs only once per query and is not a bottleneck. --------- Co-authored-by: Xuanwo --- rust/lance-index/src/vector/pq.rs | 171 +++++++++++++----- rust/lance-index/src/vector/pq/distance.rs | 17 +- rust/lance-linalg/src/distance/l2.rs | 199 +++++++++++++++++++++ 3 files changed, 341 insertions(+), 46 deletions(-) diff --git a/rust/lance-index/src/vector/pq.rs b/rust/lance-index/src/vector/pq.rs index e522d9d15d2..3d11fc4a99e 100644 --- a/rust/lance-index/src/vector/pq.rs +++ b/rust/lance-index/src/vector/pq.rs @@ -14,7 +14,7 @@ use deepsize::DeepSizeOf; use distance::build_distance_table_dot; use lance_arrow::*; use lance_core::{Error, Result, assume_eq}; -use lance_linalg::distance::{DistanceType, Dot, L2}; +use lance_linalg::distance::{DistanceType, Dot, L2, l2::L2Prepared}; use lance_table::utils::LanceIteratorExtension; use num_traits::Float; use prost::Message; @@ -27,7 +27,9 @@ pub mod storage; pub mod transform; pub(crate) mod utils; -use self::distance::{build_distance_table_l2, compute_pq_distance}; +use self::distance::{ + build_distance_table_l2, build_distance_table_l2_prepared, compute_pq_distance, +}; pub use self::utils::num_centroids; use super::quantizer::{ Quantization, QuantizationMetadata, QuantizationType, Quantizer, QuantizerBuildParams, @@ -44,6 +46,10 @@ pub struct ProductQuantizer { pub dimension: usize, pub codebook: FixedSizeListArray, pub distance_type: DistanceType, + /// Pre-transposed L2 targets per sub-vector for fast f32 L2 batch computation. + /// Only populated when codebook is f32 and distance_type is L2 + /// (Cosine is converted to L2 before construction, so it benefits too). + l2_targets: Option>, } impl DeepSizeOf for ProductQuantizer { @@ -53,10 +59,43 @@ impl DeepSizeOf for ProductQuantizer { + self.num_bits.deep_size_of_children(_context) + self.dimension.deep_size_of_children(_context) + self.distance_type.deep_size_of_children(_context) + + self + .l2_targets + .as_ref() + .map_or(0, |v| v.iter().map(|t| t.size_bytes()).sum()) } } impl ProductQuantizer { + /// Build per-sub-vector L2Prepared from the codebook if applicable (f32 + L2). + fn build_l2_targets( + codebook: &FixedSizeListArray, + distance_type: DistanceType, + num_sub_vectors: usize, + num_bits: u32, + dimension: usize, + ) -> Option> { + if codebook.value_type() != DataType::Float32 || distance_type != DistanceType::L2 { + return None; + } + let values = codebook + .values() + .as_primitive::() + .values(); + let sub_dim = dimension / num_sub_vectors; + let num_centroids = 2_usize.pow(num_bits); + let block_size = sub_dim * num_centroids; + + let targets = (0..num_sub_vectors) + .map(|sub_idx| { + let block_start = sub_idx * block_size; + let block = &values[block_start..block_start + block_size]; + L2Prepared::new(block, sub_dim) + }) + .collect(); + Some(targets) + } + pub fn new( num_sub_vectors: usize, num_bits: u32, @@ -64,12 +103,20 @@ impl ProductQuantizer { codebook: FixedSizeListArray, distance_type: DistanceType, ) -> Self { + let l2_targets = Self::build_l2_targets( + &codebook, + distance_type, + num_sub_vectors, + num_bits, + dimension, + ); Self { num_bits, num_sub_vectors, dimension, codebook, distance_type, + l2_targets, } } @@ -85,13 +132,13 @@ impl ProductQuantizer { proto.dimension as i32, )?, }; - Ok(Self { - num_bits: proto.num_bits, - num_sub_vectors: proto.num_sub_vectors as usize, - dimension: proto.dimension as usize, + Ok(Self::new( + proto.num_sub_vectors as usize, + proto.num_bits, + proto.dimension as usize, codebook, distance_type, - }) + )) } #[instrument(name = "ProductQuantizer::transform", level = "debug", skip_all)] @@ -136,38 +183,74 @@ impl ProductQuantizer { let flatten_data = fsl.values().as_primitive::(); let sub_dim = dim / num_sub_vectors; + let num_centroids = 2_usize.pow(NUM_BITS); let total_code_length = fsl.len() * num_sub_vectors / (8 / NUM_BITS as usize); - let values = flatten_data - .values() - .chunks_exact(dim) - .flat_map(|vector| { - let sub_vec_code = vector - .chunks_exact(sub_dim) - .enumerate() - .map(|(sub_idx, sub_vector)| { - let centroids = get_sub_vector_centroids::( - codebook.values(), - dim, - num_sub_vectors, - sub_idx, - ); - // SAFETY: The must be 2^NUM_BITS centroids, it's safe to unwrap_or(0), - // this could happen if all distances are INFs in the case of vectors are large. - assume_eq!(centroids.len(), 2_usize.pow(NUM_BITS) * sub_dim); - compute_partition(centroids, sub_vector, distance_type).unwrap_or(0) as u8 - }) - .collect::>(); + + let values = if let Some(targets) = &self.l2_targets { + // Fast path for f32 + L2: use pre-transposed codebook. + // SAFETY: l2_targets is only populated when T::Native is f32. + let flat_f32: &[f32] = unsafe { + std::slice::from_raw_parts( + flatten_data.values().as_ptr() as *const f32, + flatten_data.values().len(), + ) + }; + let mut values = vec![0u8; total_code_length]; + let bytes_per_vector = num_sub_vectors / (8 / NUM_BITS as usize); + let mut dist_buf = vec![0.0f32; num_centroids]; + for (vec_idx, vector) in flat_f32.chunks_exact(dim).enumerate() { + let out = &mut values[vec_idx * bytes_per_vector..][..bytes_per_vector]; if NUM_BITS == 4 { - sub_vec_code - .chunks_exact(2) - .map(|v| (v[1] << 4) | v[0]) - .collect::>() + for (pair_idx, pair) in vector.chunks_exact(sub_dim * 2).enumerate() { + let lo = targets[pair_idx * 2] + .nearest_into(&pair[..sub_dim], &mut dist_buf) + .unwrap_or(0) as u8; + let hi = targets[pair_idx * 2 + 1] + .nearest_into(&pair[sub_dim..], &mut dist_buf) + .unwrap_or(0) as u8; + out[pair_idx] = (hi << 4) | lo; + } } else { - sub_vec_code + for (sub_idx, sv) in vector.chunks_exact(sub_dim).enumerate() { + out[sub_idx] = targets[sub_idx] + .nearest_into(sv, &mut dist_buf) + .unwrap_or(0) as u8; + } } - }) - .exact_size(total_code_length) - .collect::>(); + } + values + } else { + flatten_data + .values() + .chunks_exact(dim) + .flat_map(|vector| { + let sub_vec_code: Vec = vector + .chunks_exact(sub_dim) + .enumerate() + .map(|(sub_idx, sub_vector)| { + let centroids = get_sub_vector_centroids::( + codebook.values(), + dim, + num_sub_vectors, + sub_idx, + ); + assume_eq!(centroids.len(), num_centroids * sub_dim); + compute_partition(centroids, sub_vector, distance_type).unwrap_or(0) + as u8 + }) + .collect(); + if NUM_BITS == 4 { + sub_vec_code + .chunks_exact(2) + .map(|v| (v[1] << 4) | v[0]) + .collect::>() + } else { + sub_vec_code + } + }) + .exact_size(total_code_length) + .collect::>() + }; let num_sub_vectors_in_byte = if NUM_BITS == 4 { num_sub_vectors / 2 @@ -216,15 +299,7 @@ impl ProductQuantizer { /// It returns the squared L2 distance. fn l2_distances(&self, key: &dyn Array, code: &UInt8Array) -> Result { let distance_table = self.build_l2_distance_table(key)?; - - #[cfg(target_feature = "avx512f")] - { - Ok(self.compute_l2_distance(&distance_table, code.values())) - } - #[cfg(not(target_feature = "avx512f"))] - { - Ok(self.compute_l2_distance(&distance_table, code.values())) - } + Ok(self.compute_l2_distance(&distance_table, code.values())) } /// Parameters @@ -284,7 +359,13 @@ impl ProductQuantizer { Ok(self.build_l2_distance_table_impl::(key.as_primitive())) } DataType::Float32 => { - Ok(self.build_l2_distance_table_impl::(key.as_primitive())) + if let Some(targets) = &self.l2_targets { + let query = key.as_primitive::().values(); + Ok(build_distance_table_l2_prepared(targets, query)) + } else { + Ok(self + .build_l2_distance_table_impl::(key.as_primitive())) + } } DataType::Float64 => { Ok(self.build_l2_distance_table_impl::(key.as_primitive())) diff --git a/rust/lance-index/src/vector/pq/distance.rs b/rust/lance-index/src/vector/pq/distance.rs index dd76c170e59..0a3f2ac7b8c 100644 --- a/rust/lance-index/src/vector/pq/distance.rs +++ b/rust/lance-index/src/vector/pq/distance.rs @@ -6,7 +6,7 @@ use std::cmp::{max, min}; use super::{num_centroids, utils::get_sub_vector_centroids}; use lance_core::assume_eq; -use lance_linalg::distance::{Dot, L2, dot_distance_batch, l2_distance_batch}; +use lance_linalg::distance::{Dot, L2, dot_distance_batch, l2::L2Prepared, l2_distance_batch}; use lance_linalg::simd::u8::u8x16; use lance_linalg::simd::{SIMD, Shuffle}; @@ -54,6 +54,21 @@ pub fn build_distance_table_l2_impl( result } +/// Build an L2 distance table using pre-prepared [L2Prepared] per sub-vector. +/// +/// This avoids the per-call AoS→SoA transpose by reusing targets that were +/// transposed once at `ProductQuantizer` construction time. +pub fn build_distance_table_l2_prepared(l2_targets: &[L2Prepared], query: &[f32]) -> Vec { + let sub_dim = query.len() / l2_targets.len(); + let num_targets = l2_targets[0].num_targets(); + + let mut result = vec![0.0f32; l2_targets.len() * num_targets]; + for (i, sub_vec) in query.chunks_exact(sub_dim).enumerate() { + l2_targets[i].distances_into(sub_vec, &mut result[i * num_targets..][..num_targets]); + } + result +} + /// Build a Distance Table from the query to each PQ centroid /// using Dot distance. pub fn build_distance_table_dot( diff --git a/rust/lance-linalg/src/distance/l2.rs b/rust/lance-linalg/src/distance/l2.rs index 7523ef4603f..9aadb5472de 100644 --- a/rust/lance-linalg/src/distance/l2.rs +++ b/rust/lance-linalg/src/distance/l2.rs @@ -174,6 +174,108 @@ impl L2 for f64 { } } +/// Accumulate squared differences for one dimension into per-target results. +/// +/// Separated into its own function so that LLVM sees `row` and `result` +/// as non-aliasing via the function signature (`&[f32]` vs `&mut [f32]`), +/// enabling packed SIMD vectorization (vbroadcastss + vsubps + vfmadd231ps). +#[inline(never)] +fn accumulate_l2_dimension(q: f32, row: &[f32], result: &mut [f32]) { + for (dist, &target) in result.iter_mut().zip(row.iter()) { + let diff = q - target; + *dist += diff * diff; + } +} + +/// Pre-transposed target vectors for batched L2 distance computation. +/// +/// Stores targets in SoA layout `[dimension][num_targets]` so the inner +/// distance loop iterates over targets contiguously. The AoS-to-SoA +/// transpose is done once at construction; callers should reuse the +/// struct across many queries to amortize that cost. +/// +/// **Cache constraint**: this is designed for cases where +/// `num_targets × dimension × 4` fits in L1 cache (~32 KB), such as PQ +/// sub-vector codebooks (e.g. 256 centroids × 16 dims = 16 KB). +/// For large target sets the SoA layout causes L1 thrashing and +/// [`l2_distance_batch`] with its AoS per-target locality is faster. +#[derive(Debug, Clone)] +pub struct L2Prepared { + transposed: Vec, + dimension: usize, + num_targets: usize, +} + +impl L2Prepared { + /// Transpose `targets` from AoS `[num_targets][dimension]` to SoA layout. + pub fn new(targets: &[f32], dimension: usize) -> Self { + let num_targets = targets.len() / dimension; + debug_assert_eq!(targets.len(), num_targets * dimension); + + let mut transposed = vec![0.0f32; targets.len()]; + for t in 0..num_targets { + for d in 0..dimension { + transposed[d * num_targets + t] = targets[t * dimension + d]; + } + } + + Self { + transposed, + dimension, + num_targets, + } + } + + /// Compute L2 distances from `query` to every target, writing into `out`. + /// + /// `out` must have length `num_targets`. It will be zeroed before accumulation. + pub fn distances_into(&self, query: &[f32], out: &mut [f32]) { + debug_assert_eq!(query.len(), self.dimension); + debug_assert_eq!(out.len(), self.num_targets); + + out.fill(0.0); + for (d, &q) in query.iter().enumerate() { + let row = &self.transposed[d * self.num_targets..][..self.num_targets]; + accumulate_l2_dimension(q, row, out); + } + } + + /// Compute L2 distances from `query` to every target. + pub fn distances(&self, query: &[f32]) -> Vec { + let mut result = vec![0.0f32; self.num_targets]; + self.distances_into(query, &mut result); + result + } + + /// Return the index of the nearest target to `query`, using `buf` as scratch space. + /// + /// `buf` must have length `num_targets`. + pub fn nearest_into(&self, query: &[f32], buf: &mut [f32]) -> Option { + self.distances_into(query, buf); + crate::kernels::argmin_value_float(buf.iter().copied()).map(|(idx, _)| idx) + } + + /// Return the index of the nearest target to `query`. + pub fn nearest(&self, query: &[f32]) -> Option { + self.nearest_into(query, &mut vec![0.0f32; self.num_targets]) + } + + /// Number of targets in this set. + pub fn num_targets(&self) -> usize { + self.num_targets + } + + /// Dimension of each target vector. + pub fn dimension(&self) -> usize { + self.dimension + } + + /// Size of the internal buffer in bytes. + pub fn size_bytes(&self) -> usize { + self.transposed.len() * std::mem::size_of::() + } +} + /// Compute L2 distance between two vectors. #[inline] pub fn l2_distance(from: &[f32], to: &[f32]) -> f32 { @@ -445,4 +547,101 @@ mod tests { (255_u32.pow(2) * 2048) as f32 ); } + + #[test] + fn test_l2_targets_matches_scalar() { + let cases = vec![ + (16, 8), // small target count + (16, 16), // exact SIMD width + (16, 256), // PQ-like: 256 centroids, 16-dim sub-vectors + (16, 17), // one remainder + (16, 31), // 15 remainder + (1, 32), // dim=1 + (3, 20), // odd dimension + (128, 64), // larger dimension + ]; + + for (dim, num_targets) in cases { + let query: Vec = (0..dim).map(|i| (i as f32) * 0.1 + 0.05).collect(); + let targets: Vec = (0..dim * num_targets) + .map(|i| ((i * 7 + 3) % 100) as f32 * 0.01) + .collect(); + + let expected: Vec = targets + .chunks_exact(dim) + .map(|v| l2_scalar::(&query, v)) + .collect(); + + let prepared = L2Prepared::new(&targets, dim); + let actual = prepared.distances(&query); + + assert_eq!( + actual.len(), + expected.len(), + "length mismatch for dim={dim}, num_targets={num_targets}" + ); + for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() { + assert!( + approx::relative_eq!(a, e, max_relative = 1e-6), + "mismatch at index {i} for dim={dim}, num_targets={num_targets}: \ + prepared={a}, scalar={e}" + ); + } + } + } + + #[test] + fn test_l2_targets_zeros() { + let dim = 16; + let num_targets = 32; + let query = vec![0.0f32; dim]; + let targets = vec![0.0f32; dim * num_targets]; + + let prepared = L2Prepared::new(&targets, dim); + let distances = prepared.distances(&query); + assert_eq!(distances.len(), num_targets); + for d in &distances { + assert_eq!(*d, 0.0); + } + } + + #[test] + fn test_l2_targets_known_values() { + let dim = 2; + let query = vec![1.0f32, 0.0]; + + // 16 targets: [1,0], [0,1], [2,0], [0,0], then 12x [0,0] + let mut targets = vec![1.0, 0.0, 0.0, 1.0, 2.0, 0.0, 0.0, 0.0]; + for _ in 4..16 { + targets.extend_from_slice(&[0.0, 0.0]); + } + + let prepared = L2Prepared::new(&targets, dim); + let distances = prepared.distances(&query); + assert_eq!(distances.len(), 16); + assert_relative_eq!(distances[0], 0.0); + assert_relative_eq!(distances[1], 2.0); + assert_relative_eq!(distances[2], 1.0); + assert_relative_eq!(distances[3], 1.0); + for d in &distances[4..] { + assert_relative_eq!(*d, 1.0); + } + } + + #[test] + fn test_l2_targets_reuse() { + // Verify that the same L2Prepared can be queried multiple times + let dim = 4; + let targets = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let prepared = L2Prepared::new(&targets, dim); + + let q1 = vec![1.0, 2.0, 3.0, 4.0]; + let q2 = vec![5.0, 6.0, 7.0, 8.0]; + + let d1 = prepared.distances(&q1); + let d2 = prepared.distances(&q2); + + assert_relative_eq!(d1[0], 0.0); // q1 == target[0] + assert_relative_eq!(d2[1], 0.0); // q2 == target[1] + } } From 023b14b28b0b6f2869c4900b89dffa73581dfd87 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Wed, 18 Mar 2026 08:03:55 -0700 Subject: [PATCH 043/206] feat: add file list with sizes to IndexMetadata (#5497) Start storing the size of index files in the manifest file. Now you get the byte size of an index in `describe_indices()`: ```python >>> ds.describe_indices() [IndexDescription(name='x_idx', ..., total_size_bytes=1475), IndexDescription(name='y_idx', ..., total_size_bytes=2772)] ``` Because we can now skip the `HEAD` request to get file size, this reduces read IO requests for cold queries: | Case | Before | After | |------|-------:|------:| | Cold BTree search | 5 | 3 | | Warm BTree search | 1 | 1 | | Cold FTS search | 10 | 5 | | Warm FTS search | 1 | 1 | (Cold queries now do 1 IOP per index file, plus 1 for taking results. BTree has two files, while FTS has 4 files, so that's why it's 3 and 5 IOPs respectively.) Migration is handled on write: when we commit, we check for any indices missing the files field and will add them as needed. Closes https://github.com/lance-format/lance/issues/5226 --------- Co-authored-by: Claude Opus 4.5 --- java/lance-jni/src/transaction.rs | 2 + protos/table.proto | 14 + protos/transaction.proto | 3 + python/python/lance/__init__.py | 2 + python/python/lance/dataset.py | 9 + .../python/lance/lance/indices/__init__.pyi | 2 + python/python/tests/test_commit_index.py | 74 ++ python/python/tests/test_scalar_index.py | 8 + python/src/indices.rs | 26 +- python/src/transaction.rs | 54 +- rust/lance-index/src/scalar.rs | 12 + rust/lance-index/src/scalar/bitmap.rs | 3 + rust/lance-index/src/scalar/bloomfilter.rs | 2 + rust/lance-index/src/scalar/btree.rs | 3 + rust/lance-index/src/scalar/inverted.rs | 1 + .../src/scalar/inverted/builder.rs | 6 +- rust/lance-index/src/scalar/inverted/index.rs | 2 + rust/lance-index/src/scalar/json.rs | 3 + rust/lance-index/src/scalar/label_list.rs | 3 + rust/lance-index/src/scalar/lance_format.rs | 29 +- rust/lance-index/src/scalar/ngram.rs | 3 + rust/lance-index/src/scalar/rtree.rs | 2 + rust/lance-index/src/scalar/zonemap.rs | 2 + rust/lance-index/src/traits.rs | 6 + rust/lance-table/src/format.rs | 2 +- rust/lance-table/src/format/index.rs | 101 +++ rust/lance/src/dataset/index.rs | 6 +- .../src/dataset/mem_wal/memtable/flush.rs | 2 + rust/lance/src/dataset/optimize.rs | 1 + rust/lance/src/dataset/optimize/remapping.rs | 6 +- .../src/dataset/tests/dataset_migrations.rs | 102 +++ rust/lance/src/dataset/transaction.rs | 41 +- rust/lance/src/index.rs | 696 +++++++++++++++++- rust/lance/src/index/append.rs | 10 +- rust/lance/src/index/create.rs | 13 +- rust/lance/src/index/frag_reuse.rs | 2 + rust/lance/src/index/mem_wal.rs | 2 + rust/lance/src/index/scalar.rs | 7 +- rust/lance/src/index/vector.rs | 7 +- rust/lance/src/index/vector/ivf.rs | 3 + rust/lance/src/index/vector/ivf/v2.rs | 15 +- rust/lance/src/io/commit.rs | 37 +- rust/lance/src/io/commit/conflict_resolver.rs | 3 + test_data/pre_file_sizes/datagen.py | 47 ++ .../page_data.lance | Bin 0 -> 2885 bytes .../page_lookup.lance | Bin 0 -> 1151 bytes ...0-20f83903-0474-45d5-a4c8-58f793a0f577.txn | Bin 0 -> 182 bytes ...1-ed397f26-dd73-49f3-8781-3828da94fcea.txn | Bin 0 -> 145 bytes .../_versions/1.manifest | Bin 0 -> 449 bytes .../_versions/2.manifest | Bin 0 -> 520 bytes ...11100110009777b54c5f953114705fd03f9b.lance | Bin 0 -> 2885 bytes 51 files changed, 1332 insertions(+), 42 deletions(-) create mode 100644 test_data/pre_file_sizes/datagen.py create mode 100644 test_data/pre_file_sizes/index_without_file_sizes/_indices/be61629d-904e-433f-836c-1e586be2d5c6/page_data.lance create mode 100644 test_data/pre_file_sizes/index_without_file_sizes/_indices/be61629d-904e-433f-836c-1e586be2d5c6/page_lookup.lance create mode 100644 test_data/pre_file_sizes/index_without_file_sizes/_transactions/0-20f83903-0474-45d5-a4c8-58f793a0f577.txn create mode 100644 test_data/pre_file_sizes/index_without_file_sizes/_transactions/1-ed397f26-dd73-49f3-8781-3828da94fcea.txn create mode 100644 test_data/pre_file_sizes/index_without_file_sizes/_versions/1.manifest create mode 100644 test_data/pre_file_sizes/index_without_file_sizes/_versions/2.manifest create mode 100644 test_data/pre_file_sizes/index_without_file_sizes/data/1111100111100111100110009777b54c5f953114705fd03f9b.lance diff --git a/java/lance-jni/src/transaction.rs b/java/lance-jni/src/transaction.rs index 936834b588d..d2f5a391bb3 100644 --- a/java/lance-jni/src/transaction.rs +++ b/java/lance-jni/src/transaction.rs @@ -153,6 +153,7 @@ impl FromJObjectWithEnv for JObject<'_> { value: new_index_details_value, }, new_index_version: new_index_version as u32, + new_index_files: None, }) } } @@ -214,6 +215,7 @@ impl FromJObjectWithEnv for JObject<'_> { index_version, created_at, base_id, + files: None, }) } } diff --git a/protos/table.proto b/protos/table.proto index e7de867e46e..cb398e66431 100644 --- a/protos/table.proto +++ b/protos/table.proto @@ -280,6 +280,20 @@ message IndexMetadata { // The base path index of the data file. Used when the file is imported or referred from another dataset. // Lance use it as key of the base_paths field in Manifest to determine the actual base path of the data file. optional uint32 base_id = 9; + + // List of files and their sizes for this index segment. + // This enables skipping HEAD calls when opening indices and allows reporting + // of index sizes without extra IO. + // If this is empty, the index files sizes are unknown. + repeated IndexFile files = 10; +} + +// Metadata about a single file within an index segment. +message IndexFile { + // Path relative to the index directory (e.g., "index.idx", "auxiliary.idx") + string path = 1; + // Size of the file in bytes + uint64 size_bytes = 2; } // Index Section, containing a list of index metadata for one dataset version. diff --git a/protos/transaction.proto b/protos/transaction.proto index 17d96486736..bbdb61dbcce 100644 --- a/protos/transaction.proto +++ b/protos/transaction.proto @@ -110,6 +110,9 @@ message Transaction { google.protobuf.Any new_index_details = 3; // the version of the new index uint32 new_index_version = 4; + // Files in the new index with their sizes. + // Empty if file sizes are not available (e.g. older writers). + repeated IndexFile new_index_files = 5; } // A group of rewrite files that are all part of the same rewrite. diff --git a/python/python/lance/__init__.py b/python/python/lance/__init__.py index d3f1e46a017..0b905457a50 100644 --- a/python/python/lance/__init__.py +++ b/python/python/lance/__init__.py @@ -14,6 +14,7 @@ DataStatistics, FieldStatistics, Index, + IndexFile, LanceDataset, LanceOperation, LanceScanner, @@ -62,6 +63,7 @@ "FieldStatistics", "FragmentMetadata", "Index", + "IndexFile", "LanceDataset", "LanceFragment", "LanceOperation", diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index d57281d52e7..c73a7b5a724 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -4226,6 +4226,14 @@ class ExecuteResult(TypedDict): num_deleted_rows: int +@dataclass +class IndexFile: + """Metadata about a file in an index segment.""" + + path: str + size_bytes: int + + @dataclass class Index: """Represents an index in the dataset.""" @@ -4238,6 +4246,7 @@ class Index: index_version: int created_at: Optional[datetime] = None base_id: Optional[int] = None + files: Optional[List["IndexFile"]] = None class AutoCleanupConfig(TypedDict): diff --git a/python/python/lance/lance/indices/__init__.pyi b/python/python/lance/lance/indices/__init__.pyi index feed1f934fe..38e34218399 100644 --- a/python/python/lance/lance/indices/__init__.pyi +++ b/python/python/lance/lance/indices/__init__.pyi @@ -57,6 +57,7 @@ class IndexSegmentDescription: fragment_ids: set[int] index_version: int created_at: Optional[datetime] + size_bytes: Optional[int] def __repr__(self) -> str: ... @@ -69,5 +70,6 @@ class IndexDescription: field_names: list[str] segments: list[IndexSegmentDescription] details: dict + total_size_bytes: Optional[int] def __repr__(self) -> str: ... diff --git a/python/python/tests/test_commit_index.py b/python/python/tests/test_commit_index.py index f7471d39175..5505c248c00 100644 --- a/python/python/tests/test_commit_index.py +++ b/python/python/tests/test_commit_index.py @@ -105,3 +105,77 @@ def test_commit_index(dataset_with_index, test_table, tmp_path): ) plan = scanner.explain_plan() assert "ScalarIndexQuery: query=[meta = hello]@meta_idx" in plan + + +def test_commit_index_with_files(dataset_with_index, test_table, tmp_path): + """Test that the files field on Index round-trips through commit.""" + from lance.dataset import Index, IndexFile + + # Get info about the existing index created by the fixture + original_desc = dataset_with_index.describe_indices()[0] + index_id = original_desc.segments[0].uuid + + # Verify the original index has file sizes + original_size = original_desc.total_size_bytes + assert original_size is not None and original_size > 0 + + # Create a new dataset without index + dataset_without_index = lance.write_dataset( + test_table, tmp_path / "dataset_without_index" + ) + + # Copy the index files from dataset_with_index to dataset_without_index + src_index_dir = Path(dataset_with_index.uri) / "_indices" / index_id + dest_index_dir = Path(dataset_without_index.uri) / "_indices" / index_id + shutil.copytree(src_index_dir, dest_index_dir) + + # Get the field id + field_id = _get_field_id_by_name(dataset_without_index.lance_schema, "meta") + + # Create IndexFile objects with custom sizes to verify they round-trip + index_files = [ + IndexFile(path="index.idx", size_bytes=1024), + IndexFile(path="auxiliary.bin", size_bytes=2048), + ] + + # Create an Index object with the files field + index = Index( + uuid=index_id, + name="meta_idx", + fields=[field_id], + dataset_version=dataset_without_index.version, + fragment_ids=set( + [f.fragment_id for f in dataset_without_index.get_fragments()] + ), + index_version=0, + files=index_files, + ) + + create_index_op = lance.LanceOperation.CreateIndex( + new_indices=[index], + removed_indices=[], + ) + dataset_without_index = lance.LanceDataset.commit( + dataset_without_index.uri, + create_index_op, + read_version=dataset_without_index.version, + ) + + # Read back the transaction to verify the files were stored + transactions = dataset_without_index.get_transactions(1) + assert len(transactions) == 1 + transaction = transactions[0] + assert transaction is not None + assert transaction.operation is not None + + # The operation should be a CreateIndex with our index that has files + op = transaction.operation + assert len(op.new_indices) == 1 + committed_index = op.new_indices[0] + assert committed_index.files is not None + assert len(committed_index.files) == 2 + + # Verify the file sizes match what we set + files_by_path = {f.path: f.size_bytes for f in committed_index.files} + assert files_by_path["index.idx"] == 1024 + assert files_by_path["auxiliary.bin"] == 2048 diff --git a/python/python/tests/test_scalar_index.py b/python/python/tests/test_scalar_index.py index d00cc7f0533..24b32e54d81 100644 --- a/python/python/tests/test_scalar_index.py +++ b/python/python/tests/test_scalar_index.py @@ -4294,6 +4294,10 @@ def test_describe_indices(tmp_path): assert indices[0].segments[0].index_version == 1 assert indices[0].segments[0].created_at is not None assert isinstance(indices[0].segments[0].created_at, datetime) + assert indices[0].segments[0].size_bytes is not None + assert indices[0].segments[0].size_bytes > 0 + assert indices[0].total_size_bytes is not None + assert indices[0].total_size_bytes > 0 details = indices[0].details assert details is not None and len(details) > 0 @@ -4374,6 +4378,10 @@ def test_describe_indices(tmp_path): assert indices[i].segments[0].index_version == 0 assert indices[i].segments[0].created_at is not None assert isinstance(indices[i].segments[0].created_at, datetime) + assert indices[i].segments[0].size_bytes is not None + assert indices[i].segments[0].size_bytes > 0 + assert indices[i].total_size_bytes is not None + assert indices[i].total_size_bytes > 0 assert indices[i].details == json.loads(details[i]) ds.delete("id < 50") diff --git a/python/src/indices.rs b/python/src/indices.rs index 6b8d9e6c001..7428bf0680d 100644 --- a/python/src/indices.rs +++ b/python/src/indices.rs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use std::collections::HashSet; +use std::fmt::Write; use std::sync::Arc; use arrow::pyarrow::{PyArrowType, ToPyArrow}; @@ -484,17 +485,21 @@ pub struct PyIndexSegmentDescription { pub index_version: i32, /// The timestamp when the index segment was created pub created_at: Option>, + /// The total size in bytes of all files in this segment + /// (None for backward compatibility with indices created before file tracking) + pub size_bytes: Option, } impl PyIndexSegmentDescription { pub fn __repr__(&self) -> String { format!( - "IndexSegmentDescription(uuid={}, dataset_version_at_last_update={}, fragment_ids={:?}, index_version={}, created_at={:?})", + "IndexSegmentDescription(uuid={}, dataset_version_at_last_update={}, fragment_ids={:?}, index_version={}, created_at={:?}, size_bytes={:?})", self.uuid, self.dataset_version_at_last_update, self.fragment_ids, self.index_version, - self.created_at + self.created_at, + self.size_bytes ) } } @@ -517,6 +522,9 @@ pub struct PyIndexDescription { pub details: PyJson, /// The segments of the index pub segments: Vec, + /// The total size in bytes of all files across all segments + /// (None for backward compatibility with indices created before file tracking) + pub total_size_bytes: Option, } impl PyIndexDescription { @@ -542,12 +550,14 @@ impl PyIndexDescription { .as_ref() .map(|bitmap| bitmap.iter().collect::>()) .unwrap_or_default(); + let size_bytes = segment.total_size_bytes(); PyIndexSegmentDescription { uuid: segment.uuid.to_string(), dataset_version_at_last_update: segment.dataset_version, fragment_ids, index_version: segment.index_version, created_at: segment.created_at, + size_bytes, } }) .collect(); @@ -563,6 +573,7 @@ impl PyIndexDescription { type_url: index.type_url().to_string(), num_rows_indexed: index.rows_indexed(), details: PyJson(details), + total_size_bytes: index.total_size_bytes(), } } } @@ -570,15 +581,20 @@ impl PyIndexDescription { #[pymethods] impl PyIndexDescription { pub fn __repr__(&self) -> String { - format!( - "IndexDescription(name={}, type_url={}, num_rows_indexed={}, fields={:?}, field_names={:?}, num_segments={})", + let mut repr = format!( + "IndexDescription(name='{}', type_url='{}', num_rows_indexed={}, fields={:?}, field_names={:?}, num_segments={}", self.name, self.type_url, self.num_rows_indexed, self.fields, self.field_names, self.segments.len() - ) + ); + if let Some(byte_size) = self.total_size_bytes { + write!(repr, ", total_size_bytes={}", byte_size).unwrap(); + } + repr.push(')'); + repr } } diff --git a/python/src/transaction.rs b/python/src/transaction.rs index 1bd0123b405..1c29ec416c0 100644 --- a/python/src/transaction.rs +++ b/python/src/transaction.rs @@ -11,7 +11,7 @@ use lance::dataset::transaction::{ UpdateMapEntry, UpdateMode, }; use lance::datatypes::Schema; -use lance_table::format::{BasePath, DataFile, Fragment, IndexMetadata}; +use lance_table::format::{BasePath, DataFile, Fragment, IndexFile, IndexMetadata}; use pyo3::exceptions::PyValueError; use pyo3::types::PySet; use pyo3::{Bound, FromPyObject, PyAny, PyResult, Python}; @@ -21,7 +21,43 @@ use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; -// Add Index bindings +// IndexFile bindings +impl FromPyObject<'_> for PyLance { + fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { + let path = ob.getattr("path")?.extract()?; + let size_bytes = ob.getattr("size_bytes")?.extract()?; + Ok(Self(IndexFile { path, size_bytes })) + } +} + +impl<'py> IntoPyObject<'py> for PyLance<&IndexFile> { + type Target = PyAny; + type Output = Bound<'py, Self::Target>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + let namespace = py + .import(intern!(py, "lance")) + .expect("Failed to import lance module"); + + let cls = namespace + .getattr("IndexFile") + .expect("Failed to get IndexFile class"); + cls.call1((self.0.path.clone(), self.0.size_bytes)) + } +} + +impl<'py> IntoPyObject<'py> for PyLance { + type Target = PyAny; + type Output = Bound<'py, Self::Target>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + PyLance(&self.0).into_pyobject(py) + } +} + +// IndexMetadata bindings impl FromPyObject<'_> for PyLance { fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { let uuid = ob.getattr("uuid")?.to_string(); @@ -44,6 +80,11 @@ impl FromPyObject<'_> for PyLance { .extract::>()? .map(|id| id as u32); + let files: Option> = ob + .getattr("files")? + .extract::>>>()? + .map(|v| v.into_iter().map(|f| f.0).collect()); + Ok(Self(IndexMetadata { uuid: Uuid::parse_str(&uuid).map_err(|e| PyValueError::new_err(e.to_string()))?, name, @@ -54,6 +95,7 @@ impl FromPyObject<'_> for PyLance { index_version, created_at, base_id, + files, })) } } @@ -85,6 +127,12 @@ impl<'py> IntoPyObject<'py> for PyLance<&IndexMetadata> { ); let created_at = self.0.created_at; let base_id = self.0.base_id.map(|id| id as i64); + let files = self + .0 + .files + .as_ref() + .map(|f| export_vec(py, f.as_slice())) + .transpose()?; let cls = namespace .getattr("Index") @@ -98,6 +146,7 @@ impl<'py> IntoPyObject<'py> for PyLance<&IndexMetadata> { index_version, created_at, base_id, + files, )) } } @@ -639,6 +688,7 @@ impl FromPyObject<'_> for PyLance { value: new_details_value, }, new_index_version, + new_index_files: None, })) } } diff --git a/rust/lance-index/src/scalar.rs b/rust/lance-index/src/scalar.rs index 753f1445d76..a8abdee7a40 100644 --- a/rust/lance-index/src/scalar.rs +++ b/rust/lance-index/src/scalar.rs @@ -27,6 +27,7 @@ use serde::Serialize; use crate::metrics::MetricsCollector; use crate::scalar::registry::TrainingCriteria; use crate::{Index, IndexParams, IndexType}; +pub use lance_table::format::IndexFile; pub mod bitmap; pub mod bloomfilter; @@ -229,6 +230,12 @@ pub trait IndexStore: std::fmt::Debug + Send + Sync + DeepSizeOf { /// Delete an index file (used in the tmp spill store to keep tmp size down) async fn delete_index_file(&self, name: &str) -> Result<()>; + + /// List all files in the index directory with their sizes. + /// + /// Returns a list of (relative_path, size_bytes) tuples. + /// Used to capture file metadata after index creation/modification. + async fn list_files_with_sizes(&self) -> Result>; } /// Different scalar indices may support different kinds of queries @@ -787,6 +794,11 @@ pub struct CreatedIndex { /// /// This can be used to determine if a reader is able to load the index. pub index_version: u32, + /// List of files and their sizes for this index + /// + /// This enables skipping HEAD calls when opening indices and provides + /// visibility into index storage size via describe_indices(). + pub files: Option>, } /// The criteria that specifies how to update an index diff --git a/rust/lance-index/src/scalar/bitmap.rs b/rust/lance-index/src/scalar/bitmap.rs index 86931d8a64f..bf2b47aabe9 100644 --- a/rust/lance-index/src/scalar/bitmap.rs +++ b/rust/lance-index/src/scalar/bitmap.rs @@ -586,6 +586,7 @@ impl ScalarIndex for BitmapIndex { index_details: prost_types::Any::from_msg(&pbold::BitmapIndexDetails::default()) .unwrap(), index_version: BITMAP_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -616,6 +617,7 @@ impl ScalarIndex for BitmapIndex { index_details: prost_types::Any::from_msg(&pbold::BitmapIndexDetails::default()) .unwrap(), index_version: BITMAP_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -805,6 +807,7 @@ impl ScalarIndexPlugin for BitmapIndexPlugin { index_details: prost_types::Any::from_msg(&pbold::BitmapIndexDetails::default()) .unwrap(), index_version: BITMAP_INDEX_VERSION, + files: Some(index_store.list_files_with_sizes().await?), }) } diff --git a/rust/lance-index/src/scalar/bloomfilter.rs b/rust/lance-index/src/scalar/bloomfilter.rs index b72c8806fc4..7fcaa3aad82 100644 --- a/rust/lance-index/src/scalar/bloomfilter.rs +++ b/rust/lance-index/src/scalar/bloomfilter.rs @@ -464,6 +464,7 @@ impl ScalarIndex for BloomFilterIndex { index_details: prost_types::Any::from_msg(&pb::BloomFilterIndexDetails::default()) .unwrap(), index_version: BLOOMFILTER_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -1070,6 +1071,7 @@ impl ScalarIndexPlugin for BloomFilterIndexPlugin { index_details: prost_types::Any::from_msg(&pb::BloomFilterIndexDetails::default()) .unwrap(), index_version: BLOOMFILTER_INDEX_VERSION, + files: Some(index_store.list_files_with_sizes().await?), }) } diff --git a/rust/lance-index/src/scalar/btree.rs b/rust/lance-index/src/scalar/btree.rs index 9930c96793e..99bee0b6842 100644 --- a/rust/lance-index/src/scalar/btree.rs +++ b/rust/lance-index/src/scalar/btree.rs @@ -1628,6 +1628,7 @@ impl ScalarIndex for BTreeIndex { index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default()) .unwrap(), index_version: BTREE_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -1648,6 +1649,7 @@ impl ScalarIndex for BTreeIndex { index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default()) .unwrap(), index_version: BTREE_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -2586,6 +2588,7 @@ impl ScalarIndexPlugin for BTreeIndexPlugin { index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default()) .unwrap(), index_version: BTREE_INDEX_VERSION, + files: Some(index_store.list_files_with_sizes().await?), }) } diff --git a/rust/lance-index/src/scalar/inverted.rs b/rust/lance-index/src/scalar/inverted.rs index 313db208039..ba02546b8a9 100644 --- a/rust/lance-index/src/scalar/inverted.rs +++ b/rust/lance-index/src/scalar/inverted.rs @@ -69,6 +69,7 @@ impl InvertedIndexPlugin { Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&details).unwrap(), index_version: INVERTED_INDEX_VERSION, + files: Some(index_store.list_files_with_sizes().await?), }) } diff --git a/rust/lance-index/src/scalar/inverted/builder.rs b/rust/lance-index/src/scalar/inverted/builder.rs index b7cbf0b2033..764ac65345e 100644 --- a/rust/lance-index/src/scalar/inverted/builder.rs +++ b/rust/lance-index/src/scalar/inverted/builder.rs @@ -1484,7 +1484,7 @@ mod tests { use super::*; use crate::metrics::NoOpMetricsCollector; use crate::progress::IndexBuildProgress; - use crate::scalar::{IndexReader, IndexWriter}; + use crate::scalar::{IndexFile, IndexReader, IndexWriter}; use arrow_array::{RecordBatch, StringArray, UInt64Array}; use arrow_schema::{DataType, Field, Schema}; use async_trait::async_trait; @@ -1594,6 +1594,10 @@ mod tests { "CountingStore does not support deleting", )) } + + async fn list_files_with_sizes(&self) -> Result> { + Ok(vec![]) + } } #[tokio::test] diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index 53ef5fc6a66..7c1662edcb7 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -623,6 +623,7 @@ impl ScalarIndex for InvertedIndex { Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&details).unwrap(), index_version, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -645,6 +646,7 @@ impl ScalarIndex for InvertedIndex { Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&details).unwrap(), index_version, + files: Some(dest_store.list_files_with_sizes().await?), }) } diff --git a/rust/lance-index/src/scalar/json.rs b/rust/lance-index/src/scalar/json.rs index 9025ab05c3e..63465547c8f 100644 --- a/rust/lance-index/src/scalar/json.rs +++ b/rust/lance-index/src/scalar/json.rs @@ -130,6 +130,7 @@ impl ScalarIndex for JsonIndex { index_details: prost_types::Any::from_msg(&json_details)?, // TODO: We should store the target index version in the details index_version: JSON_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -151,6 +152,7 @@ impl ScalarIndex for JsonIndex { index_details: prost_types::Any::from_msg(&json_details)?, // TODO: We should store the target index version in the details index_version: JSON_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -779,6 +781,7 @@ impl ScalarIndexPlugin for JsonIndexPlugin { Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&index_details)?, index_version: JSON_INDEX_VERSION, + files: Some(index_store.list_files_with_sizes().await?), }) } diff --git a/rust/lance-index/src/scalar/label_list.rs b/rust/lance-index/src/scalar/label_list.rs index 9bd77dae218..a7a683a6a3d 100644 --- a/rust/lance-index/src/scalar/label_list.rs +++ b/rust/lance-index/src/scalar/label_list.rs @@ -201,6 +201,7 @@ impl ScalarIndex for LabelListIndex { index_details: prost_types::Any::from_msg(&pbold::LabelListIndexDetails::default()) .unwrap(), index_version: LABEL_LIST_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -219,6 +220,7 @@ impl ScalarIndex for LabelListIndex { index_details: prost_types::Any::from_msg(&pbold::LabelListIndexDetails::default()) .unwrap(), index_version: LABEL_LIST_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -449,6 +451,7 @@ impl ScalarIndexPlugin for LabelListIndexPlugin { index_details: prost_types::Any::from_msg(&pbold::LabelListIndexDetails::default()) .unwrap(), index_version: LABEL_LIST_INDEX_VERSION, + files: Some(index_store.list_files_with_sizes().await?), }) } diff --git a/rust/lance-index/src/scalar/lance_format.rs b/rust/lance-index/src/scalar/lance_format.rs index fc499eb7e3f..c09fd4b5914 100644 --- a/rust/lance-index/src/scalar/lance_format.rs +++ b/rust/lance-index/src/scalar/lance_format.rs @@ -22,6 +22,7 @@ use lance_io::scheduler::{ScanScheduler, SchedulerConfig}; use lance_io::utils::CachedFileSize; use lance_io::{ReadBatchParams, object_store::ObjectStore}; use lance_table::format::SelfDescribingFileReader; +use lance_table::format::{IndexFile, list_index_files_with_sizes}; use object_store::path::Path; use std::cmp::min; use std::collections::HashMap; @@ -38,6 +39,9 @@ pub struct LanceIndexStore { index_dir: Path, metadata_cache: Arc, scheduler: Arc, + /// Cached file sizes (filename -> size in bytes) + /// When set, used to avoid HEAD calls when opening files + file_sizes: HashMap, format_version: LanceFileVersion, } @@ -80,9 +84,19 @@ impl LanceIndexStore { index_dir, metadata_cache, scheduler, + file_sizes: HashMap::new(), format_version, } } + + /// Set cached file sizes to avoid HEAD calls when opening files. + /// + /// The map should contain relative paths (e.g., "index.idx") as keys + /// and file sizes in bytes as values. + pub fn with_file_sizes(mut self, file_sizes: HashMap) -> Self { + self.file_sizes = file_sizes; + self + } } #[async_trait] @@ -244,10 +258,13 @@ impl IndexStore for LanceIndexStore { async fn open_index_file(&self, name: &str) -> Result> { let path = self.index_dir.child(name); - let file_scheduler = self - .scheduler - .open_file(&path, &CachedFileSize::unknown()) - .await?; + // Use cached file size if available, otherwise unknown (requires HEAD call) + let cached_size = self + .file_sizes + .get(name) + .map(|&size| CachedFileSize::new(size)) + .unwrap_or_else(CachedFileSize::unknown); + let file_scheduler = self.scheduler.open_file(&path, &cached_size).await?; match current_reader::FileReader::try_open( file_scheduler, None, @@ -317,6 +334,10 @@ impl IndexStore for LanceIndexStore { let path = self.index_dir.child(name); self.object_store.delete(&path).await } + + async fn list_files_with_sizes(&self) -> Result> { + list_index_files_with_sizes(&self.object_store, &self.index_dir).await + } } #[cfg(test)] diff --git a/rust/lance-index/src/scalar/ngram.rs b/rust/lance-index/src/scalar/ngram.rs index ce992af70ab..486efe6e034 100644 --- a/rust/lance-index/src/scalar/ngram.rs +++ b/rust/lance-index/src/scalar/ngram.rs @@ -504,6 +504,7 @@ impl ScalarIndex for NGramIndex { index_details: prost_types::Any::from_msg(&pbold::NGramIndexDetails::default()) .unwrap(), index_version: NGRAM_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -524,6 +525,7 @@ impl ScalarIndex for NGramIndex { index_details: prost_types::Any::from_msg(&pbold::NGramIndexDetails::default()) .unwrap(), index_version: NGRAM_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -1291,6 +1293,7 @@ impl ScalarIndexPlugin for NGramIndexPlugin { index_details: prost_types::Any::from_msg(&pbold::NGramIndexDetails::default()) .unwrap(), index_version: NGRAM_INDEX_VERSION, + files: Some(index_store.list_files_with_sizes().await?), }) } diff --git a/rust/lance-index/src/scalar/rtree.rs b/rust/lance-index/src/scalar/rtree.rs index 598bef43f50..9600c94823b 100644 --- a/rust/lance-index/src/scalar/rtree.rs +++ b/rust/lance-index/src/scalar/rtree.rs @@ -600,6 +600,7 @@ impl ScalarIndex for RTreeIndex { Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&pb::RTreeIndexDetails::default())?, index_version: RTREE_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -964,6 +965,7 @@ impl ScalarIndexPlugin for RTreeIndexPlugin { Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&pb::RTreeIndexDetails::default())?, index_version: RTREE_INDEX_VERSION, + files: Some(index_store.list_files_with_sizes().await?), }) } diff --git a/rust/lance-index/src/scalar/zonemap.rs b/rust/lance-index/src/scalar/zonemap.rs index 8db7c771cac..a8ea616a31b 100644 --- a/rust/lance-index/src/scalar/zonemap.rs +++ b/rust/lance-index/src/scalar/zonemap.rs @@ -557,6 +557,7 @@ impl ScalarIndex for ZoneMapIndex { index_details: prost_types::Any::from_msg(&pbold::ZoneMapIndexDetails::default()) .unwrap(), index_version: ZONEMAP_INDEX_VERSION, + files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -890,6 +891,7 @@ impl ScalarIndexPlugin for ZoneMapIndexPlugin { index_details: prost_types::Any::from_msg(&pbold::ZoneMapIndexDetails::default()) .unwrap(), index_version: ZONEMAP_INDEX_VERSION, + files: Some(index_store.list_files_with_sizes().await?), }) } diff --git a/rust/lance-index/src/traits.rs b/rust/lance-index/src/traits.rs index 305ec6892c5..cb211854df2 100644 --- a/rust/lance-index/src/traits.rs +++ b/rust/lance-index/src/traits.rs @@ -115,6 +115,12 @@ pub trait IndexDescription: Send + Sync { /// plugin. As a result, this method may fail if there is no plugin /// available for the index. fn details(&self) -> Result; + + /// Returns the total size in bytes of all files across all segments. + /// + /// Returns `None` if file size information is not available for any segment + /// (for backward compatibility with indices created before file tracking was added). + fn total_size_bytes(&self) -> Option; } // Extends Lance Dataset with secondary index. diff --git a/rust/lance-table/src/format.rs b/rust/lance-table/src/format.rs index db065199532..9eab63023b0 100644 --- a/rust/lance-table/src/format.rs +++ b/rust/lance-table/src/format.rs @@ -13,7 +13,7 @@ pub use crate::rowids::version::{ RowDatasetVersionMeta, RowDatasetVersionRun, RowDatasetVersionSequence, }; pub use fragment::*; -pub use index::IndexMetadata; +pub use index::{IndexFile, IndexMetadata, list_index_files_with_sizes}; pub use manifest::{ BasePath, DETACHED_VERSION_MASK, DataStorageFormat, Manifest, SelfDescribingFileReader, diff --git a/rust/lance-table/src/format/index.rs b/rust/lance-table/src/format/index.rs index 8db98bcdeba..09cf59ea64c 100644 --- a/rust/lance-table/src/format/index.rs +++ b/rust/lance-table/src/format/index.rs @@ -3,16 +3,29 @@ //! Metadata for index +use std::collections::HashMap; use std::sync::Arc; use chrono::{DateTime, Utc}; use deepsize::DeepSizeOf; +use futures::StreamExt; +use lance_io::object_store::ObjectStore; +use object_store::path::Path; use roaring::RoaringBitmap; use uuid::Uuid; use super::pb; use lance_core::{Error, Result}; +/// Metadata about a single file within an index segment. +#[derive(Debug, Clone, PartialEq, DeepSizeOf)] +pub struct IndexFile { + /// Path relative to the index directory (e.g., "index.idx", "auxiliary.idx") + pub path: String, + /// Size of the file in bytes + pub size_bytes: u64, +} + /// Index metadata #[derive(Debug, Clone, PartialEq)] pub struct IndexMetadata { @@ -56,6 +69,13 @@ pub struct IndexMetadata { /// The base path index of the index files. Used when the index is imported or referred from another dataset. /// Lance uses it as key of the base_paths field in Manifest to determine the actual base path of the index files. pub base_id: Option, + + /// List of files and their sizes for this index segment. + /// This enables skipping HEAD calls when opening indices and provides + /// visibility into index storage size via describe_indices(). + /// This is None if the file sizes are unknown. This happens for indices created + /// before this field was added. + pub files: Option>, } impl IndexMetadata { @@ -66,6 +86,28 @@ impl IndexMetadata { let fragment_bitmap = self.fragment_bitmap.as_ref()?; Some(fragment_bitmap & existing_fragments) } + + /// Returns a map of relative file paths to their sizes. + /// Returns an empty map if file information is not available. + pub fn file_size_map(&self) -> HashMap { + self.files + .as_ref() + .map(|files| { + files + .iter() + .map(|f| (f.path.clone(), f.size_bytes)) + .collect() + }) + .unwrap_or_default() + } + + /// Returns the total size of all files in this index segment in bytes. + /// Returns None if file information is not available. + pub fn total_size_bytes(&self) -> Option { + self.files + .as_ref() + .map(|files| files.iter().map(|f| f.size_bytes).sum()) + } } impl DeepSizeOf for IndexMetadata { @@ -79,6 +121,7 @@ impl DeepSizeOf for IndexMetadata { .as_ref() .map(|fragment_bitmap| fragment_bitmap.serialized_size()) .unwrap_or(0) + + self.files.deep_size_of_children(context) } } @@ -94,6 +137,21 @@ impl TryFrom for IndexMetadata { )?) }; + let files = if proto.files.is_empty() { + None + } else { + Some( + proto + .files + .into_iter() + .map(|f| IndexFile { + path: f.path, + size_bytes: f.size_bytes, + }) + .collect(), + ) + }; + Ok(Self { uuid: proto.uuid.as_ref().map(Uuid::try_from).ok_or_else(|| { Error::invalid_input("uuid field does not exist in Index metadata".to_string()) @@ -109,6 +167,7 @@ impl TryFrom for IndexMetadata { .expect("Invalid timestamp in index metadata") }), base_id: proto.base_id, + files, }) } } @@ -125,6 +184,20 @@ impl From<&IndexMetadata> for pb::IndexMetadata { fragment_bitmap.clear(); } + let files = idx + .files + .as_ref() + .map(|files| { + files + .iter() + .map(|f| pb::IndexFile { + path: f.path.clone(), + size_bytes: f.size_bytes, + }) + .collect() + }) + .unwrap_or_default(); + Self { uuid: Some((&idx.uuid).into()), name: idx.name.clone(), @@ -138,6 +211,34 @@ impl From<&IndexMetadata> for pb::IndexMetadata { index_version: Some(idx.index_version), created_at: idx.created_at.map(|dt| dt.timestamp_millis() as u64), base_id: idx.base_id, + files, } } } + +/// List all files in an index directory with their sizes. +/// +/// Returns a list of `IndexFile` structs containing relative paths and sizes. +/// This is used to capture file metadata after index creation/modification. +pub async fn list_index_files_with_sizes( + object_store: &ObjectStore, + index_dir: &Path, +) -> Result> { + let mut files = Vec::new(); + let mut stream = object_store.read_dir_all(index_dir, None); + while let Some(meta) = stream.next().await { + let meta = meta?; + // Get relative path by stripping the index_dir prefix + let relative_path = meta + .location + .as_ref() + .strip_prefix(index_dir.as_ref()) + .map(|s| s.trim_start_matches('/').to_string()) + .unwrap_or_else(|| meta.location.filename().unwrap_or("").to_string()); + files.push(IndexFile { + path: relative_path, + size_bytes: meta.size, + }); + } + Ok(files) +} diff --git a/rust/lance/src/dataset/index.rs b/rust/lance/src/dataset/index.rs index 77634326568..d4de4d0be6f 100644 --- a/rust/lance/src/dataset/index.rs +++ b/rust/lance/src/dataset/index.rs @@ -105,6 +105,7 @@ impl IndexRemapper for DatasetIndexRemapper { new_id: id, index_details, index_version: index.index_version as u32, + files: index.files.clone(), }); } RemapResult::Remapped(remapped_index) => { @@ -162,11 +163,12 @@ impl LanceIndexStoreExt for LanceIndexStore { .child(index.uuid.to_string()); let cache = dataset.metadata_cache.file_metadata_cache(&index_dir); let format_version = dataset_format_version(dataset); - Ok(Self::with_format_version( + let store = Self::with_format_version( dataset.object_store.clone(), index_dir, Arc::new(cache), format_version, - )) + ); + Ok(store.with_file_sizes(index.file_size_map())) } } diff --git a/rust/lance/src/dataset/mem_wal/memtable/flush.rs b/rust/lance/src/dataset/mem_wal/memtable/flush.rs index eb91e856673..5869f6d2db7 100644 --- a/rust/lance/src/dataset/mem_wal/memtable/flush.rs +++ b/rust/lance/src/dataset/mem_wal/memtable/flush.rs @@ -483,6 +483,7 @@ impl MemTableFlusher { index_version: INVERTED_INDEX_VERSION as i32, created_at: None, base_id: None, + files: None, }; // Commit the index to the dataset @@ -726,6 +727,7 @@ impl MemTableFlusher { base_id: None, created_at: Some(chrono::Utc::now()), index_version: 1, + files: None, }; Ok(index_meta) diff --git a/rust/lance/src/dataset/optimize.rs b/rust/lance/src/dataset/optimize.rs index f950487f785..cdde5268a1d 100644 --- a/rust/lance/src/dataset/optimize.rs +++ b/rust/lance/src/dataset/optimize.rs @@ -1516,6 +1516,7 @@ pub async fn commit_compaction( new_id: rewritten.new_id, new_index_details: rewritten.index_details, new_index_version: rewritten.index_version, + new_index_files: rewritten.files, }) .collect() } else if !options.defer_index_remap && !has_address_style { diff --git a/rust/lance/src/dataset/optimize/remapping.rs b/rust/lance/src/dataset/optimize/remapping.rs index 1f2508beff5..253bd63ff15 100644 --- a/rust/lance/src/dataset/optimize/remapping.rs +++ b/rust/lance/src/dataset/optimize/remapping.rs @@ -13,7 +13,7 @@ use lance_core::Error; use lance_core::utils::address::RowAddress; use lance_index::DatasetIndexExt; use lance_index::frag_reuse::{FRAG_REUSE_INDEX_NAME, FragDigest}; -use lance_table::format::{Fragment, IndexMetadata}; +use lance_table::format::{Fragment, IndexFile, IndexMetadata}; use lance_table::io::manifest::read_manifest_indexes; use roaring::RoaringTreemap; use serde::{Deserialize, Serialize}; @@ -39,6 +39,8 @@ pub struct RemappedIndex { pub new_id: Uuid, pub index_details: prost_types::Any, pub index_version: u32, + /// List of files in the index with their sizes. + pub files: Option>, } /// When compaction runs the row ids will change. This typically means that @@ -283,6 +285,7 @@ async fn remap_index(dataset: &mut Dataset, index_id: &Uuid) -> Result<()> { index_version: curr_index_meta.index_version, created_at: curr_index_meta.created_at, base_id: None, + files: curr_index_meta.files.clone(), }, RemapResult::Remapped(remapped_index) => IndexMetadata { uuid: remapped_index.new_id, @@ -294,6 +297,7 @@ async fn remap_index(dataset: &mut Dataset, index_id: &Uuid) -> Result<()> { index_version: remapped_index.index_version as i32, created_at: curr_index_meta.created_at, base_id: None, + files: remapped_index.files, }, }; diff --git a/rust/lance/src/dataset/tests/dataset_migrations.rs b/rust/lance/src/dataset/tests/dataset_migrations.rs index f205c880416..ecbffa717ba 100644 --- a/rust/lance/src/dataset/tests/dataset_migrations.rs +++ b/rust/lance/src/dataset/tests/dataset_migrations.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use std::vec; +use crate::dataset::InsertBuilder; use crate::dataset::optimize::{CompactionOptions, compact_files}; use crate::utils::test::copy_test_data_to_tmp; use crate::{Dataset, Result}; @@ -376,6 +377,107 @@ async fn test_max_fragment_id_migration() { } } +#[tokio::test] +async fn test_index_without_file_sizes() { + // Test that we can open indices created before the `files` field was added + // to IndexMetadata. The index should still work correctly, falling back to + // HEAD calls for file sizes. + + let test_dir = copy_test_data_to_tmp("pre_file_sizes/index_without_file_sizes").unwrap(); + let test_uri = test_dir.path_str(); + + // Open the dataset + let dataset = Dataset::open(&test_uri).await.unwrap(); + + // Verify the index exists and has no file size info + let indices = dataset.load_indices().await.unwrap(); + assert_eq!(indices.len(), 1); + let index = &indices[0]; + assert_eq!(index.name, "values_idx"); + assert!( + index.files.is_none() || index.files.as_ref().unwrap().is_empty(), + "Index should not have file size info (created with old version)" + ); + + // Verify the index still works - scan with a filter that uses the index + let batch = dataset + .scan() + .filter("values = 'value_42'") + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!(batch.num_rows(), 1); + + // Verify describe_indices returns None for total_size_bytes for old indices + let descriptions = dataset.describe_indices(None).await.unwrap(); + assert_eq!(descriptions.len(), 1); + assert!( + descriptions[0].total_size_bytes().is_none(), + "Old index without file sizes should return None for total_size_bytes" + ); +} + +#[tokio::test] +async fn test_index_file_size_migration() { + // Test that file sizes are migrated when a write operation is performed + // on a dataset with an index missing file sizes. + + let test_dir = copy_test_data_to_tmp("pre_file_sizes/index_without_file_sizes").unwrap(); + let test_uri = test_dir.path_str(); + + // Open the dataset and verify the index has no file sizes + let dataset = Dataset::open(&test_uri).await.unwrap(); + let indices = dataset.load_indices().await.unwrap(); + assert!( + indices[0].files.is_none() || indices[0].files.as_ref().unwrap().is_empty(), + "Index should not have file size info before migration" + ); + + // Perform a write operation (append) to trigger migration + let batch = arrow_array::record_batch!( + ("id", Int64, [100, 101]), + ("values", Utf8, ["value_100", "value_101"]) + ) + .unwrap(); + let dataset = InsertBuilder::new(Arc::new(dataset)) + .with_params(&WriteParams { + mode: WriteMode::Append, + ..Default::default() + }) + .execute(vec![batch]) + .await + .unwrap(); + + // Verify the index now has file sizes after migration + let indices = dataset.load_indices().await.unwrap(); + let index = &indices[0]; + assert!( + index.files.is_some() && !index.files.as_ref().unwrap().is_empty(), + "Index should have file size info after migration" + ); + + // Verify each file has a positive size + for file in index.files.as_ref().unwrap() { + assert!( + file.size_bytes > 0, + "File {} should have positive size after migration", + file.path + ); + } + + // Verify describe_indices now returns total_size_bytes + let descriptions = dataset.describe_indices(None).await.unwrap(); + assert!( + descriptions[0].total_size_bytes().is_some(), + "Index should have total_size_bytes after migration" + ); + assert!( + descriptions[0].total_size_bytes().unwrap() > 0, + "Total size should be positive after migration" + ); +} + /// Regression test for issue #5702: project_by_schema should reorder fields inside List. /// /// This test reads a dataset with: diff --git a/rust/lance/src/dataset/transaction.rs b/rust/lance/src/dataset/transaction.rs index 689f63c659b..574955b216b 100644 --- a/rust/lance/src/dataset/transaction.rs +++ b/rust/lance/src/dataset/transaction.rs @@ -27,7 +27,8 @@ use lance_table::feature_flags::{FLAG_STABLE_ROW_IDS, apply_feature_flags}; use lance_table::rowids::read_row_ids; use lance_table::{ format::{ - BasePath, DataFile, DataStorageFormat, Fragment, IndexMetadata, Manifest, RowIdMeta, pb, + BasePath, DataFile, DataStorageFormat, Fragment, IndexFile, IndexMetadata, Manifest, + RowIdMeta, pb, }, io::{ commit::CommitHandler, @@ -1137,6 +1138,9 @@ pub struct RewrittenIndex { pub new_id: Uuid, pub new_index_details: prost_types::Any, pub new_index_version: u32, + /// Files in the new index with their sizes. + /// Empty list from older writers that didn't persist this field. + pub new_index_files: Option>, } impl DeepSizeOf for RewrittenIndex { @@ -2516,6 +2520,10 @@ impl Transaction { groups, )?); index.uuid = rewritten_index.new_id; + // Update file sizes to match the new index files. When not available + // (e.g., from older writers), clear the old file sizes to avoid + // using stale sizes from the pre-remap index. + index.files = rewritten_index.new_index_files.clone(); } Ok(()) } @@ -3031,6 +3039,20 @@ impl TryFrom<&pb::transaction::rewrite::RewrittenIndex> for RewrittenIndex { })? .clone(), new_index_version: message.new_index_version, + new_index_files: if message.new_index_files.is_empty() { + None + } else { + Some( + message + .new_index_files + .iter() + .map(|f| IndexFile { + path: f.path.clone(), + size_bytes: f.size_bytes, + }) + .collect(), + ) + }, }) } } @@ -3264,6 +3286,19 @@ impl From<&RewrittenIndex> for pb::transaction::rewrite::RewrittenIndex { new_id: Some((&value.new_id).into()), new_index_details: Some(value.new_index_details.clone()), new_index_version: value.new_index_version, + new_index_files: value + .new_index_files + .as_ref() + .map(|files| { + files + .iter() + .map(|f| pb::IndexFile { + path: f.path.clone(), + size_bytes: f.size_bytes, + }) + .collect() + }) + .unwrap_or_default(), } } } @@ -3473,6 +3508,7 @@ mod tests { index_version: 1, created_at: Some(Utc::now()), base_id: None, + files: None, } } @@ -3979,6 +4015,7 @@ mod tests { index_version: 1, created_at: None, base_id: None, + files: None, } } @@ -4000,6 +4037,7 @@ mod tests { index_version: 1, created_at: None, base_id: None, + files: None, } } @@ -4411,6 +4449,7 @@ mod tests { value: vec![], }, new_index_version: 1, + new_index_files: None, }]; // Should succeed (skip missing index) instead of error diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index 8c1b2404a42..9ae326ad7e6 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -54,8 +54,8 @@ use lance_io::utils::{ CachedFileSize, read_last_block, read_message, read_message_from_buf, read_metadata_offset, read_version, }; -use lance_table::format::IndexMetadata; use lance_table::format::{Fragment, SelfDescribingFileReader}; +use lance_table::format::{IndexMetadata, list_index_files_with_sizes}; use lance_table::io::manifest::read_manifest_indexes; use roaring::RoaringBitmap; use scalar::index_matches_criteria; @@ -360,12 +360,18 @@ pub(crate) async fn remap_index( row_id_map, ) .await?; + + // Capture file sizes for the vector index + let index_dir = dataset.indices_dir().child(new_id.to_string()); + let files = list_index_files_with_sizes(&dataset.object_store, &index_dir).await?; + CreatedIndex { index_details: prost_types::Any::from_msg( &lance_table::format::pb::VectorIndexDetails::default(), ) .unwrap(), index_version, + files: Some(files), } } _ => { @@ -381,6 +387,7 @@ pub(crate) async fn remap_index( new_id, index_details: created_index.index_details, index_version: created_index.index_version, + files: created_index.files, })) } @@ -558,6 +565,18 @@ impl IndexDescription for IndexDescriptionImpl { .details_as_json(&self.details.0) .map(|v| v.to_string()) } + + fn total_size_bytes(&self) -> Option { + let mut total = 0u64; + for segment in &self.segments { + // If any segment is missing file info, return None for backward compatibility + let files = segment.files.as_ref()?; + for file in files { + total += file.size_bytes; + } + } + Some(total) + } } #[async_trait] @@ -780,6 +799,7 @@ impl DatasetIndexExt for Dataset { index_version: 0, created_at: Some(chrono::Utc::now()), base_id: None, // New indices don't have base_id (they're not from shallow clone) + files: None, // File info will be populated when index is created }; let transaction = Transaction::new( @@ -899,7 +919,8 @@ impl DatasetIndexExt for Dataset { index_details: Some(Arc::new(res.new_index_details)), index_version: res.new_index_version, created_at: Some(chrono::Utc::now()), - base_id: None, // Mew merged index file locates in the cloned dataset. + base_id: None, // New merged index file locates in the cloned dataset. + files: res.files, }; removed_indices.extend(res.removed_indices.iter().map(|&idx| idx.clone())); new_indices.push(new_idx); @@ -1304,18 +1325,26 @@ impl DatasetIndexInternalExt for Dataset { // Sometimes we want to open an index and we don't care if it is a scalar or vector index. // For example, we might want to get statistics for an index, regardless of type. // - // Currently, we solve this problem by checking for the existence of INDEX_FILE_NAME since - // only vector indices have this file. In the future, once we support multiple kinds of - // scalar indices, we may start having this file with scalar indices too. Once that happens - // we can just read this file and look at the `implementation` or `index_type` fields to - // determine what kind of index it is. + // We determine if this is a vector index by checking if INDEX_FILE_NAME exists in the + // file list (available since file sizes tracking was added). If the file list is not + // available (older indices), we fall back to checking file existence via HEAD request. let index_meta = self .load_index(uuid) .await? .ok_or_else(|| Error::index(format!("Index with id {} does not exist", uuid)))?; - let index_dir = self.indice_files_dir(&index_meta)?; - let index_file = index_dir.child(uuid).child(INDEX_FILE_NAME); - if self.object_store.exists(&index_file).await? { + + // Check if this is a vector index by looking at the files list + let is_vector_index = if let Some(files) = &index_meta.files { + // If we have file metadata, check if INDEX_FILE_NAME is in the list + files.iter().any(|f| f.path == INDEX_FILE_NAME) + } else { + // Fall back to file existence check for older indices without file metadata + let index_dir = self.indice_files_dir(&index_meta)?; + let index_file = index_dir.child(uuid).child(INDEX_FILE_NAME); + self.object_store.exists(&index_file).await? + }; + + if is_vector_index { let index = self.open_vector_index(column, uuid, metrics).await?; Ok(index.as_index()) } else { @@ -1428,9 +1457,12 @@ impl DatasetIndexInternalExt for Dataset { self.object_store.clone(), SchedulerConfig::max_bandwidth(&self.object_store), ); - let file = scheduler - .open_file(&index_file, &CachedFileSize::unknown()) - .await?; + let file_sizes = index_meta.file_size_map(); + let cached_size = file_sizes + .get(INDEX_FILE_NAME) + .map(|&size| CachedFileSize::new(size)) + .unwrap_or_else(CachedFileSize::unknown); + let file = scheduler.open_file(&index_file, &cached_size).await?; let reader = lance_file::reader::FileReader::try_open( file, None, @@ -1464,6 +1496,7 @@ impl DatasetIndexInternalExt for Dataset { frag_reuse_index, self.metadata_cache.as_ref(), index_cache, + file_sizes, ) .await?; Ok(Arc::new(ivf) as Arc) @@ -1476,6 +1509,7 @@ impl DatasetIndexInternalExt for Dataset { frag_reuse_index, self.metadata_cache.as_ref(), index_cache, + file_sizes, ) .await?; Ok(Arc::new(ivf) as Arc) @@ -1494,6 +1528,7 @@ impl DatasetIndexInternalExt for Dataset { frag_reuse_index, self.metadata_cache.as_ref(), index_cache, + file_sizes, ) .await?; Ok(Arc::new(ivf) as Arc) @@ -1507,6 +1542,7 @@ impl DatasetIndexInternalExt for Dataset { frag_reuse_index, self.metadata_cache.as_ref(), index_cache, + file_sizes, ) .await?; Ok(Arc::new(ivf) as Arc) @@ -1520,6 +1556,7 @@ impl DatasetIndexInternalExt for Dataset { frag_reuse_index, self.metadata_cache.as_ref(), index_cache, + file_sizes, ) .await?; Ok(Arc::new(ivf) as Arc) @@ -1536,6 +1573,7 @@ impl DatasetIndexInternalExt for Dataset { frag_reuse_index, &file_metadata_cache, index_cache, + file_sizes, ) .await?; Ok(Arc::new(ivf) as Arc) @@ -1549,6 +1587,7 @@ impl DatasetIndexInternalExt for Dataset { frag_reuse_index, self.metadata_cache.as_ref(), index_cache, + file_sizes, ) .await?; Ok(Arc::new(ivf) as Arc) @@ -1562,6 +1601,7 @@ impl DatasetIndexInternalExt for Dataset { frag_reuse_index, self.metadata_cache.as_ref(), index_cache, + file_sizes, ) .await?; Ok(Arc::new(ivf) as Arc) @@ -1961,7 +2001,7 @@ mod tests { use lance_arrow::*; use lance_core::utils::tempfile::TempStrDir; use lance_datagen::gen_batch; - use lance_datagen::{BatchCount, Dimension, RowCount, array}; + use lance_datagen::{BatchCount, ByteCount, Dimension, RowCount, array}; use lance_index::scalar::bitmap::BITMAP_LOOKUP_NAME; use lance_index::scalar::{ BuiltinIndexType, FullTextSearchQuery, InvertedIndexParams, ScalarIndexParams, @@ -5270,6 +5310,634 @@ mod tests { ); } + #[tokio::test] + async fn test_scalar_index_file_sizes_captured() { + // Test that file sizes are captured when creating a scalar index + let reader = gen_batch() + .col("id", array::step::()) + .col("values", array::rand_utf8(ByteCount::from(10), false)) + .into_reader_rows(RowCount::from(4), BatchCount::from(1)); + + let mut dataset = Dataset::write(reader, "memory://", None).await.unwrap(); + + // Create a scalar index + dataset + .create_index( + &["values"], + IndexType::Scalar, + Some("test_idx".to_string()), + &ScalarIndexParams::default(), + false, + ) + .await + .unwrap(); + + // Get index metadata and verify files are populated + let indices = dataset.load_indices().await.unwrap(); + let test_index = indices.iter().find(|idx| idx.name == "test_idx").unwrap(); + + assert!( + test_index.files.is_some(), + "Index should have files populated" + ); + let files = test_index.files.as_ref().unwrap(); + assert!(!files.is_empty(), "Index should have at least one file"); + + // Verify each file has a positive size + for file in files { + assert!( + file.size_bytes > 0, + "File {} should have positive size", + file.path + ); + } + + // Verify total_size_bytes works + let total_size = test_index.total_size_bytes(); + assert!(total_size.is_some(), "total_size_bytes should return Some"); + assert!(total_size.unwrap() > 0, "Total size should be positive"); + } + + #[tokio::test] + async fn test_vector_index_file_sizes_captured() { + // Test that file sizes are captured when creating a vector index + let reader = gen_batch() + .col("id", array::step::()) + .col( + "vector", + array::rand_vec::(4.into()), + ) + .into_reader_rows(RowCount::from(300), BatchCount::from(1)); + + let mut dataset = Dataset::write(reader, "memory://", None).await.unwrap(); + + // Create vector index + let params = VectorIndexParams::ivf_pq(1, 8, 2, MetricType::L2, 2); + dataset + .create_index( + &["vector"], + IndexType::Vector, + Some("test_vec_idx".to_string()), + ¶ms, + false, + ) + .await + .unwrap(); + + // Get index metadata and verify files are populated + let indices = dataset.load_indices().await.unwrap(); + let test_index = indices + .iter() + .find(|idx| idx.name == "test_vec_idx") + .unwrap(); + + assert!( + test_index.files.is_some(), + "Index should have files populated" + ); + let files = test_index.files.as_ref().unwrap(); + assert!(!files.is_empty(), "Index should have at least one file"); + + // Verify each file has a positive size + for file in files { + assert!( + file.size_bytes > 0, + "File {} should have positive size", + file.path + ); + } + + // Verify total_size_bytes works + let total_size = test_index.total_size_bytes(); + assert!(total_size.is_some(), "total_size_bytes should return Some"); + assert!(total_size.unwrap() > 0, "Total size should be positive"); + } + + #[tokio::test] + async fn test_describe_indices_total_size() { + // Test that describe_indices returns total_size_bytes + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("values", DataType::Utf8, false), + ])); + + let values = StringArray::from_iter_values(["hello", "world", "foo", "bar"]); + let record_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..4)), + Arc::new(values), + ], + ) + .unwrap(); + + let reader = + RecordBatchIterator::new(vec![record_batch].into_iter().map(Ok), schema.clone()); + + let mut dataset = Dataset::write(reader, "memory://", None).await.unwrap(); + + // Create a scalar index + dataset + .create_index( + &["values"], + IndexType::Scalar, + Some("test_idx".to_string()), + &ScalarIndexParams::default(), + false, + ) + .await + .unwrap(); + + // Use describe_indices to get index info + let descriptions = dataset.describe_indices(None).await.unwrap(); + assert_eq!(descriptions.len(), 1); + + let desc = &descriptions[0]; + assert_eq!(desc.name(), "test_idx"); + + // Verify total_size_bytes is available + let total_size = desc.total_size_bytes(); + assert!(total_size.is_some(), "total_size_bytes should be Some"); + assert!(total_size.unwrap() > 0, "Total size should be positive"); + } + + /// Helper to assert that all indices have file sizes populated + async fn assert_all_indices_have_files(dataset: &Dataset, context: &str) { + let indices = dataset.load_indices().await.unwrap(); + for index in indices.iter() { + // Skip system indices (mem_wal, frag_reuse) which don't have files + if index.name == lance_index::mem_wal::MEM_WAL_INDEX_NAME + || index.name == lance_index::frag_reuse::FRAG_REUSE_INDEX_NAME + { + continue; + } + assert!( + index.files.is_some(), + "{}: Index '{}' should have files field populated", + context, + index.name + ); + let files = index.files.as_ref().unwrap(); + assert!( + !files.is_empty(), + "{}: Index '{}' should have at least one file", + context, + index.name + ); + for file in files { + assert!( + file.size_bytes > 0, + "{}: Index '{}' file '{}' should have positive size", + context, + index.name, + file.path + ); + } + } + } + + #[tokio::test] + async fn test_index_file_sizes_through_lifecycle() { + use crate::dataset::WriteDestination; + use crate::dataset::optimize::{CompactionOptions, compact_files, remapping}; + use lance_index::frag_reuse::FRAG_REUSE_INDEX_NAME; + + // Create initial dataset with columns for different index types + let data = gen_batch() + .col("int_col", array::step::()) + .col("str_col", array::rand_utf8(8.into(), false)) + .col( + "vec_col", + array::rand_vec::(Dimension::from(32)), + ) + .into_reader_rows(RowCount::from(1000), BatchCount::from(1)); + + let test_dir = TempStrDir::default(); + let mut dataset = Dataset::write( + data, + test_dir.as_str(), + Some(WriteParams { + max_rows_per_file: 200, // Multiple fragments for compaction + ..Default::default() + }), + ) + .await + .unwrap(); + + // Create BTree index + dataset + .create_index( + &["int_col"], + IndexType::BTree, + Some("btree_idx".to_string()), + &ScalarIndexParams::default(), + false, + ) + .await + .unwrap(); + + // Create Bitmap index + dataset + .create_index( + &["int_col"], + IndexType::Bitmap, + Some("bitmap_idx".to_string()), + &ScalarIndexParams::default(), + false, + ) + .await + .unwrap(); + + // Create Inverted index for text search + dataset + .create_index( + &["str_col"], + IndexType::Inverted, + Some("inverted_idx".to_string()), + &InvertedIndexParams::default(), + false, + ) + .await + .unwrap(); + + // Validate files are populated after creation + assert_all_indices_have_files(&dataset, "after initial creation").await; + + // Append more data + let more_data = gen_batch() + .col("int_col", array::step::()) + .col("str_col", array::rand_utf8(8.into(), false)) + .col( + "vec_col", + array::rand_vec::(Dimension::from(32)), + ) + .into_reader_rows(RowCount::from(500), BatchCount::from(1)); + + Dataset::write( + more_data, + WriteDestination::Dataset(Arc::new(dataset.clone())), + Some(WriteParams { + max_rows_per_file: 200, + mode: WriteMode::Append, + ..Default::default() + }), + ) + .await + .unwrap(); + + dataset = DatasetBuilder::from_uri(test_dir.as_str()) + .load() + .await + .unwrap(); + + // Optimize indices (triggers update/merge) + dataset + .optimize_indices(&OptimizeOptions::default()) + .await + .unwrap(); + + // Validate files are still populated after optimize + assert_all_indices_have_files(&dataset, "after optimize_indices").await; + + // Run compaction with deferred remap + let options = CompactionOptions { + target_rows_per_fragment: 500, + defer_index_remap: true, + ..Default::default() + }; + + compact_files(&mut dataset, options.clone(), None) + .await + .unwrap(); + + // Check if frag reuse index exists (indicates remap is needed) + if dataset + .load_index_by_name(FRAG_REUSE_INDEX_NAME) + .await + .unwrap() + .is_some() + { + // Remap each index + remapping::remap_column_index( + &mut dataset, + &["int_col"], + Some("btree_idx".to_string()), + ) + .await + .unwrap(); + + remapping::remap_column_index( + &mut dataset, + &["int_col"], + Some("bitmap_idx".to_string()), + ) + .await + .unwrap(); + + remapping::remap_column_index( + &mut dataset, + &["str_col"], + Some("inverted_idx".to_string()), + ) + .await + .unwrap(); + + // Validate files are populated after remap + assert_all_indices_have_files(&dataset, "after remap").await; + } + } + + #[tokio::test] + async fn test_btree_index_iops() { + // Test that querying a BTree index uses minimal IOPs (no HEAD requests) + let test_dir = TempStrDir::default(); + + // Create dataset with a column suitable for BTree index + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("value", DataType::Int32, false), + ])); + + let num_rows = 1000; + let ids = Int32Array::from_iter_values(0..num_rows); + let values = Int32Array::from_iter_values((0..num_rows).map(|i| i % 100)); + + let batch = + RecordBatch::try_new(schema.clone(), vec![Arc::new(ids), Arc::new(values)]).unwrap(); + + let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone()); + let mut dataset = Dataset::write(reader, test_dir.as_str(), None) + .await + .unwrap(); + + // Create BTree index + dataset + .create_index( + &["value"], + IndexType::BTree, + Some("btree_idx".to_string()), + &ScalarIndexParams::default(), + true, + ) + .await + .unwrap(); + + // Re-open dataset fresh to avoid cached state + let dataset = DatasetBuilder::from_uri(test_dir.as_str()) + .load() + .await + .unwrap(); + + // Reset IO stats before query + let _ = dataset.object_store().io_stats_incremental(); + + // Query using the BTree index + let results = dataset + .scan() + .filter("value = 50") + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert!(results.num_rows() > 0); + + // Verify IOPs - should be minimal (no HEAD requests) + let stats = dataset.object_store().io_stats_incremental(); + // We expect reads for: index metadata + index pages + data files + // The key assertion is that we don't have extra HEAD requests + assert_io_lt!( + stats, + read_iops, + 10, + "BTree index query should use minimal IOPs" + ); + } + + #[tokio::test] + async fn test_bitmap_index_iops() { + // Test that querying a Bitmap index uses minimal IOPs (no HEAD requests) + let test_dir = TempStrDir::default(); + + // Create dataset with low-cardinality column for Bitmap index + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("category", DataType::Int32, false), + ])); + + let num_rows = 1000; + let ids = Int32Array::from_iter_values(0..num_rows); + // Low cardinality - only 10 unique values + let categories = Int32Array::from_iter_values((0..num_rows).map(|i| i % 10)); + + let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(ids), Arc::new(categories)]) + .unwrap(); + + let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone()); + let mut dataset = Dataset::write(reader, test_dir.as_str(), None) + .await + .unwrap(); + + // Create Bitmap index + dataset + .create_index( + &["category"], + IndexType::Bitmap, + Some("bitmap_idx".to_string()), + &ScalarIndexParams::default(), + true, + ) + .await + .unwrap(); + + // Re-open dataset fresh + let dataset = DatasetBuilder::from_uri(test_dir.as_str()) + .load() + .await + .unwrap(); + + // Reset IO stats before query + let _ = dataset.object_store().io_stats_incremental(); + + // Query using the Bitmap index + let results = dataset + .scan() + .filter("category = 5") + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert!(results.num_rows() > 0); + + // Verify IOPs + let stats = dataset.object_store().io_stats_incremental(); + assert_io_lt!( + stats, + read_iops, + 10, + "Bitmap index query should use minimal IOPs" + ); + } + + #[tokio::test] + async fn test_inverted_index_iops() { + // Test that querying an Inverted (FTS) index uses minimal IOPs + let test_dir = TempStrDir::default(); + + // Create dataset with text column for Inverted index + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("text", DataType::Utf8, false), + ])); + + let num_rows = 100; + let ids = Int32Array::from_iter_values(0..num_rows); + let texts = StringArray::from_iter_values((0..num_rows).map(|i| { + if i % 3 == 0 { + format!("hello world document {}", i) + } else if i % 3 == 1 { + format!("goodbye universe text {}", i) + } else { + format!("random content item {}", i) + } + })); + + let batch = + RecordBatch::try_new(schema.clone(), vec![Arc::new(ids), Arc::new(texts)]).unwrap(); + + let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone()); + let mut dataset = Dataset::write(reader, test_dir.as_str(), None) + .await + .unwrap(); + + // Create Inverted index + let params = InvertedIndexParams::default(); + dataset + .create_index( + &["text"], + IndexType::Inverted, + Some("inverted_idx".to_string()), + ¶ms, + true, + ) + .await + .unwrap(); + + // Re-open dataset fresh + let dataset = DatasetBuilder::from_uri(test_dir.as_str()) + .load() + .await + .unwrap(); + + // Reset IO stats before query + let _ = dataset.object_store().io_stats_incremental(); + + // Query using the Inverted index (full-text search) + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("hello".to_string())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert!(results.num_rows() > 0); + + // Verify IOPs + let stats = dataset.object_store().io_stats_incremental(); + assert_io_lt!( + stats, + read_iops, + 15, + "Inverted index query should use minimal IOPs" + ); + } + + #[tokio::test] + async fn test_ivf_pq_index_iops() { + // Test that querying an IVF_PQ vector index uses minimal IOPs + let test_dir = TempStrDir::default(); + + // Create dataset with vector column + let dimension = 32; + let num_rows = 1000; + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + dimension, + ), + false, + ), + ])); + + let ids = Int32Array::from_iter_values(0..num_rows); + let vectors: Vec>>> = (0..num_rows) + .map(|i| { + Some( + (0..dimension) + .map(|j| Some((i * dimension + j) as f32 / 1000.0)) + .collect(), + ) + }) + .collect(); + let vector_array = + FixedSizeListArray::from_iter_primitive::(vectors, dimension); + + let batch = + RecordBatch::try_new(schema.clone(), vec![Arc::new(ids), Arc::new(vector_array)]) + .unwrap(); + + let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone()); + let mut dataset = Dataset::write(reader, test_dir.as_str(), None) + .await + .unwrap(); + + // Create IVF_PQ index + let params = VectorIndexParams::ivf_pq(4, 8, 4, MetricType::L2, 50); + dataset + .create_index( + &["vector"], + IndexType::Vector, + Some("ivf_pq_idx".to_string()), + ¶ms, + true, + ) + .await + .unwrap(); + + // Re-open dataset fresh + let dataset = DatasetBuilder::from_uri(test_dir.as_str()) + .load() + .await + .unwrap(); + + // Reset IO stats before query + let _ = dataset.object_store().io_stats_incremental(); + + // Query using the IVF_PQ index (KNN search) + let query_vector: Vec = (0..dimension).map(|i| i as f32 / 1000.0).collect(); + let results = dataset + .scan() + .nearest("vector", &Float32Array::from(query_vector), 10) + .unwrap() + .nprobes(2) + .try_into_batch() + .await + .unwrap(); + assert!(results.num_rows() > 0); + + // Verify IOPs + let stats = dataset.object_store().io_stats_incremental(); + assert_io_lt!( + stats, + read_iops, + 15, + "IVF_PQ index query should use minimal IOPs" + ); + } + #[tokio::test] async fn test_describe_indices_returns_correct_vector_index_type() { const DIM: i32 = 8; diff --git a/rust/lance/src/index/append.rs b/rust/lance/src/index/append.rs index e0aa6438f4a..b9944b9e656 100644 --- a/rust/lance/src/index/append.rs +++ b/rust/lance/src/index/append.rs @@ -14,7 +14,7 @@ use lance_index::{ progress::NoopIndexBuildProgress, scalar::{CreatedIndex, OldIndexDataFilter, lance_format::LanceIndexStore}, }; -use lance_table::format::{Fragment, IndexMetadata}; +use lance_table::format::{Fragment, IndexMetadata, list_index_files_with_sizes}; use roaring::RoaringBitmap; use uuid::Uuid; @@ -33,6 +33,8 @@ pub struct IndexMergeResults<'a> { pub new_fragment_bitmap: RoaringBitmap, pub new_index_version: i32, pub new_index_details: prost_types::Any, + /// List of files and their sizes for the merged index + pub files: Option>, } async fn build_stable_row_id_filter( @@ -264,6 +266,10 @@ pub async fn merge_indices_with_unindexed_frags<'a>( frag_bitmap.extend(idx.fragment_bitmap.as_ref().unwrap().iter()); }); + // Capture file sizes for the new vector index + let index_dir = dataset.indices_dir().child(new_uuid.to_string()); + let files = list_index_files_with_sizes(&dataset.object_store, &index_dir).await?; + Ok(( new_uuid, indices_merged, @@ -273,6 +279,7 @@ pub async fn merge_indices_with_unindexed_frags<'a>( // index_version <= our max supported version, so we can safely // write the current library's version for this index type. index_version: it.version() as u32, + files: Some(files), }, )) } @@ -295,6 +302,7 @@ pub async fn merge_indices_with_unindexed_frags<'a>( new_fragment_bitmap: frag_bitmap, new_index_version: created_index.index_version as i32, new_index_details: created_index.index_details, + files: created_index.files, })) } diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index d5c11c84694..562f2ed7df4 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -25,7 +25,7 @@ use lance_index::{ metrics::NoOpMetricsCollector, scalar::{LANCE_SCALAR_INDEX, ScalarIndexParams, inverted::tokenizer::InvertedIndexParams}, }; -use lance_table::format::IndexMetadata; +use lance_table::format::{IndexMetadata, list_index_files_with_sizes}; use std::{future::IntoFuture, sync::Arc}; use tracing::instrument; use uuid::Uuid; @@ -358,9 +358,14 @@ impl<'a> CreateIndexBuilder<'a> { ) .await?; } + // Capture file sizes after vector index creation + let index_dir = self.dataset.indices_dir().child(index_id.to_string()); + let files = + list_index_files_with_sizes(&self.dataset.object_store, &index_dir).await?; CreatedIndex { index_details: vector_index_details(), index_version, + files: Some(files), } } // Can't use if let Some(...) here because it's not stable yet. @@ -392,9 +397,14 @@ impl<'a> CreateIndexBuilder<'a> { } else { todo!("create empty vector index when train=false"); } + // Capture file sizes after vector index creation + let index_dir = self.dataset.indices_dir().child(index_id.to_string()); + let files = + list_index_files_with_sizes(&self.dataset.object_store, &index_dir).await?; CreatedIndex { index_details: vector_index_details(), index_version: self.index_type.version() as u32, + files: Some(files), } } (IndexType::FragmentReuse, _) => { @@ -433,6 +443,7 @@ impl<'a> CreateIndexBuilder<'a> { index_version: created_index.index_version as i32, created_at: Some(chrono::Utc::now()), base_id: None, + files: created_index.files, }) } diff --git a/rust/lance/src/index/frag_reuse.rs b/rust/lance/src/index/frag_reuse.rs index 76d225d2c27..e9e66f972b6 100644 --- a/rust/lance/src/index/frag_reuse.rs +++ b/rust/lance/src/index/frag_reuse.rs @@ -172,5 +172,7 @@ pub(crate) async fn build_frag_reuse_index_metadata( index_version: index_meta.map_or(0, |index_meta| index_meta.index_version), created_at: Some(chrono::Utc::now()), base_id: None, + // Fragment reuse index is inline (no files) + files: None, }) } diff --git a/rust/lance/src/index/mem_wal.rs b/rust/lance/src/index/mem_wal.rs index 7407fd467de..c979ab42fe2 100644 --- a/rust/lance/src/index/mem_wal.rs +++ b/rust/lance/src/index/mem_wal.rs @@ -111,6 +111,8 @@ pub(crate) fn new_mem_wal_index_meta( index_version: 0, created_at: Some(chrono::Utc::now()), base_id: None, + // Memory WAL index is inline (no files) + files: None, }) } diff --git a/rust/lance/src/index/scalar.rs b/rust/lance/src/index/scalar.rs index c9194ce92eb..d90f26cf160 100644 --- a/rust/lance/src/index/scalar.rs +++ b/rust/lance/src/index/scalar.rs @@ -299,7 +299,7 @@ pub(super) async fn build_scalar_index( }; progress.stage_complete("load_data").await?; - plugin + let created_index = plugin .train_index( training_data, &index_store, @@ -307,7 +307,9 @@ pub(super) async fn build_scalar_index( fragment_ids, progress, ) - .await + .await?; + + Ok(created_index) } /// Fetches the scalar index plugin for a given index metadata @@ -594,6 +596,7 @@ mod tests { index_version: 0, created_at: None, base_id: None, + files: None, } } diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index 3d4eb895f1e..ba0611c462d 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -52,7 +52,7 @@ use lance_index::{ }; use lance_io::traits::Reader; use lance_linalg::distance::*; -use lance_table::format::IndexMetadata; +use lance_table::format::{IndexMetadata, list_index_files_with_sizes}; use serde::Serialize; use tracing::instrument; use utils::get_vector_type; @@ -1574,6 +1574,10 @@ pub async fn initialize_vector_index( ) .await?; + // Capture file sizes for the new vector index + let index_dir = target_dataset.indices_dir().child(new_uuid.to_string()); + let files = list_index_files_with_sizes(&target_dataset.object_store, &index_dir).await?; + let field = target_dataset.schema().field(column_name).ok_or_else(|| { Error::index(format!( "Column '{}' not found in target dataset", @@ -1603,6 +1607,7 @@ pub async fn initialize_vector_index( index_version: source_index.index_version, created_at: Some(chrono::Utc::now()), base_id: None, + files: Some(files), }; let transaction = Transaction::new( diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index a39bd58e3c3..ecbcde56b35 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -2667,6 +2667,7 @@ mod tests { index_version: VECTOR_INDEX_VERSION as i32, created_at: Some(chrono::Utc::now()), base_id: None, + files: None, }; // We need to commit this index to the dataset so that it can be found @@ -2705,6 +2706,7 @@ mod tests { index_version: VECTOR_INDEX_VERSION as i32, created_at: None, // Test index, not setting timestamp base_id: None, + files: None, }; let prefilter = Arc::new(DatasetPreFilter::new(dataset.clone(), &[index_meta], None)); @@ -2770,6 +2772,7 @@ mod tests { index_version: VECTOR_INDEX_VERSION as i32, created_at: Some(chrono::Utc::now()), base_id: None, + files: None, }; // We need to commit this new index to the dataset so it can be found diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index d781bb5456c..b214c4704e3 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -142,16 +142,19 @@ impl IVFIndex { frag_reuse_index: Option>, file_metadata_cache: &LanceCache, index_cache: LanceCache, + file_sizes: HashMap, ) -> Result { let io_parallelism = object_store.io_parallelism(); let scheduler_config = SchedulerConfig::max_bandwidth(&object_store); let scheduler = ScanScheduler::new(object_store, scheduler_config); let uri = index_dir.child(uuid.as_str()).child(INDEX_FILE_NAME); + let cached_size = file_sizes + .get(INDEX_FILE_NAME) + .map(|&size| CachedFileSize::new(size)) + .unwrap_or_else(CachedFileSize::unknown); let index_reader = FileReader::try_open( - scheduler - .open_file(&uri, &CachedFileSize::unknown()) - .await?, + scheduler.open_file(&uri, &cached_size).await?, None, Arc::::default(), file_metadata_cache, @@ -185,13 +188,17 @@ impl IVFIndex { .ok_or(Error::index(format!("{} not found", S::metadata_key())))?; let sub_index_metadata: Vec = serde_json::from_str(sub_index_metadata)?; + let aux_cached_size = file_sizes + .get(INDEX_AUXILIARY_FILE_NAME) + .map(|&size| CachedFileSize::new(size)) + .unwrap_or_else(CachedFileSize::unknown); let storage_reader = FileReader::try_open( scheduler .open_file( &index_dir .child(uuid.as_str()) .child(INDEX_AUXILIARY_FILE_NAME), - &CachedFileSize::unknown(), + &aux_cached_size, ) .await?, None, diff --git a/rust/lance/src/io/commit.rs b/rust/lance/src/io/commit.rs index 7dbc472e6b4..7a7b9a5a77a 100644 --- a/rust/lance/src/io/commit.rs +++ b/rust/lance/src/io/commit.rs @@ -32,7 +32,7 @@ use lance_index::metrics::NoOpMetricsCollector; use lance_io::utils::CachedFileSize; use lance_table::format::{ DETACHED_VERSION_MASK, DataStorageFormat, DeletionFile, Fragment, IndexMetadata, Manifest, - WriterVersion, is_detached_version, pb, + WriterVersion, is_detached_version, list_index_files_with_sizes, pb, }; use lance_table::io::commit::{ CommitConfig, CommitError, CommitHandler, ManifestLocation, ManifestNamingScheme, @@ -564,6 +564,7 @@ fn must_recalculate_fragment_bitmap( /// Update indices with new fields. /// /// Indices might be missing `fragment_bitmap`, so this function will add it. +/// Indices might also be missing `files` (file sizes), so this function will collect them. async fn migrate_indices(dataset: &Dataset, indices: &mut [IndexMetadata]) -> Result<()> { let needs_recalculating = match detect_overlapping_fragments(indices) { Ok(()) => vec![], @@ -571,7 +572,7 @@ async fn migrate_indices(dataset: &Dataset, indices: &mut [IndexMetadata]) -> Re bad_indices.into_iter().map(|(name, _)| name).collect() } }; - for index in indices { + for index in indices.iter_mut() { if needs_recalculating.contains(&index.name) || must_recalculate_fragment_bitmap(index, dataset.manifest.writer_version.as_ref()) && !is_system_index(index) @@ -596,6 +597,38 @@ async fn migrate_indices(dataset: &Dataset, indices: &mut [IndexMetadata]) -> Re index.uuid ); } + + // Migrate file sizes for indices that don't have them. + // Use indice_files_dir to handle shallow-cloned indices with base_id. + if index.files.is_none() && !is_system_index(index) { + let result = async { + let index_dir = dataset + .indice_files_dir(index)? + .child(index.uuid.to_string()); + list_index_files_with_sizes(&dataset.object_store, &index_dir).await + } + .await; + match result { + Ok(files) => { + log::debug!( + "Migrated file sizes for index {} (uuid: {}): {} files", + index.name, + index.uuid, + files.len() + ); + index.files = Some(files); + } + Err(e) => { + // Log but don't fail - file sizes are optional + log::debug!( + "Could not collect file sizes for index {} (uuid: {}): {}", + index.name, + index.uuid, + e + ); + } + } + } } Ok(()) diff --git a/rust/lance/src/io/commit/conflict_resolver.rs b/rust/lance/src/io/commit/conflict_resolver.rs index da044bac43d..3765b87eec7 100644 --- a/rust/lance/src/io/commit/conflict_resolver.rs +++ b/rust/lance/src/io/commit/conflict_resolver.rs @@ -2175,6 +2175,7 @@ mod tests { index_version: 0, created_at: None, // Test index, not setting timestamp base_id: None, + files: None, }; let fragment0 = Fragment::new(0); let fragment1 = Fragment::new(1); @@ -2674,6 +2675,7 @@ mod tests { index_version: 0, created_at: None, base_id: None, + files: None, }; let index1 = IndexMetadata { uuid: uuid::Uuid::new_v4(), @@ -2739,6 +2741,7 @@ mod tests { index_version: 0, created_at: None, base_id: None, + files: None, }], removed_indices: vec![], }, diff --git a/test_data/pre_file_sizes/datagen.py b/test_data/pre_file_sizes/datagen.py new file mode 100644 index 00000000000..0967118de02 --- /dev/null +++ b/test_data/pre_file_sizes/datagen.py @@ -0,0 +1,47 @@ +""" +Generate test data to simulate a dataset created before the `files` field was added +to IndexMetadata. This tests backward compatibility for the files field. + +To generate this test data: +1. Check out Lance before the file sizes feature was added (or use a released version) +2. Run: pip install -e python/ +3. Run: python test_data/pre_file_sizes/datagen.py + +The dataset will be created without the `files` field in IndexMetadata, +which simulates what an older dataset would look like. +""" + +import lance +import pyarrow as pa +import shutil +import os + +assert lance.__version__ == "2.0.0-beta.1" + +# Create output directory +OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "index_without_file_sizes") +if os.path.exists(OUTPUT_DIR): + shutil.rmtree(OUTPUT_DIR) + +# Create a simple dataset with a scalar index +data = pa.table( + { + "id": pa.array(range(100)), + "values": pa.array([f"value_{i}" for i in range(100)]), + } +) + +# Write dataset +dataset = lance.write_dataset(data, OUTPUT_DIR) + +# Create a scalar index (BTree) using the correct method +dataset.create_scalar_index("values", index_type="BTREE") + +print(f"Created dataset at {OUTPUT_DIR}") +print(f"Dataset version: {dataset.version}") + +# Verify +dataset = lance.dataset(OUTPUT_DIR) +indices = dataset.list_indices() +print(f"Indices: {indices}") +print("\nTest data created successfully!") diff --git a/test_data/pre_file_sizes/index_without_file_sizes/_indices/be61629d-904e-433f-836c-1e586be2d5c6/page_data.lance b/test_data/pre_file_sizes/index_without_file_sizes/_indices/be61629d-904e-433f-836c-1e586be2d5c6/page_data.lance new file mode 100644 index 0000000000000000000000000000000000000000..6cc33bab62fc0b5d936f527552ae6516177c2ce8 GIT binary patch literal 2885 zcmb8xe`u6-9Ki9r+d9vu&GfNsHFZlff7P7N^W5F@Wa-wjR+^di!?Mz~jatnOHdhpc zAw&>F5R?!^5=0P05JV6`1`z}i1Q7%g1Q7%k1Q7&L&F=LLrC@abyzcY-ct6{1_kEs4 zrPgZ+S8z2iV;>vb&g-~`xAR_(^9bkoIG^M*e4a1yRldQu`5r&yC;XgW@mv1DU-&!Q z%dIyZT+VK;h@A3nF%+L5Gzv1`%nZL1I zY5o3-xr|-xu!Ji(yuj<>k;%F7L;WqbB8*{9Y)_f7&IX&zS+sO|BAdu2VY_T1o5&`ziEJX9 z$T}^RO=VNrH0+m6WmDNyHkD0f9iGW%vYBiqn}y?KGuccwlg(ruR+p{I)@AFmb=i72 zUbZeg_He?&J4cUflBb+DOkZs7?+>~v~Hf5W#P1&YwQ??n-mu<=#bJ<)r zm(68!*<3c4&1Lh@kqyWevW09RTgVo&g=`^P$QH!{Uz#&^TZT)Pqf%tUx$Iyk!+q*J z^HMHj%;og;E}d__fZ;xOp}C6}@nUv!C0Eh%Pzv|S9@~3)3Dlm@;FX;r`w<=PY<7x3S8rcs0ZG!4C5P2YC&LcrABw5ySJv z_2yyTz#F-XyLl7CbIHx-8u#)R?&E&m%G(&8Ywj=~;GJB|yLgay^B#uhqC@7x9N~Q& z=W-S$K%9ixQpvHt3@in-3 z;mveOi^i`QDs!3n@63OHJBQ6Gadc9~9d{$!G*Y@d9kvvHqmlLjGkjX_%=ceEObFyr z9XO)+IuBCH1flPUzCr2~3bM;bI{B@?gCHW;H7zd^p-w-IMUDuol{$efWJBJ!O{X`~ z)gMPnIoR;IkTXN>hfe5t!tUY9HFpEMgaB7Ov~JA6d9YEd8I8kr0{gfJyRdoSa$(tJ zTUcICJ~}A3JvSPNvqU2W1-n3%CQ_Ew$@VEW;RFibY%SmC_wTNJB?oJg%OC?9Yoda( zVocsabhZfk60|Xy5@}>hfv+wCKL$FcNHj^VpaJDS*Zx?!h{}SbX?oHTjsM|eoRmqT lpAubFXW)mQi$|KPi{9t0hjISs6L zn4I-!4U&){j%)(e&ag}3ZNS7LkL<4UBOG#9eL(y#B6<-yG1Z(r+o~c#P7!VLr)?Md wx&b{?#bl}RxhNwPpxao!zNpQ-?Z-W#atc_=CR2{AHCag`TF#DuI~Nkv|k7#AxfkXViM k4D<}7*pgC967>wLcwpkpK=Bof47-5dh5#l8A4flD09Cz!;{X5v literal 0 HcmV?d00001 diff --git a/test_data/pre_file_sizes/index_without_file_sizes/_versions/2.manifest b/test_data/pre_file_sizes/index_without_file_sizes/_versions/2.manifest new file mode 100644 index 0000000000000000000000000000000000000000..b5ccc5d7ed75b3ff910325b8c373322758ee5bc4 GIT binary patch literal 520 zcmYdeU|`@%;S%B!*q4|zcY>d@eRGanME0Yr$AlOerMSuxb4pW-<1LMo{##+K%3MrOJxDdxtyCYEW&x)$aZhPuWUMiwcFmL_S*sfoMT(&*!;uRup} zNeMA!rYQV}0Y)v>%)Ama69YyIMlMAmHb@vqFe0m9D=sO@%u5FgPvg(4@{gH UD87O*>pwX9Km-$mkE5S60D2#jv;Y7A literal 0 HcmV?d00001 diff --git a/test_data/pre_file_sizes/index_without_file_sizes/data/1111100111100111100110009777b54c5f953114705fd03f9b.lance b/test_data/pre_file_sizes/index_without_file_sizes/data/1111100111100111100110009777b54c5f953114705fd03f9b.lance new file mode 100644 index 0000000000000000000000000000000000000000..e729bb5641f8ce8312f9c381764201223ff54781 GIT binary patch literal 2885 zcmb8xIZzW(0D$3Ktnmqmt40I~hXR5cviGja5hI9-@j&qgBLN2o!VK4p4MRmq89RoG zl9H04qNJpxq?3{o4etLu#)5I1Z+3R`zdhb=HW2WaAcioE5iCIkPhb>JqQFyFie*@i zr?CRhpo(Yl9G=G*UcgGch*emPm#_xoSc`R7j}3SkuV5oKVKZLE7Hq|9cpYzG0^2Z& z8tQ1Ei5A-Epo<>f#9Me9+pz;Xu?tgp2h(^LyRirFVK3gt2lx>C@DXP4F+Rbk_ze5; zIljOFe2K5{HNL^O_zvIWAP(UN%;HBJ#@=HV`5ef3E$6A6cXA%dc_Ht!yl?V8$m=Sv zk33I#yrUdX9_Mf#7jX#-xQwf~hU>V2TeyuoxQlzZj|X^&3NMgyjA0y`Fo8C9U=L<+ z00(gt^Eij|xQI(wz-3&;HC)FH+`?_#!Clo;<#Ex|>FM-zdOAIwo=#7vXV5d~ z8S+Hw8T1T#20eqGLC>IP(lhCq^h|l>=$Z6PdL})So=MN5XVJ6hS@bM=7CnoeMbDyV z(X;8<^lW-IJ)53Q&!%V7v+3FN9C{8thn_>vq36(Z=sENpdJa98o=eZA=hAcOx%6Cm zEHO@Y%Lkf&)8-%=mtf- zs{DL5rYnPg?x9T2OpWC~6?X|$bWL$?@5Vd^hWA zNCeb&5e%rI6{RxQpsPud5b=M0m71E$e);vIQdNkkP(&o!r3U_Iq<#_ER2ItA$cj=p z8ZND-isyRVa5Z|h_-jbDICwOwhN8jBuS%G&)6e{AO|ZVe`X*noY1ZZb-`KB=uiB$` NzW?3}2l`T(^bcOv-U|Q# literal 0 HcmV?d00001 From 28db831e83cf15ad41d6172b57dd355282e29364 Mon Sep 17 00:00:00 2001 From: Prashanth Rao <35005448+prrao87@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:45:26 -0400 Subject: [PATCH 044/206] docs: fix incorrect URLs and cleanup (#5317) - Remove termynal dependencies as we don't need it any more (leaner codebase) - Update lakehouse stack diagram - Fix incorrect URL slug --- docs/mkdocs.yml | 6 +- docs/src/assets/javascript/termynal.js | 197 ----------------------- docs/src/assets/stylesheets/home.css | 86 ---------- docs/src/assets/stylesheets/termynal.css | 107 ------------ docs/src/guide/performance.md | 2 +- docs/src/images/lakehouse_stack.png | Bin 1632620 -> 319253 bytes 6 files changed, 2 insertions(+), 396 deletions(-) delete mode 100644 docs/src/assets/javascript/termynal.js delete mode 100644 docs/src/assets/stylesheets/termynal.css diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 2a84cf12213..73116cf0c78 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,5 +1,5 @@ site_name: Lance -site_description: Open Lakehouse Format for Multimodal AI +site_description: "Documentation for Lance, an open lakehouse format for Multimodal AI" site_url: https://lance.org/ docs_dir: src @@ -77,9 +77,5 @@ extra: copyright: © 2025 Lance Format. All rights reserved. extra_css: - - assets/stylesheets/termynal.css - assets/stylesheets/home.css -extra_javascript: - - assets/javascript/termynal.js - diff --git a/docs/src/assets/javascript/termynal.js b/docs/src/assets/javascript/termynal.js deleted file mode 100644 index 77ec6cb01c8..00000000000 --- a/docs/src/assets/javascript/termynal.js +++ /dev/null @@ -1,197 +0,0 @@ -/** - * termynal.js - * A lightweight, modern and extensible animated terminal window, using - * async/await. - * - * @author Ines Montani - * @version 0.0.1 - * @license MIT - */ - -'use strict'; - -/** Generate a terminal widget. */ -class Termynal { - /** - * Construct the widget's settings. - * @param {(string|Node)=} container - Query selector or container element. - * @param {Object=} options - Custom settings. - * @param {string} options.prefix - Prefix to use for data attributes. - * @param {number} options.startDelay - Delay before animation, in ms. - * @param {number} options.typeDelay - Delay between each typed character, in ms. - * @param {number} options.lineDelay - Delay between each line, in ms. - * @param {number} options.progressLength - Number of characters displayed as progress bar. - * @param {string} options.progressChar – Character to use for progress bar, defaults to █. - * @param {number} options.progressPercent - Max percent of progress. - * @param {string} options.cursor – Character to use for cursor, defaults to ▋. - * @param {Object[]} lineData - Dynamically loaded line data objects. - * @param {boolean} options.noInit - Don't initialise the animation. - */ - constructor(container = '#termynal', options = {}) { - this.container = (typeof container === 'string') ? document.querySelector(container) : container; - this.pfx = `data-${options.prefix || 'ty'}`; - this.startDelay = options.startDelay - || parseFloat(this.container.getAttribute(`${this.pfx}-startDelay`)) || 600; - this.typeDelay = options.typeDelay - || parseFloat(this.container.getAttribute(`${this.pfx}-typeDelay`)) || 90; - this.lineDelay = options.lineDelay - || parseFloat(this.container.getAttribute(`${this.pfx}-lineDelay`)) || 1500; - this.progressLength = options.progressLength - || parseFloat(this.container.getAttribute(`${this.pfx}-progressLength`)) || 40; - this.progressChar = options.progressChar - || this.container.getAttribute(`${this.pfx}-progressChar`) || '█'; - this.progressPercent = options.progressPercent - || parseFloat(this.container.getAttribute(`${this.pfx}-progressPercent`)) || 100; - this.cursor = options.cursor - || this.container.getAttribute(`${this.pfx}-cursor`) || '▋'; - this.lineData = this.lineDataToElements(options.lineData || []); - if (!options.noInit) this.init() - } - - /** - * Initialise the widget, get lines, clear container and start animation. - */ - init() { - // Appends dynamically loaded lines to existing line elements. - this.lines = [...this.container.querySelectorAll(`[${this.pfx}]`)].concat(this.lineData); - - /** - * Calculates width and height of Termynal container. - * If container is empty and lines are dynamically loaded, defaults to browser `auto` or CSS. - */ - const containerStyle = getComputedStyle(this.container); - this.container.style.width = containerStyle.width !== '0px' ? - containerStyle.width : undefined; - this.container.style.minHeight = containerStyle.height !== '0px' ? - containerStyle.height : undefined; - - this.container.setAttribute('data-termynal', ''); - this.container.innerHTML = ''; - this.start(); - } - - /** - * Start the animation and rener the lines depending on their data attributes. - */ - async start() { - await this._wait(this.startDelay); - - for (let line of this.lines) { - const type = line.getAttribute(this.pfx); - const delay = line.getAttribute(`${this.pfx}-delay`) || this.lineDelay; - - if (type == 'input') { - line.setAttribute(`${this.pfx}-cursor`, this.cursor); - await this.type(line); - await this._wait(delay); - } - - else if (type == 'progress') { - await this.progress(line); - await this._wait(delay); - } - - else { - this.container.appendChild(line); - await this._wait(delay); - } - - line.removeAttribute(`${this.pfx}-cursor`); - } - } - - /** - * Animate a typed line. - * @param {Node} line - The line element to render. - */ - async type(line) { - const chars = [...line.textContent]; - const delay = line.getAttribute(`${this.pfx}-typeDelay`) || this.typeDelay; - line.textContent = ''; - this.container.appendChild(line); - - for (let char of chars) { - await this._wait(delay); - line.textContent += char; - } - } - - /** - * Animate a progress bar. - * @param {Node} line - The line element to render. - */ - async progress(line) { - const progressLength = line.getAttribute(`${this.pfx}-progressLength`) - || this.progressLength; - const progressChar = line.getAttribute(`${this.pfx}-progressChar`) - || this.progressChar; - const chars = progressChar.repeat(progressLength); - const progressPercent = line.getAttribute(`${this.pfx}-progressPercent`) - || this.progressPercent; - line.textContent = ''; - this.container.appendChild(line); - - for (let i = 1; i < chars.length + 1; i++) { - await this._wait(this.typeDelay); - const percent = Math.round(i / chars.length * 100); - line.textContent = `${chars.slice(0, i)} ${percent}%`; - if (percent>progressPercent) { - break; - } - } - } - - /** - * Helper function for animation delays, called with `await`. - * @param {number} time - Timeout, in ms. - */ - _wait(time) { - return new Promise(resolve => setTimeout(resolve, time)); - } - - /** - * Converts line data objects into line elements. - * - * @param {Object[]} lineData - Dynamically loaded lines. - * @param {Object} line - Line data object. - * @returns {Element[]} - Array of line elements. - */ - lineDataToElements(lineData) { - return lineData.map(line => { - let div = document.createElement('div'); - div.innerHTML = `${line.value || ''}`; - - return div.firstElementChild; - }); - } - - /** - * Helper function for generating attributes string. - * - * @param {Object} line - Line data object. - * @returns {string} - String of attributes. - */ - _attributes(line) { - let attrs = ''; - for (let prop in line) { - attrs += this.pfx; - - if (prop === 'type') { - attrs += `="${line[prop]}" ` - } else if (prop !== 'value') { - attrs += `-${prop}="${line[prop]}" ` - } - } - - return attrs; - } -} - -/** -* HTML API: If current script has container(s) specified, initialise Termynal. -*/ -if (document.currentScript.hasAttribute('data-termynal-container')) { - const containers = document.currentScript.getAttribute('data-termynal-container'); - containers.split('|') - .forEach(container => new Termynal(container)) -} diff --git a/docs/src/assets/stylesheets/home.css b/docs/src/assets/stylesheets/home.css index 086b6b43be6..57795ecd957 100644 --- a/docs/src/assets/stylesheets/home.css +++ b/docs/src/assets/stylesheets/home.css @@ -241,89 +241,3 @@ flex-direction: row-reverse; } -/* Terminal container adjustments */ -[data-termynal] { - margin: 0 auto; - width: 500px; - max-width: 100%; - font-size: 14px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); -} - -/* Responsive Design */ -@media (max-width: 768px) { - .container { - padding-right: 10px; - padding-left: 10px; - } - - .mdx-hero__content h1 { - font-size: 2.2rem; - } - - .mdx-hero__content p { - font-size: 1.1rem; - } - - .lance-feature-section { - padding: 50px 0; - } - - .lance-feature-content { - flex-direction: column; - gap: 40px; - } - - .lance-feature-section.reverse .lance-feature-content { - flex-direction: column; - } - - .lance-feature-text h2 { - font-size: 1.8rem; - } - - .lance-feature-text p { - font-size: 1rem; - } - - .lance-feature-demo { - min-width: 100%; - } - - [data-termynal] { - width: 100% !important; - max-width: 100% !important; - font-size: 12px; - padding: 50px 25px 25px; - } -} - -@media (max-width: 960px) { - .intro-message .list-inline li { - display: block; - padding: 0; - margin-bottom: 12px; - } - - .intro-message .md-button { - display: block; - margin: 0 auto; - width: 90%; - max-width: 350px; - } -} - -@media (max-width: 480px) { - .intro-message h1 { - font-size: 1.8rem; - } - - .intro-message .md-button { - max-width: 300px; - } - - [data-termynal] { - font-size: 10px; - padding: 40px 15px 15px; - } -} diff --git a/docs/src/assets/stylesheets/termynal.css b/docs/src/assets/stylesheets/termynal.css deleted file mode 100644 index 456f469bea3..00000000000 --- a/docs/src/assets/stylesheets/termynal.css +++ /dev/null @@ -1,107 +0,0 @@ -/** - * termynal.js - * - * @author Ines Montani - * @version 0.0.1 - * @license MIT - */ - -:root { - --color-bg: #252a33; - --color-text: #eee; - --color-text-subtle: #a2a2a2; - --color-keyword: #c678dd; - --color-string: #98c379; - --color-function: #61afef; - --color-number: #d19a66; - --color-comment: #5c6370; -} - -[data-termynal] { - width: 750px; - max-width: 100%; - background: var(--color-bg); - color: var(--color-text); - font-size: 18px; - font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; - border-radius: 4px; - padding: 75px 45px 35px; - position: relative; - -webkit-box-sizing: border-box; - box-sizing: border-box; -} - -[data-termynal]:before { - content: ''; - position: absolute; - top: 15px; - left: 15px; - display: inline-block; - width: 15px; - height: 15px; - border-radius: 50%; - /* A little hack to display the window buttons in one pseudo element. */ - background: #d9515d; - -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; - box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; -} - -[data-termynal]:after { - content: 'Python'; - position: absolute; - color: var(--color-text-subtle); - top: 5px; - left: 0; - width: 100%; - text-align: center; -} - -[data-ty] { - display: block; - line-height: 2; - white-space: pre; -} - -[data-ty]:before { - /* Set up defaults and ensure empty lines are displayed. */ - content: ''; - display: inline-block; - vertical-align: middle; -} - -[data-ty="input"]:before, -[data-ty-prompt]:before { - margin-right: 0.75em; - color: var(--color-text-subtle); -} - -[data-ty="input"]:before { - content: '$'; -} - -[data-ty][data-ty-prompt]:before { - content: attr(data-ty-prompt); -} - -[data-ty-cursor]:after { - content: attr(data-ty-cursor); - font-family: monospace; - margin-left: 0.5em; - -webkit-animation: blink 1s infinite; - animation: blink 1s infinite; -} - - -/* Cursor animation */ - -@-webkit-keyframes blink { - 50% { - opacity: 0; - } -} - -@keyframes blink { - 50% { - opacity: 0; - } -} diff --git a/docs/src/guide/performance.md b/docs/src/guide/performance.md index ca1458834f1..21484b83cda 100644 --- a/docs/src/guide/performance.md +++ b/docs/src/guide/performance.md @@ -64,7 +64,7 @@ debugging query performance. Lance is designed to be thread-safe and performant. Lance APIs can be called concurrently unless explicitly stated otherwise. Users may create multiple tables and share tables between threads. Operations may run in parallel on the same table, but some operations may lead to conflicts. For -details see [conflict resolution](../format/table/transaction.md#conflict-resolution). +details see [conflict resolution](../format/table/transaction/#conflict-resolution). Most Lance operations will use multiple threads to perform work in parallel. There are two thread pools in lance: the IO thread pool and the compute thread pool. The IO thread pool is used for diff --git a/docs/src/images/lakehouse_stack.png b/docs/src/images/lakehouse_stack.png index ff9546d9639ab3641ea1e69552c5a5b08d7b0559..4fa98dbdcf64b8d7ccdf14255eb8c4ba73b776ee 100644 GIT binary patch literal 319253 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 z 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

i0UWN8k4c5S40;XMQj2 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 z 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

0L%c9uN=mam>JkFdYpEhefmU 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

0s$Oqx3PBrOL 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

-D7Uhb&%wC9sosEYVU=XR!g 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Ÿ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 z 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�taP2sUF=`^ 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जq>hK@QM8@AeQ8`pk-t*C1@0Mc4( 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

N5MvF9XeONJ%+pgWGgnYE8xPwulE9x%?@PWDpT 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 LHuJ6BY@d|DX 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

%3@^*wA;*0 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 z%0D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.WAnIp)kh0W(uF{UWi}sl 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%0D=M#r2?7b3ZmnQYn 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 z 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

QzHhvjT=M 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 z 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%0A>Nch{Kh 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ͻ!xLR54_*Aa?=98UH2ZX%yNAbFvEX65{0Of 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

;W;~aOOC!V z;A`wxW0|Ns^ue_23eoR73&9iXJM(pTX}1kr|5s32SUL7E;8txTRrp`ZOC`X;Sdslg zW3ITin-|KzAai+G7q~@~RengvK8LfQtjYbt2A(bt+%^=!HJ?rf(g}Vmtq#c@yzP9# zTBTj(^+kp43-^0J%P++s71CI0p7^WK9px|E017r;@`m+Nz(3brez zSXic`v3lt7M_;RG-S>a6XxBSsG337-`b-w@DRxQb@okHI4LuKL|KrkRuo&};A5r4( z`DnmX_;dj+8<>^YvtMH)3e5JG#<Bdep9XbiIf2?KV>p=48(f9M5nYWx#E5J+q03jp@w~oH+><} z(t>MPvEAn0*VB--3hS`bwaLE!^Oz83s~+X7oMr=ow%>W{@8b0S9-R`rbIs7pu-vnd z!k38iJdnBT+->Ytp$wO(8_wc)xKIV@E3wiCT`|GL?ImnINnD*6c-EzdN_(IG&uE<) zc`=-APONU<4RrF@2E>1+g>9A(-AViEUrW|AuY;YZ+zS&-xWmJ5+*nTYd^Oo`S5)h&ovgQ3!Xns+hmdQ>(c%X1XQyKzCXFpC zN>mYj)d+WyEN5npp9XIu;S~LlEzH1;{Ib7+rUZI4EYaB*e<|#8W4Y!mseB|@hiSI4 z1aUF*`q|07)JGfbz;{+}1NC~#NIWA_wlr|^{k7a=SH?q|J(|@yr_mE}&pLzF@T4=H zyK}>Vj_z_o==yjb|3Jaz`^N84x~BKNDB#!P6Oo?vX#(t)cKDGDXLdiDM%xj(4hZ|3 z^ShiXVaVuzJXRkJm0uYVRx)%$!ha^UmI+Zpy3po6H1A#lBw{Sm-KG|*`l;k*YFd8=H;wZ zhSXIfK+I?9IzKb$xj!erxWj+f&as5@hW9{N3Tr>sSoA-sx#*tJ8{{$pvWylQ;77XU zRgs7QO`YmUlenrpA{-auklB4|kLp~>w17tT<^~%sV?t=pKrFQ*E5s!BMu9Tx!TkiT zi8vc7|12|_`hZIO>^(RD+R0D*xb``5w|t$kStEWh9xPNBa0vP07l8~@+~PDpEw-}s zsxEg5hFX3?2$8@3M-A3z;zzwfR;-W{nT*Yw-S4jjMQ@xaG-CZiaah7(;ZPK1m+Go| zHIJ7IGVmi_YKftGEq{>JVYO4~Z_H(EL*3sc!tR#B8oe4pp*Y#MRK?mH?uvQEA~`Mi)v{o5{VQ4D9^2xYq_goU&bJa=yW z_B8gZ`Rw8fm98~8oCk(pS?*6$b{-ZO3>OYdHwZn08wowkW`S=7I98oK5bezFkV)m1 zyx``c7Fin=P9tj^YhxPwtwf35AcktRiWj4aapIHRq7Ij3!0XD^UntzN?I{k2|VMSnxCOz)bmV?jbId_@tXV2Rsl7G zXEQFWO=lK}_cjhXI10n2te&%dPD^%f8}(2%G48KBeJWi|xVexIopu>)wT(-D8VS=Z zP=LDZ*%rd~XZorUz1#Z`uMFxqar|Razf0&Gsz}YbN+6L^Se=?t$an6|5~=}hF#>`x>Z%v2t5FzmNu&^%OlUK zqzG^!OP4@hJ9f88{Z1A)IX$`L$V)olc%q%7bI|cBC0KwF6B3mu+z2Ai5ua?>u1uh6 zO{zrwM^((VD>#8ppeBlrN#KJWD6j&aYSc zG*SeGu908cJIgS#E_*-18 zj)I!Cua;0aVI&uepaO~!a0k1i)k=h1DWT5FzVulK9K`rM^0l?tv*M?>CPFs>EV=~h ztMBewcbM5{$Zb`}j*Kqd#bxStu#Y!YSMAo|$;~lii%eW5sIJ{11yR^f8?@q1 zcE<|NXj0q=J3oOs6@_9cOo6x_8(9AZN>DZYJE%?DjfJLPR-tACxvPz19zZ&d!hHX} zMx>0n)h+wJA1+*0vtl-L-s^~tvtjhrln!)I@5sM7NxOkzV=j|#YR@VYfRme)lPos- z-f8uVjWUgLn=mXTR05W?@}CDpCjP?IuG#s-|FZ6&Y*%?aVXt|&w%hPPT6%ml<&f}B z!3A0vcEc`{7jLol&lIOU{nOr5+1RG!lxDMM;hlp^*hEcn^J~R0h*to@~(sEy2F4otMasb(0@~R7=;DwOQNr)RkGwu zqnOCt?h7;sUs6zbNJ}_UZ6qN!RC{DjYXMif>dTqkIn}8}HD(VPyBjv(O-%wTG)&`r=V2&(EtI`oNadmP*(n{NgFz{X{GE zB-wQ&N=coIKlJ`4NOjry_2>|<)QYpITihn@^SxXHrpHL?jpcW-zg8ea(9DzalkCo z#THicv3xU`6_w`%Tl<6x7q86QsJm=IGS{y~|J3l!Y5hve``Km?1TUkJVKo}DlU0~B zxw%|V_H8|N5Sx2CIhl&M81jlh+Nn*{*2&QjM5>ksg5*27kyQNGX&|M1Sk7XwizO*8 ze21kcNp{aPXuk__Imzz1^t#ylRqa9Jf%a#J)BT~+(sRw^jtFg&RaQL1S!Y+_4$DIa z2b*{{Uu(&nCQ07|LgJTyhlLZ?pKd9O7r&fsPN@^m11}g2JN?m+&9=wuBDIU4izCxD zCSO8Vh4Y*9Ho2PXBJjxxPP6*E6o}CiDrv%l1nDOuB)}ziUxA;D0lBvU#hHhO36qnT z&3@Y|{WIQLjN)edGuccjmn6g3Gm7?CXT)oD{b1;^cs1iB+;G34Y*X;z0qGfx`2f&0 z2S}9^k!tTWkh>|kitrAr(>S-A(iQEr`Ag^4^j*xck%qOF=Hx*T-7`16EGro)$@oI0 zXC;wcQ%X30kJkdQ@^cZ1*R_*#JEK}qkhj0pkP~&~khY_3et7|JXX&dP zmdvql_;=ZVe&!3TvQ1fykgFru=39^lKXnC-$84Jzr3o%bwI;S5x-K{5Y36pQOOp}Q z{&b&b5j*772faCiGHQ(4y}h_$I_n)--Ax(>VcHn$T!z}4Z?4M@i2<>8Qr%CGB_xbh z4M*Yq5rmYGPd`6Y-%%em6n~U>`Os8iGkPp1_gWcjcSP=dCp@v9;J#W1-5i0!oV*?! zh@Z3Xq^1$T$cm}Fh6@e%A;0BH{rW>8GMJNaCu;rkKh~^n1`!sr~o_$|7+H>Ucbw-k*P!vE2!+QIerH$|N0l+`ln-5)Stu-k8V*!WEw- zsHk=$@l2%d(hFa9)<=DPHsGQHG=qSw$41@wPGKU>TMyQ6At&EZI@>PB7Z~cAd?=p% zL+msvJ#6M^q>IEuj;DtOHF6=kY3Knf2GGLP;l2|%tr!wdjp@_v3pndcxnP4iV>iT` zzc!IHY4;_41p1ByeRIlYZjyXhnyd?8bJv``qmh=cS(uoY9hIfR-$`>g^>|$8qupeP_)-BNjhO2jUt|DvO^MWgqa8Hm0uHC5i`! zieF5)fi&yy!EFgzPo)lF2F-h}c$HcNs<^d>qz|JyyP|pcW;( z?ASz+I>R>LXbd`GG1=un^;KJLW#lnuZ10>?#$C&^dNC?6iQ3*G$_|yk6LTajw?y2D zU8>eTaL2vob?5bCYPCyK6{oy|1p z!uPfwsQOGe-6qj9Cr4`n~8XQ;)SQ-@e|QC>ES;-m_TucGBUkHAQYeOoKo-6K@(eWu1JX%FC@r zDpqCULt}>o{90!7Ji>j9W+qa7*g2 zpxdO;wz#n`;s!Uo9uSFeKMH48^leNtI#9e<-E=0DvY@9lAHTXQYQ6zK$%^?_St!A{ zPM6u=bUK>tmI(Hx7cQ2)+davO(zByfYXxrCiG){@%QpEUncTZ8#);>AG-qqsU~aiS zhc^da1ma=%sIA}?{f;QC)`wb)#4B*^p4x3N1AYN%{+(DU!GR+iPf9UHF+ z`FNfN`)`a_i>n)TzEP|M8!kFY9DTx5UsMqJDzsZ&_#C})CJDpqq-hf*=TkkMd4t29 z%W@f!>hC}jSG_QkC*AHm&fA2>PG##MA5h@(=Oo86<-cC`8?M1R;|`ls%jE|pLEwd! z;TsIvhuUXvD{*sJ>p(wEV-X*D<9&TCO4qKF-zNSTOVcr2T=P3QCbc}zg>bj_tBLT5 zKwqMon;QO@bi>}vq!G>LG}TBJOvAOCX=cpK3dN1gT!J72P?P+1glJapQq8h*u6%8u|31 zp!lmS{IIcqYw$I*sZ=MW^B5;x<)Ey)wV9X%CT4xGmcj{UG~DBj)`LOWB_yc?Loiuz)kR%>y60* z>2a(wJ1PczvaEr~lqWnumkQYr%HAQE7s{ z&mvZAxp`P7$12Y*SUdO%2I6gHM_b4iU?dY;-MLVuf81^P$U|id(lL*;8v>XpJQ? zH7)FG!5G=>t4k_3F%%%{JS(ht0e3skK;PeuIjeqZONky2z%J^tIKad zA754BV>}w2*l1J|x8*~lCcffNchE3NNMA~%mLfA0z&l3@1mWSBcB=06tDvw~r_4PBQv{ z3!S!%asSa>c7n<&|8vg%^IOn@mG?1&=F`NIB z!~im{mAckvMt-yJ_w8ORz>nm`F5jS+qZfE}?e;}0hd4y-AN|i4P;LvcK z0d8ShdQRJ4dK^|20J$8Gnl64CqQsv%882!!DZ`f}m5uk>WfgQ7SM)-V_8oengI$iJ%(hSUgjGuQI!MhDEFtRy5lXkE(VY0ztUGz>vt6_y>s!MPSOvx`l0 z=7jNz;Y=;#Z#i?m^Gcf;{5ZaM?~Y51{sr3+``RekQB7`VGB`6FB^!Cw zCLaT6e>k5TbanIWf_=$T`_cS@SYoaQ1Aorlx*K3`s4xgwm%SABOR0n^`?j^DMy;GkS`IpJWehb z>D}aE)@+g731j&Ap3JYh;$LdBd%%WIeu?0Rs}(1Q^HDB3nItd1JEf$2!iTY3bXRE= z4Bn-<*f1QqczhB0-+Kb(}={Iz$tWS8^o0{f! ze^Q;r(Hg+&?0@~%0pqper%u=H79l%{zud`J3uT@Z}m3ZJmBsuyo}@ z?2Z1XoPwB!XdtDrovA8k0s6UyhD40yb(lPYuV^xgX zzjE1zvU#=`kYEQsS$D3-@wRZR^y z?cq~hQJcOoIH?`P@-rTj`(!B|^PcVMI*WZbNu=~e8jPFHNWYb8`t0cJj%W8+4X;{f zvYPU9aG>hqVm%J)bRo4V`aYD@8NoYW1{3lrTL|w43P%G*zWA9U)f}nwjgWrbc445O zzmL2pOc~aw9*1ANy}n|Bv%A=Qv|Z>k%ZX9?5r&3oi96q^@5Y_X4go-W;+@7>*ul{- z0TM5|5tGRuZ5u-2N{Bb5Uu~--x+@%Z8r=)?S2ThAd(CJ1w03kMwrd1Z^GZOJ5 zpmYUMXQ#z`Bq^%zuFpSwRFY2y=@X|&r9YKr?Ha6HR5ip$ZY|64dB`$PZ_D*Ranw^J z0W#(^z&*}-j?h-}fLU%Q=cVF4AhkgjW5Pt$Z8kY;wCT4^x9LTc0nk;Y1;nwyw7~)+ zvI%Nd&u#ONjP)96HSGraHvx-QCo{P&9G+E&3W$T@(^QBbquQ>@tlQ;8>$}(HX)xlcp3=vG~@? zBd!ayNVDK$b@MSph9*OlyLwrSf{V2^X%S(kr2IMuz!j(8H6)93p4Bj8amd=1f64H~ z$HK>q7u*v%ARGj9NMz(Dm47V?j-m(``mMf1-o_@g^n=*HeD+M zHJ~e8_>yOs^k1J;KneyG0xTplzpt~}4unW3%qg2qfF-P#h-IpJW?>`v7$BtLk0>8L z&)k^gvZM zR?QB1Ot2oTfL-tCh#TxA-!7kk^};=2jo1NUHSswpe~N_f$DNSs_FP`J#;Pt&@+{ih zf}?6UJ{ISM&n*5qJMquy4tZ_TC@1}AE{ALG-3?a8tGrH^F<$x_w!>^Dcv1$hJbZ#*G8mEwnj!px9~*6lUoDr@2=^>Pj^jymKfBNhAsp^d^fm;k`mTSR;4UU75R z1{$ji72P;Jcw!O@DzkB0|8ApcC>@(3OJXbQG(*VZ`->eEoZxFmS9uu${NO|znQ z@0v3AgrujnS=R&*#&fM4&huW>&@?&3av zX7M8uX)Deo16uZUsN*aQsX3`!TpyTe`4_U@Wk5Nz$eQ#%zUl1zwS7#?{;^#pe1wkC(1_RjWIOi(Ek3k=+RnU|6);eAE?w?y;!6PX-}=fgo_|)dW!xWqYvUIR?`86kS$%09t<-e<>dZ8;!(~g^ z8u{i*Y$x7P*Z9b}^`2)3Y4uY5H$aT1BgG5iBix|)?79-`^4kYd{h^BNMISg29i%MR6f=cQr3KP|x{a3bS8l25YexCZHGlSn(`b*@Zpn29Iu( zA_L^*a>|zbdGI{JzT1e?6<=G1u&krFH2gS&20-3KkX8t%o>6Zvj@oqK+#A@Vd1RHY_WR3EGNQ{v$KVBr%d!@QrbcP?UF`_SX z5^`3~+Q;7(FnLN=K%*bE4Ue9oKw@KWUPsAz8gv+MV_vw`$PPmmB?`En86$j;QDQwd z>zO^PWlry4QUkTi$~K<_X7t>K>x&vE4cOnq)@WBX8Q|}hj0zyz|aMNB0deoklKGLbDeAPxt==mODLA+$gF) zupP_M2+bxwKJ?JC`E=swH%R&yN%g7Q3ld(P)(g1-Q2%u4yHDFgtE%s9x;r8^EH^v! zF0Ew3#1Gq=Ze#^-Cs&BA#rd(R`#t*hKP}*P{qe16RQreXgU{&_^|E6vfZSwb_S2S$ zgkY6lM9%{T0PO6}+RQal{-E24J%8`o!QI@Gp_sxW&8AunQ&t;I+l4IUEvK%?>Smj< zZAS+_H}CVT81?6uTeRG-r${ITK7Q|)STcvQ~i9HoBoXFLmyz4Hjr!&#HPI`q$ae;JB9?+&!UGhh>G(p_z zZIf}b=sM_8>BardN<7;|6|b}Mmtq+!E0UBGS$wDEAL5BTM_)BkiA%J0_jxhl?-q_z02fbabP6e#h`di=3|@i5 z_nV`*m1ZcE*erN$Wj#_hs1Nh+%FK0a`n)h14*`Nq3G=qIhkPR$XrI)NZLPve%6l%D z_#=?;dN6%mwj98SoXU44iKKvvI<*4im0r_sNZYVK5Lk1XKBp%D>D?S-?n-T3Uo1xc zyLAd5C?@Tl!wGIr;wTM&L{DTSw|J3vh>mJaZN?2134^tn;YJ0V9+Ot`eh5fJZ6dlByFKH{%nML>E}7ML(5lR z3n@<`AO9XTpa@4Bhs<28qRAgk^l#9YudM#Cm+Un|{@(Ja z2N}vzRh~T-fdaiLPUw%#RJAF+->HlzBU$MmAH@RANG>5}Vd0ewWLkvlKhhBifnbYl@(k zM78QGg2Y&&W#UH}bYU2)N9y+(B6FhC>LiB1sS%d+KyTtT zlKbxtvKqc7NuSzw@#gyLS7Z0yOD|>tL4DFW@6xISC4B z*PzLSyBC@bXhkFE6+nv#*&1m_?c4r?FVLP=plPu5$qQ00s)V?G^vgPf-n?TgFXO2M zlc{C`y>D*P4!b^&Yd+dJM=EY1AI1#a>WO{Zwb|^I7`Sqe-Ty`BtsN3Yc8*KbOi4-s znL^2#Iuc6eXs~Yg!a1c?H<2Fcz4Nzg5mvF1#Mc=kazXX{C_S3bK`YBS#r?36s$t>U zYo^ySW@W-UePtDS!;?){AS;^wt?>F?og{tRqAOY1#ZLb0$H!cjmaDIy`wqy}4;Cp1 zZLta*DW@L4kkv?sE6j*lysbB^+MW@K{&yJ#izfq6ftiU9q;4amoV>r1!j{5hT6DR3i$5BCu99A0Gp1ZNBXO#w*{q!)o?Y1@&eW&EkhvIr z@pZ%)+vA{6{x|c0GaFt#ELWb8eJjD3ZMy*ou`jp1_h2uE-M6P{B#V0vL**y7DYNk) z$!0=ZIIqnf8*<{IR6e~oG0a>?t3flT6;Hs@vOtbqh+ zQ`UJpcxZ`&t*3%%`p8iCbKzQx2CHz2Or&!zT>^tQZmBv^&^ur$U-$?R4BhLYLHFpD2Q;S`8ON71^Fgt7?t3phKerTix*KKKAof0xFd#JqB2KPzFlN*yi#~bZgyeA zE|H{HH_9*XX64UarN>lK-}yNHt^>0*i5v@{#qmOpl1738qRP4Vp%_U9@?{Agd9z0bP zWx#gOG0}Xh__$A$m*$q+%u>2rB&NQ47pkgvnUdFwLyfbTe~HKc8~f=kRr#8AXNIxj zw?Lw~e{mHR=40DK!6~a-Xf8@Q98p>G@9<)yqB*Y4fLl>BAlzRga&Y!;#ed-d#SRI32 z>})+<4yM>}&|c2yp~4L?GtQgzbPsj0_yn>oIp2qxeX?4;u&KPgeB`kTCb`*G~1Az$E+`p7=`iFQrw0IfC4h8nGtug!J}_%=F?si{)4!gf=YqsBtHSFf)XF8zUsb-> z3um4{^shZv5wy7oXSJy|a{ZdZV?psP*0lZePcF$lE%tk{NM1hR;?G9MlZgDhZIosn z?!2sjPw4UIG&M6pJn!?OUdZ;=h*=)XqQmt$@c~QV@V5#VdSbW~)pgCmUCLX2l)EOx`b%@3u1>TDvWYcwF?{21Uce z14?%&4|J16jaUFzLuph5x<{` zq6*n}G+7#Ng0UwajpPv(r}J7wTWg5`G8&a|HPMNeh~mj1T<1e<(v8Xf%fZMiaFw3f ziPAC6+2O(bGuF$Cy=gtCb$sXJ^qc+aw?z%deO15AKMIyk8dP-%)=v#3YApO7RHe4` zX46>L^!8d^&BE|I0bReDjB6beH@oWevwff|>N~tE3jT>*G2kZ{XPOV>*M6YHo-95U z$=MM4v}Q(URsrbTFJJjH!sF{I6yf5=1I>4{{$6^W%jQ`UEAt*nZlwqT2@4L&+`r|& z#U_16zJiu|5*fGCW!n^eZS`2mKr;T^-x$3MmWcO=WBs1w%X1!H9R{gO_2SPrlH{tL zeI8{~#=T+ORz81}UA*E+i!ZWfsmu%l%1wQ~Spuk$B$(lhvBQ@Ul2T?AyBcB*$?rr~ zge{6{rR-&-jf-U`CJ!1mm+l;j51(*7~u#L_A_YwiAvH!-M+5p=k%p0Cfmd_i9JLvi85wzgLXDa=^LErEj(8$D>rZ$dJX6Fjfxtp!=H!EqV& zKAufzW6JTp{=OlcKy@{$B4E0CP@R?`ETQfvy?%N3)BW4kU(A%F?SZ_8 zDG_~dAI1kwGqo%}*&p#29M5@v8xv4>{5WB5G}oyk0TWFDxImxHUrTr)>KP_M^10PSXkJd20xd&vm#|+w^*PUe5;_o?@5HT)U*CpM0FG#5N8w1gOwL2CS01HGDtYoQKi-6LM(_j#M5;J5)ywZA7v zRHV+1AtHSITdah;qbjObC?RMLt%Kna{=V;k#?Q0LbgpkG8=f=9ENndpuWv@d63+~a z^0%hNM~%)s{^35t)|x5v8-4&<=v;UO+8%;Tc|p&1iJG&B4Xj`zq)Xi(T&z}r6`Oxi z__g3DqHsfq`{!pC5OR*tx}@$G$x_swhD&FOdMAgQ?doKN+UMJ`F9ZR9zfn>y)O!PR zU$^+~#Wj~>v2 zkfq}h)>D@3VguyXE|4w4Io{I~C8inn&Cw=vg{9Lpkl8b+{wP)7)PrA)r-sey36h@^ zTSWBUXelwB`j+t0;(Mdx>W|Pj`VfV0nHT5tsN<)v8?%k5OtgOe8O#D_|~o=kd0jp3r!lU2`)b%4EE{<0zp_ci_dtm6>R!5Q{B zr1AbOND65KCLrFMWSLx7KIzzda!{a{Io;gryc5|#$9};ZH+gr;wY~iYQ4I=nCEwRP#UVX!H^ax*Z-MC%pSvg zoke=r<&}CqH3dPlB7zu`;aXYn&ZNS3_{ScXKQQ`v@ID}Lj%Wp{#)yAo7R=V5P1oG< z^*9hDP2gIQteNf{?6~VoB6{k_(-fwx*I%Dk{NhZC`4gA1(1T6X6_wcSCIesQANFu;Y z%cVvvrG2oYd;iEad{~>v-mIP^(ylny6V^5u zf2x!WubMAX*7jZrYd5S|Vyan-4#*RoCh7|oeJ5}$u;ey(AE?23*0JGP)jd3IC`0hy ze(sEY0xRg^M!$22=K)U(c_CPj58BIfE%#cXfW+My{fVRlDB}}YPn}Ns7C_lgfG2K3 z_Jnu2cli6f&0kb;2h}NH`jFIN$%U}tYKC-qY~p;2a#n!cHXqXY{V`XZc^JEJ9qs7C~3L8!Zl!nIgMRsA6ew0%*LG|jj54un zZeFGKd&5!;D@DwEpqRu@(i;Nrx0ndJJ~Yb^*+~D={JPJhS((W4U!&tk-NY$PbJD6) za0B!oe~hbqyjy^L&|=bxk}xVp-Wom17} z#n@By!LcNTqukHyi^HWJm})jn^>&wpxd=WjFvkSl8+e-1h}Q6Z!Ql8)$-6hyNAq;^ za#*2oseMh89w1Q9k9=Gnt^0g7>laf~CxaRi(itu-5OG8~;$%h=OZo>zi&Z*mr6S-P zoC7jfCqjK`U33jovr3m4204S&*=p31Qrd$LW$k|r$}PgG(&;hwL*yF27I;IslY-NZ zr<5zkdc7WZ&Bwbmx7q%xX8kEn!LQI#G@Sss#Vo-wwv15y3 zRGYUy%U0?Rm*Wzb1D7ng)+`OVU^0ojJ@?&z_Cdq2W(|86Y}jEJ2>k?7)_?)j6L;Nc_^uu4w`T9I_zKQ zK*QSrcI(;wvM?W$d%SB;)UhRt_OA>HhlaxsEGx2GUt36+k@@0u%##4MnR4#j^uZS? zxRxWU^92zyrI;`Q&O~b3u#KZTNbPD2+!)A~o7t{KhWYmVrj-65a>3hdpV;E~8`ZGr z_|52Kl34c#CvM05H~;Fh)?Ny+_GCcsUcBWmG%ujS7|auz0RgmrYWi*xcS3~VDy)+f z;9sY@b3$zrzN2d5)8fLzck5hO=zWMqgw~VVp{!L-m8B4Ec(E`e;w!0J=A(*tVxKy0 zuckw`KeBSE5ghbM#5G$s|tSx4HI(4TobNyl;WsARj-?5k(q!>4P zAt0`DJ03*sXd!--g&X#)po^<=^Tm2`VTLlUdZ`=nWq!XRw{5v97#{ZP18@t|idUsa( z!yob_%^EzFM1AY4_RZi4vi5otwql20ieP!&VG z$CMt#*nschKL2RIf50nCz(Mh`f2N84QtI~q_iD$KAap_$`1goZID<0)APGW ze`)k*h2zQ@WE@Wh9f$|nlCzzlyP8TVnhjq7@~O&l*YcrFj8a4z%Z0uEv}h`AL&6KXp{8)1YVX@7df$oi%`#UIG-B)fcT*5Ha0+y19x)bWujM|V`o z!}@}ok-wmX9OD-!I7&G}4%uJEcAfW1NlyM(R%yg*6DydNr#Z35aUOn&^b7CN=LiUq z^fsZz4&5s}N(mLEil)0HN~7#Img7vreG^joM5%%a5P;a(J&jaPb@ApsGYvhgj4$M* zbBzxWX%p~y@aTsiYe%F^n+cis8#l}=2A8|yxTOllucGOH@ql}DCh5#3f}l3K!)6L4 zON8gYhLlJeByJVNa!Ku!DJhu1ByCFEs-*@rz14V|(djAi!cx0a&mjA4QW+0&VT`aw z6*nCb>UV^KD~GE0V_|dYvg1H@(@(`49wGB7Q8#y*w-1M2W`Ug&BRRA%Wx%KuU{0;i z*LK}G^pjXffrI0yugHlMXET@Do!M@Z74@0A#K;c7mMubQ>g`GM)kQT5ikM#TUjNxr z*Be^9rSWe+g^z8ZcXYo5u}*}imOh;O1) z|5AscSz{1&yP-VC@?>5Px?D;|Bjc$xPK$pD%>$XZ6W2#6n4s5<9eZz6xY>zZS|1t_ z2HzKQ?&)uV;-+Rm_BkRCO4w(C7!h-Ws#Va!8^e@>0U#LhMe$bxyQj5$%}vDYP1mB4sL|~Q&DcyU2<*8-E zl7{`wB1#?Y>;4fPy=nRa)<~( z*qvMqEw6VVNXC@WhvY8u;N%0deWo{Oa*a{soJer6;7^rmGX-j?+TX@o=zDF?42`qtCA z!s80zW(7s^qu%GO76~=?tDUl~m0a3Tf0o%!{xX`B?1Eq;2XLHA{%AVsi5=b1dj~;k z1$}kBV2TW)*qxBdZOf1rQ^KTGgh&qw=bIyfdi&jENZ|2cB=I^CkkZEseUGhaQ#-AA z#gfyR!jU+>ACNf5dMv35*OOZ-PmSHoRF~|$v<;%K8uC}R_!CdL%q6Gh!Ipen>wBV* zDgi&9G}KR?t&MiE;_*FrZnl~@IMQabD#`c7-e2rpuis|SpOT~%?u$?jS&J*E?_W00 zQDq(3A1n9{#iqEoyuCJlhqBV%)$;=UzZWxs{5$k4cMimx*M`?M#r*>(CnqhFTfoVV zuzRZ(D{e2Wz-xe%Q>$i4s*yk-q$WrlT)2*^q)CQXzQLSK2I%(0$oej`hWfnDL~LdE~lbl%}?|8EzkO3b9KT_cnlRi)G(p{mQK z_6S8$dv9V@QL9!_tM+KAQG4&b_ex^#h?NjL`CiZU`#XQ-`mFbT?sHy;_ho$&jWCU$ z-Cgd)3vFgjDdacd{es4$5l*S2rjxRe@0_khj_P-r7|^i|(PXBM>fcmqJ3e!W#C|jv zUSd(k1?QFVx-Jzf-zyJ<$!qTnF?9_& zYE@bu^DG5qmN;*qM3esV*w2A4;?l4lbZ_b^ZHJzhi=g!=zxG?Vx0f zyB_NWm=N?SJ4GrRoG|i5unX(c_7EAlgNOh}(P&4%vyM+zae`pPdGVv+IvFreAi^ik zaFLtoJYjP2^5n(0o0C9O3xE9LT7DQB= zpxgJsNCw1tu>!dnf7gWB`>XVixP z0gNSomPQKHLB)pME{u#wjKTDdlC8N^i%+S2x~7ZQ;*?L3k4#0ail}1l5E;j!^PBmt zQ!AeUP|KI_2R_o?j1dn3JfCf*s* zzR4NbGM4q?H4-R3Y^;GJP$`XPJ}1LsQcGy3Y?_|di2^j)f;fYe2fn24fxwBhXaM8d z`l(gY z_qceh1YEsde9492&|325C=wHcJYmkcPAxi)E5%5hj=ygarV{|#I6MiHs^z6E`XcZ3 z{fhM5mA*zzP!fIl#7x3u`y{pwh zRjjeonEbz!hP`AlCzPiu^VfBqM4{}+uvXrUd+W7R-8_JaJn1tWEhIcpA};a{#(&2v z^s~=)Z?BF23BoFCUUEX$4$rK4eLbMethv#{4%7t%uy0)Vr}cvWP&Kugxv&j>joof3 zCafeM+u=YIPjskA5_rMdbXB|_9i)=563}1EoD)nl#X_wr``;knv*e6sYKWs|!;(5V zr3sjisc}T}b$loc-T~nDnEYa8)w5~5R|sOJMCU)dz=TJP+ejCf9OPkd5eB!{z3KLS zTnAku{hyX#AMUs@BQs>L_UD-CjNLTMg}Yg%>5pF(;3|1t3ubOo!xn2Z>#(2J;?&fB zoBjL(X4^P2wVzqemXs2A`}HvoY?D@qEF@K0Kj%ih%cO4CVvRJcX7BB#(0^YlRT{uD zj|?-)PFgAv+qJSfmP_6Z>(yxD2)tOow6JEwG{?ot zqNwb?prJ=@^tWLyhwGBJj^b8$Q~To-$tBbn_Nv*9dYb_F?z;(E%va%4B0c8Sn^l)_ zMf!P$9l>*r^m&E!DXUC(IwSrdkpk)n?n-Ag! z(mrDmxw@$HQmBBd&@gpY`j_{Shrw+2>{^_->wRt=Xy4s85Al~ZQi?!Q+vjF#(+<~@x!WzVNIy9CZ#aL9HZz|#t`mU24s zRSm5V>bI6=&k{7;a15DTWsa+Z+RE;hagc)ILfn@;O3NLridezF{*6O@Q(xBl{UBQ6 zXJPWmtm+C(-#l7N0FKMNZg9d2GgNS{qpDcRftU}00qy*?3Xs@}2N^MuHIlm~mT6@V5f1#PdC1LI7?Uf@|M(6H^(IkRJF^@mz~xV@Yx? zEtC^*(-|SNrO-o7V#YyDYLF4ZOz^M?(`^Fi%Nu!FO2OpU_iM$hd{abPyANBAD!u%@ z0u40&(}`iSJ+)(5aAi+Wcsb4FkZBGMtEQD^p+u5pc#zzea@I6*^3j7YX?VwTqah{% zj+3|>i-QbDJnrg7{@z)5jYqE#2z<<{iWNEMEs}F;`<*&2%_tr^j*LxYL zi(Gzw)-E^wAnThR0$>a!l#6oG2FiNgnWdEj2E-9nqeJ|*<@hqcLQ!#GxHVDYn{&Vc zB>r7mNb3{6!ERQ+jQl7s!uz_oYx*2)UT@%YQ&8k9qQh;>u6{rKSTjwn6ljXzC)Z56 z+2hsP_ZQhL*I=e~10seR&St3Sn=B-0e+31jS(^NakbnDKUCc5^J_16P$j9AVi1U z3h!?&%S)?;rK`6Cb?@w6-FMv12tsEvMSohZyC#V#PU~t+`AUS~VR`xbO-O*elLy(`A^HU?vR7=0}G}Kc=*xI8qCJjMFVPIg4w=Eeam5r)T-#7gsYf zEI?NY@<}PI=u4}mF`J;gVo+? zsoG7OJ9saEw-fatMNGc4Y(jzL1n(Fq2uTI!L^P>;tHiq?;A^q9jhTLx>#}yEG6&zU&#O$71nhZ2=e&?d#y{Q) z1+6*VhuR%`DtM)(i8ecR9#K;xm1W<^bpFN#zFWH!cts%4_M4Ij0PNb`@ya6m+_`bo zzEEU-j1T5LEQpSGYU%sLgm(3k?+PubFejo<3_*F~u%jMFxv`h>5&X4F6QmT6`D`xS z;%?b>aqGf?SI4%|{N5oYh3E&Vb`HM_&>Q81>e;C7%@;sIL`oCD^eJrkiP8ohAYYDh7FKxu_gJB!UkOmt z{9ScU!l-{ulm_TCE~_M~kK-em2nQ#P=bg?P^T>5i=>2%v%dWq2WR7o>96ZvFsl2S} zdlZ$J{s3Cg+3WA>_p@pqEO~ey^(6xsKF&dNYfnuFk zruQb}73VA!ni~h8cGwS-K1ygSZV4=ELbeT>Em-&``!GwR0BuUw{MPuJs)t^P`3q_C zMayN$^DMHv0q~Soi3uo!H>)MNvnly z8Ta*^vKB)#S9wiyxMYHtgoyHIt04g4bu&O*SSq{4-#WpkPaE59av-vlaOax6yy10$mjUOv0b1Va%;g-S}itg>6|gb4PO6@- z*UT;q3bgLK3W+{z2mJIxM)c%>7Ak6q?-zVNOuCwtIiKrvY?U|*rq~}~a(n@FxHl6v zYh)gt=~Hs+Kh66v#5h8=DN-6G-T5q)AH0!t(T*|$XtL<5+xAn}v^>5^z6Oga)45??7FUy(ueV%yauwZao-X%uH6?`a}Cw+cP!jfG;wlOos!AH>FYPdr$1|% zGx_e4tP(?^Dmzc7CMN1y^a>y};;H_*lffIHVmy#@EyCI6y-8HE|hr@ITK zv2G4BqrDBxgJW&f`x6o`veO78cS6FYIiAf04gK&IlHHf@jsB+NO`P%Ko=zwsiE1VuD0-3i7>Li$RGFw?59_mds|QOF`(jbDN~sJyljb-V&|g1nO& z6D+2HxgYBsfD6zg80s!_kG`R~Xzht}(E-;^Tfk*0lu_(}1z+p_r3r;xV~Ej9F{M|Y z%nbg9_BOF?Khp+yLeOv5Uel0PbIS&_uiT9cnUzN?V@EKk4hjK=+MS#PY-104luOmF z8sNnPI!-z(d8gOePUslRSBW5g$Y~#_EDSWfJ-)d*2zqY5?>uY_K{|;y6+f%^J9uCh zRy*Nka-so5=B^f9^UXAfKVl-g4_cBdy(IiSpeFNYib(G>;Vs^;oEm|t`DNKsB7<&z zQa%ta6x6&kw9{X-S8^`b7{0%BM1E>nuk>e216z#Dp~Ytk#yrhS{GX zzl@fX2M6)XWj6_n_U-n0XJg?z_bO%`kx3`C?03p56e6bp^IgC7tpz|`BA&NC8ykKd8m>G+Z_~r_^R&4xsI5lmI97Rq zz#~7Gh|enyb$|2QdIx;~oEWuA-iI&mFbjD@CltZa)W^X(W%Y_2}oY$j2Pr1%?%@NV*e~hT`ctHk1zFmq?2=djwY7|@bEe~HF zHzzvT-X8y#F*82+Im7?QDI?x0b$XN8k}26^klw26;3T$^Fui){+rvophJNL=bL!T$ z2GX5&cA4(zaIPpb@$-3{R)MO0HA3#g@FRbg9tBKxv9!*dHHVRk+=|0*S)|QQd^)Lk zpVYw5%5t073URh999I@g4t+p-ZHey3<9|R<gR& z>6wWQqU_f`nzH^;a<5s(q#iDmF6~inhtEvDU;uS7Wc7VRlQ9;PwEY{SAHq`?Q>g@qLbz z^NR={`j?|%tkWc$)Zs)PCDK6{42ys|VY>NmGaVj!2H9$*u@SEiOFJ!n6V^{K*$+)< z6~CiP@xkw*@T-m#Yz^JttW~EFmE;F|)G9A!FZ<5&$?tz4Y*y2(`9~0x~ zJ(n$)I%x9rtG$sP+eq64WY@BfyAqSOOEOb^OP~7Om&oq| zYqP9+&uz!%h0>)5c(o~n(~BXtRGIx)?tMCsVv-;biVJHU3c(NT_sqh;6VX}!@3dZp z6NWP*34l@?iI&0Ct1r$Rd3@)1f<=hZ6`20i7yNB^%H`@CEm<`uifAPs@T0bI>=Nl1 z?COw}V|oOccU7yxDgL##BdJKG26$EmAA8r#{^%MSl)rm?k}da!@k~r*DQgVrejD|y zk_9Mn_d8KSkEZ@MLzOR$XYG?=Xwk}1j%=)9By-_xK0cc&bqxo26mnVGv7Lw!+7{VA zk614?=5ktkDKDE-RCxU?>(U{j;#!0kN$#-f#|@Et($s1bVi(9eTbIF2XOovMP_04Z z<4d#J+V=)W1A9=j#PiGv6%dGmJI%YEs?{4QMLzxhPWqs6HHkw8PTN{+K=8QiuexF2 z^;vYtJ?@g&Cgrv9q*iRSafwP$)s*8Q`^lA|{q)T&D_UCa`n6rxv50881OYisA`zcm zMG%YoL5;^T9@_y`|EvBnfz^`qCUe4zw=}52c6+D&bzsmH7g;p!L|iKU!M8!RJMjV#h<5 zH=!3hd>WS{?w91?=_TTz7#ssal9=|C^i!ACos)3qTrSgt}yHNp~~lRxO&9Vu|> zw9L}(nFUqL@xAS-DM+zVIAx5!uuB6MI`8kB$BHBZdB)$TBG}UtgV5ilQT62B1utIA zQwJIvrfY+`#!_;gn#Qk}_1*o|vT@wb7@6YL;Axu)REuow(U2}Ez!J-vtiiHSe{fNf zDi8vVO4)HEcTn`2I3e9+=NGEZUAfyey5>W56YsRgpvPO!dzm@x)>0v7B6gfEUze}M z;s>Lj%x}GNerfXV%g>t%gjeh)@Ng`ulAX2kveS&97WL~7lAf_H=?ldM^_y}G(4cMcEOZummFP<75R z$g@}s*nYfaTn_+5opWq3xr?Kz{Nx7DsVRk`1okY2`NVL)SS57IAC2%FucNoLqO&yq zuwnKXcRwvd=jCpH_h>4!iT?UaH2b;gN6X@19#1&$6W7f<%quW%(+vJv%F^Q+sJIT} z^NweY`$B7RDYe6EF~Sb#yo+4!M%XRqp1am!^#NM4q)(CXGqCIgNRFcK=FaTEE0PyU zw&BMQq}>RloI8HrzY95j^sI{!Qb+7}-ABU210(9@@vb1!k6#r2GyR5(?Tcc*sZ4I& z-!P$U{1-D;tL4`|Z4~5$uMF^~aU_{ZvfBR|Nz=@^SDN~%a&{Wpw=r7c*ZdfA_%#Ld zXQszp{P|WWNQuyOM8e9X z4>`^6%1dDy#_`c0b||e_nTcb`vByiF_9#kB#bDsYZtuN_=A@($XRFW6L+v$zMWB)| z*NT#Yn$6M0J7d{*Otp5~vu>Bp@ive2i#HI>q(j?58}pq!{#yLd@H`ejzc61i@P}2^ zD&(_YSdNT^(fh-nYig=$F|6QwiKTBcu>9x}JEl6?fSt#e9EcxPcmw2!sLn~R#scgr ze&PeV-(^%wZ628RCO{{N-9Xw;o7n^MLXjC!xUZ$xPW{P2IB3x`LFHSBIiF))j&lOs z@1d30q~BJ6J?cJWMgrvmj|ma+o-}nplYR`I6j>>2T=tkLXejNd92+;?5@m5%?ceCY z_tt8b{N3r5=XP*;0y0-RjGh8dknr4wqQi-N*K?#I=>yrtRM$of9h)r!r{;XdbHW#p zpABR4yxoB5^{nRRh~+Qk6~V@Js{u3V3aaE$H^J_2$LRIF5HbF*?nQu4I0o39w5eIM zwIL62DD{F!h3uuf04hLri~rvmg@PJ$sfAxLa_wq0qg+3?V;jG7A?5wz0C3&IST41H zE-m~xot=9GXFIfETE+oVp4^ zm1Z~A?(Tyh@!)l>_J9BK44i??T`9xq_&%*ofAZtk&&&|qOI~+L z>9r~`jd9Wfr}aAL04PBv!8$RJnwibpQZ{&0?nk1CbQk}mWYro7A{SQv`}gqn-uu%; z-sl_HWfms&qC(@QLR2{S=-f|J@+g@#_f%jIc(p&-r!c-dA*5)QO@wGe>TOz%N}DfH zzBNp~=ut2s&=~0unsKN*Z1DKrlcqcAC7?Okxg>C*rpgKGlR`It_bdb5ihlYQJ^kfg zIO&g&&4%@x6Q$#{5|j-%C{w0e*hv=LD{nG0{bt|r z%w`qNy`2~C)}Af?(T$*E&8~`M~N8 z9UNFS`2va{kv?@QyC$z@TK8o=`Ij_OsnE!a4EQ}W+z9s08xL4=-RL$-aGo9S0tNE2 z#>__D=zdwv?vu`prNoa$d)e=^S?ZtVb@c@+%gu=4_vhF7p6i=nS@i|~i)H&o``Q|J zd=~A?@7l~9+tW)G3=CK8W8#0TnEp$GaAy;K&%4_`=(K;Gzg9(E;6g`M=$7H{h~&A1 zWKyJ&ENANrxnCM^XnjC|_(QZikc(IH7zSw| z9BRLeHiA3`{r*Yz()MSiOlNvPFs6{A%>$U%MxCVYS~v4I=lk^jgaDy{KWDoh2ENH> zfl>T+j>FL&l53Rz<*CyC1pcKYfaHIMkgvL&D=Q5-|F&)mKymhX)2Wq*c?2 zx6ggp3G8&R)l%(COJc|m<}r&Sn5=!M+xt_ck0;^YoO=SEH07;) z{P;16W8FzrSO5@^az$dk4T;-ZU^Y(>O z+JuyTrIIHFMPU4!tbv<{mlcyQ082h+)6pWfli|?sTmD6x8SJ}*1G1{{Wt!XYfmZsO zlwtcmkj$z2)f{(PEnm%Gvg^ZX#$Bmvvg0RLehA|L?v3qAvl28MZ;WXUhI9{Fs_UQ> zwDf&0iRitoGvxb1!(DpIh%jGuty~aL&2B^3lbcXh7hU3>4V!FXkFywss}ufQLZ<6ra|)52v=UEVMt zPAa4ked}*u(o{;7A1GWw+r-dZ<1Kw5GOJhnIfy1zmV&~ZfmnG;Hvap$p@^~sQ${?d z<>plD>Q&{*D(uX_G6`5)ikekBR4aF!XlH$>bWzimv=sEspy-l^Gwk#S?u(9!~ysJw1Dz&L_Djw4FOSYtgs@* zsAIJTHffuAD)ni1Vnh$;6q8M7W9tc(kXC{V4RXD4;Z`RygooMT&gz4rG5Ngv4?LIB z1WoVW;~qfS^Y+#8TO-Q@?Xzr!AO3JUpFi)%ZY@gfo6RI zI@eaS>!|cj;jJj;b8a@6f0>EIH`@Q2d3*1SLW`qQ&|W@WOwwk}Cow8F7;|4J(J)3! zm2j^OYBS6VVE&M~?r3;Jnf&0e1{d;FjJCKD z)VHg+@L@tWYNTLn4tg_jgAd7`S8@^4mmYmYc5xjT*>y!cSJuDAQUEBvRyFQI3vD}_ z1+JCSvfKI*4}c1$fX&H*LHJbi4R1c3dwBXsbPpfk-D_)POXp7E)oLU{42~WYsLf9{ z7zn7_uM85p`L|K}3k>Hx7w5->^qD2`y^gN5yqN0*YC{G|T zHUIW#__DAzpjmNPw-)oS`!&=215L?q;z&}3Pbew|!8VL7vvRM7$p9n~eK->}e4lO^ z&kuQ0`*@=6tbz_REjulv-qRP|u<^FJt)EQ|yZa;J`3U9H#6SLU!mIgi`m5PqD;x8O z5N41PyZ&%W)YL`qFui;FQ=C0@U`8l9luVN1u|qDtyHtlsUSdfYA9~tFCV0P>+F^^H zve~b1>3r2nse+BHy1N0k5m?4hyQ zeSNzr<}s6>vJi81Xscv1A}{;=c=V=Y_DiON_2`@9kf(8!*o}uj%K2t`&;l zk*2I2!L6L@_ojN+eL}$80)GD1Sx4Ya2i?nFMHI{)<~fsXFIJXj&_$PDRXpaqW7K7A zOoz(@Ag|XF8)*+?wQoOiP%%@u0Kdn}?h?5>>DNr+6#4wdt_z3<^sv&de~&4Ce`rCf zOMrB;f@tgH4;UV!v$b_=sJ>8D`A?D5YG+o)+1T2KY6 z;=WUGOG$cl`qvH{JT-#osrZHf_|+vQKjoW(_n?hx_@l>|H+)MtF&EsK;aKJbZoRs+ zT$F~(qw&Ln%H#5mU^S; z@KdeWvb5er;+-eExvsii1)o*-G01LlO;$~5oKvInPQ7L%E6UEzWwQGMPmbSIh};d= zV5lSQ_`vF`FV*q%Qsn3D1Q$ucR?SnKF%ZjD3ZZx|`bgo-sxGl8W@>OTwx=cHl)8^T zCfp5DQuEUODqe^@lK?_#Anr#L=Jb*dn|Fz@K%`g@&9ZzdG;t1#o}L@Z5nhlox0W?h zWIQMuJ_=sO7_tC~5SzQ$4UsP&LD?#Q@_qc%<7Nj{KV zc2;7Ez?ofjPP>IcI21*F+!4*a6#Tq))j_vv2cX&}eaAAg?52q?03yBl?4(uXdWW}g zE{oN_Iw<~|k*@sr+C*OLHzqkV@5bGBzoi*yC>P3z7v45G$-If_j z)*(LzB2S5|c8xY0JRGK~5~0XQTW2@MZE+!@*^}YE>~2CYLDSHXXk48G*H<_z|Hfqb zl2M_oA2gIZRHW->`A8bbWgI=~gZ*EZAXwMJ-s{e`}*qpB2CNjirdyWUXAVHbq(hj3i93P&8cUAlSWY4Ra?y zX)P<;I2YTcbAzdbpy8f)yTKf`fCaP7p|?oMA~`j?=Bk=$$7K7bZ&mY?cUcY`Fvsv% zWiIINsH|;o$&%v~m*`%L2tIt;lam$1qX&r|b@>NbXUvw=|JUmAxs!yYO)=AShEV$V zF3SrXKK;Z>KozL%H`W;%>lX@=at0dwfHNjID};l6Y2l?`Qg;JKq?&GC?P(0bdXq+T zUq_Q$CG6}qa}Gw-6|aeZ4UF<~ZX^z@>0qZ$;Rx959+IfTBw&7`Dv^ChZNl=!-ILfC}KBDMr#0SuP? zWuu_S)Rku$y2+H)ijK6=tuq31-1JT?UIl%9F$(|*{yWGGfC+u18f~KVqmGkBfA>+8 z?8rF$v=m7|`JMhYM@gM-ebX1N39%0*qXWYge|K1O!XoL2p zS;5;p90mXeL1xk{DLp(rJ;CcFdpfn-BCVnkDa&@k^AidgT1$>`H@jU~w@sYcDdMj# zC42VET-P^S3et;|zz1}|%L>Nkn9YG{@8@9ZV+|(2lk-K#Ru8qV5Vz8%O!Z8v6bG3- z;zdR#QQ3zs*Cqo|B>8K;t~CdWcy=M(Siqgj#AT1g;X32!dc`*m!ad+Nj)X#Akh1Datx6%&Jc<*XXepJY`UzfONVJm6bMZ@J7Fiz5hzVv%b!H}IpE z3sl$vEl}>eI_AFDBy3hGthly0S_tZa8iw$Uk2SW9xLH^qMeut9wsdz}vt#EhQE>V& ziwF2x&U}B1gKS?L1M8F_M#q-77|h|*1d+Y;6NNeH2&aY$7S_|R+gg>+2)P!hKYhtk z+S+iK0DC{WYyaa|X$FmuBLs*4uCrMCq)f{E@EWkA+2rS+XdwrZEAK*B%;=chimcpd z`us~igYJp+OroG^U%NYI$`ko3#2T`#~=51@gzzF2zb6@IPN?NvXs1hFB___Ct1`ce6A^msYt3v@$D1d z2O(qLNlqQ!9EU^nn57G#pxYnYyD^C^H<>R;f&g{n89q@Cuvl3vpT?>FSd%~^ZeBZi z@h#{;ycxq*x*ne%-rA?ox@Vv6Mc8%!BZ2t-I`pz4g{ejX1iJxd5bt$0=Z8Z*@P3o- zgIclE{c8Qo6Oj+CSz9E|>+DhJge4c%-1_LBDj-+gQ+G_W;#EjXZMK^?bh%4kJnnb3 zFH|pkDZAw!7yRo01qA;ry5I9$C&E1X!|K4tNE>eg#1O|C4N1bnQIZK1EVApbjGWBN zPXN&syVh_bhE90I3v-Y`2BV9%r;NWl4n=^8mF~KMz#MKv1g)4YKD>4w`K372CM;>v z*qxJ1_OMG<7X3DzE$mv%S^P}^a;J6CK{Vx|q8K?5rwbo#a0n|diOvG9r1EDaxpHF} zf8GVr95ycGA-`k`fPE<|iNZ}b=ga^ngpX;RVh@)yMxvO)Z~}Qe*6qp*ek5SjxVG-R z8*f9$vc+plou<~L;0r@hLW(bF(|3rTADsE@0CjQPDIUo-?;Ck?W#AW3)IIS=2qL*J zcx9BZ4~#a+Y%C;e0h4}o)fCiEw_KadYihE58}J#NKh7sj6^6Icnb8Ckn04BHvPZ+q zI#NI~7Szug`eYm7cfupYmMszzXD4NG6%jSJhmGlAm+#V`aKQ@dnEmCe3jr*5DRqXb z^c17o_-kBmVTR5`9eYVQ-H*S;3%2zePr+lC=$*m?sOF2-a>cecnry9sZG`8^bQEA4 z`Ii|K^1}W{zM_R(!mm@c%4qMe&k9 zni9Mdbt$(+QkBTK+JXx9_U<71L*7Nk>_*_L%PuL?(F4MMn|}{uFzYUyY26ZV>p?VqH`~7{;5v!Q;o3PgXV; zO~r7tnfIYb%Oo*pVafUP35uYX)%WRZVH(T&>W()#g!j8M0*}9Ozu2- zS(ynQh+%_RNTtt52m)aPPcM&0=Jdy03>7xbG=g46xX7wA`S;pn!aBA+7YK6yd|D~- z2LRI)jUrn2jr`NGBTOO#bSoZ)i%57#wBpXgv?W(VI_OoMP@U-t9`0uk9e`}Gzh4NO z2qL7@X8y^ zyT-Wo&!U0FXpRH+&=tFpB9?eI-Mttk&}!EU_0G3{O3WO2YK{j7KAS1nO6rK##QyZz zEj}+o?jmd{OB^&?%$C&X{-*_u&+yCKPs%HGsznxC=$&uIT_&+o`-y9mx zlV=_XVQrf&LI=|Ar*cob;65lO*tH>C)a_U44zVIyub>v{3f(Dh2GW0RS! zZA`@XjjJ@<9JmL_XI!9aUh36J^w|pt%>cNi#4l21?`QA2L^jTe%cM>?cDK8SoTxrd zSiQF1f741Ax-6dOHKQM(#R#qXut*!_0vQVN>pvAz>-ectr?IOx#hk2o%ZEp6UzI$i z_|DJYJ%wZbkVD0riD%G=QYTOR5dF0f5F_3pSq&amEKO&1USvTuV>&2$qM>uSD|Hzd zK9)r##GXU*%I!8s?#lhch+XqOrkv{>s-xO|u79b!QFzRUuCuxQe{mCC*O4*;!0oQo z5kq)`dbain^<_h`pMF+ZMcbWh7VEvP5NWqp0plaZdjpWo%me^sMKKS~W(`h=N$PRI zX8^{8K3rQG;FH**pcr)L5CID;T>M_Ea(PuJ=oJgsGXzw~Byi`kl#xB3ui}o^(DPn~ z0S3_nak;k;dg(cV6eL=G8dP5Ro%)6H<@3ZNo(3l(K7q!vWtU}l(YTwsG*RT6jCf{I zemIDsM8t8fIVR#LaXeS)gI>vvi*Hc4g4Lc~gN^x90u4f-(D8=f>)0&8_C-E`*0Zs? z_`Nx@hl6xR_17duu}qna;Yq+Ue)jwnJjzombKOU^Rp?^LmC1EP-k_BKkB_*Kzc`dS zW;_k|?2La=$ve3d2`^|Qdy`x~xzO&>#n9~KKQQz*?)hD0TOGK0 z?@+!xCI4q(lV#0kM%-I$$giW6G&Q@t=RS+*kND$-e_9n;Pu0+O|>-)UizKIi81y=J!fjde1)7zvHdZ>Qn0oyi1LNn zZ+bS5tE|KOa`_)os5kZPS&FXAq4j!JXFJG;rduJe$sig`2O8s4uUIwzFeg)fa+vw3 zOzHVU*VgsvzL{+BoisoGd+RD@?ja#2Q_u0Q84sS_Yr~0gajjz{o0!fnDbw+sFzSNz zRD`Fszcc%OPeHoF!x4|FK5jLuNOP$WyF?1V%4By&{4@|GmhuiIr*v(Ry&O6-kR?nb zfT_@I(U_SQ4+-b)D4ED1>?`>a0mZ}%um5nH6mYP#%;YGAlyNWr zqW5amu8G^QeE;*+)yusj+dSQ?$*dbr-QMs;_x2bjv5`9Vq)J)2hDSYqD4ixfD<|K~ z6qcQwQI4CrcZQ+-0iu)^m`>aZ|M9?@YmfpKs@HryM2&v=2}33}v<|k(JEU`m`So4n zUM}a+^?>k@g?aW^ObAh10^Pc0fHj^wxh)5`vcP+$kn@qvJ`5Gbt?`9^C_4W(!|jc@ z(n|Ok*p>G$wyI`x%mno0IL|T1Z$}G_kIm2uY?6k91L{5sDxj*bpKlhn$ZH8~= z!?&b*%1S6wM8)PzmY>?fN+I5<_q|n9q1*XO5t-B9L;X0pu3@p-*y*l}N{yxFvg0ro zVFK2DZE10~?I~Y04RcdpcP6390@-QXB+!`%?i6_Aox5@u=ttm&l7kDztNhTGu9jqD z#RP$Qyl}7QI%H**zmh*0wQ=qpW|_mHf?`uDi2O`vXJ({VKgt|0bVc-BGO?O!3@fnu zV9au!qv{F^JNGgv% zCHOGeSFrw({YfNCRmLb5J2}&F9r#(OP_eLM^^S?tltw`5?*2n_$vzI5DWWqKiqEUz zXAi^q>*O=2c8AmgUDxq~to%Ya>YUM%uk|t5h6^0@Kwqx}7s`T-*54{b89ba)Vs>xJ z7)NOVx(I*BXC0X!IH<%036X@bkQhe%GYA6^tR@K}lmt~8NWqIq(2d@=#2Jfx6t*_m z^jFQhP&OCCUm0wB;z?V@6Ds4+7(9dN6WpJyy_%tRHoOhP1FHpWkG?j%Y=qH!K^xpH zBw_dNafOpuTe)0@tOUi%*BV&THf}`nO)Wn=^fD^! zyD*i4>${=9YOd(tG`HvYI-bC*pFPFhQJDL>-NkJ$C{e;C0#7K8_=-YMZ*UbH)bD-- z^)55QL9jR8YYhtjUB~)qaVy#F`;ZAjs=y!a$3Y}+T-2@Aqo`Hd0I#nU0Tt$V`p5}D zaIWPaw+dsChk*<}#$y($G>=k?U+&^bnxfkiO8nry;**DUsd-ZmpN84Bh5&^iUkTaQ zb;$F6>y{hLYe6t=m_c6PK3oQ-!0wvmp6tFQ7mlR!k_;C`_;6yis>kn|+jRca^8|?;HV^zIV544b5lt)OZCQ+{9Gp9 z%_y+kIsEf7_Q=0HIqCFnP^a>dwZi&uTAhkmnx4~-30diWmjzVc-~uY>F|5sI>R zMWvY7!@yoDQQsv*jt{vkD;uNeYsFM%NXEsK*U6blyvGAq{q5uE4(&1Qvc)YPlO$w* z1c2s(TsclhIn#oQVu32KHz!T_&cZ#6T>6(R+n{TH7xI;z@}`f_Xs{!-LtVRg(^)c2 zuj5fmqyO({&I>*N``l>mf!y~;Lo694_y5hVzHdrzqNclNPiP{!X8IC%?@$oY2`xZ? zJlCyIIx08>q=Ql{0MV^-iGNR(sH<2_8*hvreznpTMFiuaN-72&RDVdfWTvHn4`Fwb z^*{4AYNrQRCm8X2+G)mn|!w0s8U1oCi9*`E?_$@8pv$PG%{?@yMUR$1$44(ywxw z+(ajGg^UfE%9TluCN{;6oeY}b`~#I2cu;ixZUWn1-nYyZDQx;C0Vu$OTe+$EJFP`M zVH&xw8PhB1Gd$Al3&peV>1kp|@5G=vTIWgG%dY>eEWEaH4vgYz#cq;x{gw9*qq7NY zz42*$N6{KGt>&B!JJnee_ZpF~m*EwO9b<4?+(=oZV>VLk`v!Y4b{;8Rs=_UlZB7CQ z`NExv);0P`>;06D@HWb8%7Bs%o+xNZGsC&&C^-J+uyD)H8%L}7{Z z!6}nNeYeMgBg9ufDrXxqM~W@n6lIo^$G$iIzhkuM^k1OOh<=CToOCDw2kUqxi z?u@MSaSx5PpxFXXh*y7{j8t`t9OVlvbNWwry`*I|B}S7e-J$F6x0zBB?fgpN1r!`> zH3b~{ZRfnc?v&3Y8Y*`ps>8S6?yhbceqvN`NG3Mah1Dg7)5L>{GIg#ioq)_cm+h)j z^B4Fv%nY_)?VEy;yi}wRdw?R-XIs97(Wg~+qtb=#Ab5Iz)dw8>c`eg`uaNr}@hv~i z%rUxu8d(E`6u6o@co)A&?N%pj4bJ7WsT|*b87n@9DLCR{T!5UC2A<1&6JS z=;vJOeQ$!^f;IoC{MNdWa4%U#Pic9C(uhK&@q6Syi7So5Tycf#{C8-$DJR}FpJKhn zeyTUY2XDyT07SA2;&;&xAag|7?bIi84Hdy+L#PmGr?gSbEF44YB)q_iM?czOj5CzAG){8(eu`QU|p}djpv$gG?N2rtK1YkS%tayhcZSA#c-QzecVQ zLD~IJY0XijJw?nCeLQ#@2-O2d4eh6nB~&$^jf5fQT6M)$z9oM`8$S!_9OQfGA~~S) zG&^ku6z@Z3Q{Y-Y(k8`CC;UnJ{VBa{9LgIT6QAiJlRi$XDv_;qI@!!d{%bph{JWgw z&N`Ww=jugj=3QeWp%$; zo#Q1>#4kOQ!TAqfzNdX~!rbPSBg8qmPq8!|U)fszRr}{muhMp=);5JY8pJ z{kmz@-oO);$)8{TE@5de=2J)Oa*qu%7K=kY>A5PG%z7?!D=w*+Htd~)60x`t(1JGJ z@Mt$Y%Kt5dtQ-38abZr~nGp8~fxl)v{m^}30f35g>v0cLX75~3t_b%>=^m3X;}_w` zqcHr|A?;l#ahz&)>cZi#6=ZNY{x@QL(M?l&ADj?JluF84gcJsOc>PkYppGrcY8$oo z2ZH0)BBlEMwDtryjqg&G6-ba>577-MEoqxFTOFnzA1P<8I6Y!&%NGZ8`%f3AT?p&w zh%K5T_S}_W^<;pCxuy=|FsXoB-}KC0!VdNDG=;Bus&+B`)@e}B#^3HDjr6Q)SH_t%dF98=u51^wO%^tDQSYwrk=2cpq075Ek*|R zKP^Bbdr8j<@3fF5xqdYi(i_8K3$e6MoGht4lrRgv>K70-k{C*JR`Hksu~G1R#St?w z8F;I_TN0a;W>_X;QX3!5K0*=YW1=P5uL=#K`gy=J#M@3qy}1s}(+<8k=QSO~$rKQy z#373E3&^b=>siW0?__}kto_zs{us((zP(*jlATpd)>9;W& zyuc}~v359^`+9z-7wq_LG_@P_irG8Qc`a%6iOR!1HSC$>HyK~+Fi@s)@Bi_1p5bh` z@870Ms4uB1S}R6NRZHzHM$uNSDr%;xR*l#*gsP%;?M>CFt+iL|z4zY4mY7LIJo&x& zKdd1n8C8r7RHDr@K&=`|5-`w9Ja+=a(L!u)WQlIn~x2R$q`x~=Gm^R1+ zaf~N?ApPLrYEr-$*mpbSFNAF6%c2u=c_H;TmAc8tsN_ykU+VzSUMVh+_E5y8e%X_U zn%SRZbM5FqB2q^N-F2I5d9*d8QIk=Yq^j&2RTjs}?|EfkVUC&&k$0s0GHqH`A_?OS1s7g7Ysx_YAJU1u z+QVj4yDEa|H1z9x=EZT>!i#T$#Qj$nu|lGKI5sGII`~-b*wriI6lt_0KKS zy=2W{awZRj_D-iO#|waWFgqP7ymEz=$AEcjXi+g|7%mV0LA!xxJ7H({3()(^aZ+?8 zOfznLeK)G^``fop$v-1?s8tLxG{GqTktRrwWA51{V!?yOR`Ew{_FL ze^VMH|CyFtO{p^O^(=Wak;_a$>;%v>oV?jD4u&0XCDmpIQ5b5W2Y&oi< zjb`jyq_Vt7X+sf=0r)pYW<-|!*WAu@c^1uhx6w_c_+Q6nA2E7rCcq~O!hyB;%b+*F zcrru%pDS^}pVqAR{*8#cGhfdb_V~D{c#Uoy6UAZ@w9`$sb5`AL@t)POS7^(mImDEA zHzz%98AcI=o`~`Nq67ZcRCo4npPzugG!C}eQ0N~Fe{SVRT*__}d#(_4u#kamrR?qUI?EciQ}Yj~#x8{0-RVkjk%mC$UsI zGida}Els>MCxEl%7xmKFnNG$|?of`xz2gy4P9NXm(t`{l|I(9~-A-!1(x#pbE6Rm1 zWZc5*igI??-&$Avz+u-;wsa4rzWlOfox-S(lXa(_h=L$85GXy9G5i#wpjWLEYwgsl zj^w=nF3s7%A{Hry3|36>?%$GI)aF+Zbog!)dcLBuL`9EcuMx(Qv7S%^KVe9 zV99&Mulpt;Dn=OeuJz52jSZspNG^vE)*;FINg-#5sffVzv6mxLr2(iX=dVE>3w}dd zlQHiOJcqwEq0B1x(PtGOWKdZ=YJHhW_Ui4&|2Z`6QYE?Q=jzMo__l1JU7pAwpg5r& zV7V*FSHRwkR#gtfrjMf+i6jy8FO9Bt+#o^PUL_uM{C`<)eZ!wCIurw$%?OPNc*Xjz zTLYFSZ~i(--=!5Ua`ya0Jhe1F7P|-;^^JiieC$UFKe6>`&>DpNN$9mEOGUZ~g7A(0 z;DJU-2(b?<7Cd)%%!?ntoa$6w;=je)!)|Axuj2&_VL1GSS z2pj0{R^oKDb8OS2toUWg)>|U*nPAzn+g6oNZWWQ~kuyiW~f3Soc{ z>6@yO`5-h8p_8puz|A15rbe8b&67_~!W35NjImWt;I9?`_TR}*WBoF^YoQ?qB*bH` z8}p1}ezJ_kp2K*fFXYRU;~K%1iV32hDIpDPX2~$H>wtNo?A&XhT4jLacAUFoWcyP* z&;4PwPX9L*k$&5g%l3c6e|?GkN=AbwDtZNPEqK_?U#YUMefIklKeC=!Yv&WXmWr5q^8|7`e-GIg&|ucyJrHWqzUiYL z555X{H+{va!so^Z->V(qiC}c_<{s*aU0d7h;!N8krJ;RVmhb(u=d~;vrpAJawq4B0 zq#tRuj`K=5M8+r&_LL85&l1n`vfZj%;|m)t^tbFTx((nlK-_8)%}z!*D(a1Xo5((E zHn*6rQe$HO`{IT1VWUXr>+ZqoU*O%~_ z(@Ktf>z!T$Lw-`AIo2)JoE+lTj+r=*cwQon$e(_{EG~wKEdLDg-%7Zsc`c=*_WsCB z%W0-s3zZLo2^-0yNY4y^i|v_Q-1U>Id)OBgf>{HYZN>t6d!B#nxCk)yte&A_B4~a@ zt+r{rr)^&>tnU4p8f|?m%^hHO)V8+T3d;9CzIT4y{62BzOX&q}2GTelVZ0SZ+`cZT zyC0ftiJbD_2UVLY@3$tMkBQ5t*a^s(xZZr7tO`qzz-1$iKR8`(NNq zuPpj(!Y{Mc{Iz&JDQ*n@WddsF@SC9|(w%70`1-fQp8R(to>sp9rKOHT`Jy-@7Z=0@ zvf&;j+y&)4)gB6I2X3p+YfI#j`3{FSanhE)=jumC_1nC=A^hFt1jhh-`#Q`e9WB>u zz>6dRYavoxEzE0HBIq@=lYwWQe-8R45cg5~h3IFBtqofq0TN--yy!?0$HWH()n^Zj z;?f)&{^(d2?LOLh=gjvI)P(wK>N4;=D$2mvcql(P^!n;@rV%}QthqML+-LJ~aCVY% z>q+gq+nII5*p%2d-G@*a>|Nn_&}5+2r1;LrN79`Fe2fq+cDIX_t}y_XPb3GVNVNCF zfxQ&k2EEut2YE%a7Tn64D?CWHeW9`4Uxja4x5cJXnB%T_B)a+x<7vfT>MZ}Ia7s60 zajeS;zJ-hX6vbUdv$L{tF=i#=dPm#{^DADs3AdAbI0BUYSYW$&Jok^cOl!7B8-|;Rab?$X)(E)LB(WvqQ=)u98=u?HEJbWZ+Dc)@^0^> zS*3;7k+T{l=*Sm;ap=cenpOk0L637P?Eheo?j=-Lto#Zhx+~Woh5Ec@8%g_AL2|<* z@ZmO$)g|xjClT%Nd;9%h=~51p#B@tZeQpxx8p$2y-zVmg#6L9~vaLgV)X!rEe@wEj z#jSE3&!7;T_IXfxs0j9sDf?@{#?KN$N%j1fZk}kF^BAzta$rDT1V8w@;a`~d`58d6jE%arQ+6ce$A@swvbYPP3Y zLBCya|Ek?0M{L;q^18jY&0*dd1M4{Aykwhscu*Z%)riYrnA>bOC2`#ryU*i#2758p z_Zy@w+q^6}j(1{ObFM2Tv;LEDX=S7XnHLROXa zt+#W=T|*;E9$%B!IA6{#SdGR1bzyAj>X3$`L$0$w~jpOQh(YJl&`O zfZa$~g@r^ExHH>8Rjo5vs?4I-uvoI&G*bPIq^Z^vdxxTqmGVFQ`F5PM)1{NInun@F zbl6P->SL~%=Am?3lC!$Fr@!J-TcIWYJ*|`OoVw zn2%_+JA)P$E(o?)YS9sNB@;96{7@(x(7NAA7;oy!)`@RBoo&yJO9l#iF2Ag7J&D8? ze_6@lO{@Glgn4hAl|vd&qfJlYI=?m!GA;Hiw2OyF(6@b;-+t@JON)pX+oke+@tul| zS)p!H2vg~qr`ZNr1;II)=w)N}cCggl5l8=IclU~yN|x>DUY8!8w;|DU=Z#{x(u-( zdtI4>MMAn;nab%r`1+^=wnoU6!w0X65R?>3kE}WW}~WMiwkOs^LF?a;dFW?09#h8MzU~ zz?{V5|Ca5_7%4y{+9XiTh+u+5DBY4$$=h+8A@-%WNQE&^c|F4MiT&_jPvS%0EobS z!GiiICX*Z6mA; zP^hb|g>C5lJ>{boqUE$rtbn4t1&oV5H?gnu*b+6132SP&L)skRxW3ODeA)Cfs437o z-Mn>i{(gp5e?6yB)4BOr+YE=4;|Y>YkrNEMkG>fT|H=rF;EM*>ku?BW=vC^>vtMn* zsMU3BqWS}LH7nVlSv~GR-$6J;Nkw#Mxw5)O4Ef$%*91`PVi|hKGySF+M|qB5^P9=W zEQVxabN24N07DTwA+d0Ao=O9AefQ1NS5ni)X%c#=jeQ2|ovUinq?D@xTv`g`PT@4@ z%xhGKgQi0&P0QxJG$LOpE8g_C1&YXaUNnF>xE5L_& zQ}TNf3TsmX>0U7&p9|l%x^jdr6yQo)8fa#{k=>nS4ebHTq`yg+>#*D6zVmPIWZDwv zG7~&+DXrt?wD8z~7le=p6&F~`b^Q3GPD{qX`wKE!ePdBIc;%xft)_-@Xz9MO3Fd7mOPlRq^bJllk;<6}sIrG9zZo$@4cr1iR0A;g5WZ=u4+py1_F{wvTh4F*Ll0ED$)Nd9$JMpfl{ni?xuS8{}FZ%H_9YtH38@ zg`?Kv`TZPiC9IAdWhfGc79-xjVJyzX_qqUauWm@<-&))fZ?jm`W40pF^4Z3`4fF_q zSN@_*0i$b_BI$aFqL*X>zLh_~Jcl?P!;0~LcP9s+hgOjdkw zGGtC;Z$v3w4Rin(nt}2*&-#6}GUjJtk0CM2*NPsGqhHa7IB4Gn{hQG2hD;rnIDf|A z_Kh8V`JXk|%<0oHwMMF34Xh}p!X28ATLLdCLTr!3R4p*;Px=@59E%-a$-FC9L1wL# z$s$=@RZ35WPk)uQgw_rKT{;I9Wy>yGyrSg}gd#UONjU_y;(po>sW1u|?8RxVU#gw^ zfQN>n%tg$8c#E&IM!#LpYtVqy*5HmHS+Kv&XD0&}-{QYU7YX{l+-i64CkFk0=CG=G zANm&yekT1%9;U2^z;p>l-~z{8b%#8y*ro6PMDlx&U&^eY5j+-TnM0(IO>X6_=9d1b zjzoMZW&ert`%G(F%~z(J?DZxrN<-fZ&6={MrB$)*N&f`OjjUsU_t9>Mx~(M1`bMux zzNX4`uf!Ag`0b1*s()@+zdbU&{33$$;ggLXTlB%8)*_XEY+8#ADw#ca;t`eCOq_PZ z0%5uR0YN{eFkbNI%ZloPOO@9)%11&&f^&sDnaln@myDkKNZ+D@TrJr#@o8D?+jZp( zQq<4Wm**jMgTI7ddUw+(5ra6hv3_Qz!-HC5qKA`9MbuAl9-udR7G~fC*Q*ELzz;egoTn=H8&|0x zh}wTJ|Cg`4j@6d?uVc#bMIicJjE*?|d3F+%j)J9$_5RA|4w}PIx9re*G7Dkw zsNhF{WM>BBvT|({pHPmy82MRFBJPR`H%}tjrC@Y0M~!QG-TP?`A_wSF+&C zcjG|^Ubx*FME7uY)t^32l=DV+eUg`u=YLlw{oxh=%ZzmsHv#t3&Kp39_@i8@38SZR z{-wCuUztf*#`eFj<>_e88GEZLkT~5w5kaQB8RW+)uT{Zy?Q^ETLTkztTTxW+Edzsq z3e)g=GpMxBdm)GV#O!eVj1_it}qVXmx^4Pc|7;GxhT_^e5v8mLJWs`p zhE4i@5m|`Kz|ft~)?iNFJt{Zsujt&!m?Yx!Aa1+aF^uV)n(z$B>@}EL6>uT@crm^p z>bReJ?i;|ZI~vv%sZlV<2>7?`v;RCR+?x5q&~T;KC34$HxZhgF@kPnEK7&JV*oxME zmMgK;mTa8JZs~0^_@H?Kgk$w&u-(nYNxdO>Ot5yMyTa?(3$eF0z#%WMTY(PWs_2-q zSGztA#{fS$<@RTe+jXxgc`Z-O|E%Wm2=Kmj9bJq5B?reI*2bg;7;{`KeWoYs5QNX( z4I0>rr0j+gb5x#Vf1a$gAw7NqjgBUUo$NX5% zr_!u>9wYBj*Dd21wB3KzUk-7VG14sRtgvbrpjuho! zA-bVS{P)^nmrLEuFV1}{60cwNI&c1GMyGi}MjJEZuaXus^-J&*?OXP*19vSmujFSB-Re?=MvQiOn^>KLhtr6gRk01tP{3Wbi{;cR#zVdf?91t;Flo*w;xY zvG~nCRo}QN&{;Y48_@Pf@%rnGRqpVCl}M<*CM?X9Fe z0-S`xEVfJkbpTcYy|SyMD0kbTCiCdC4FDyKKd$Lfxy@xV1_+;}Li}@606e;(I81P8 zTJi5byuhlyiv0rGUKTPN?oO{u9QdzUlm4}H%UVvzL07-V7hUXw3`sTo79S`vSCdLl zKHgjM;vL(n$AnX(;qP!U<8o%xMF-;2V=yYTRSSqXfhzx(f-7EH}NJZ(CzC znsx@LSQznoR|~Bj^t)}eEesrh08P__2#`F7myk#d`w6AgHz3rM^&jZqH#I$c3Kbj1 zI>vLkLux=ZNZk=QoE&tplXfda>Yd-F|GY8bCrhcqWZ)(|1-F$@4J6LM4Tc6%-R=Ss zfUe0!p4+GvifzrlBc?D8Q%863=4F!Yz zrrM$t*`M}vR_Us?;!9Bq=al7FFSe4%_{j1C(v07@IaZOH2B%C?IX4cPFv}wAkLA|vR+x*2e{3a!GhoqR zN3zL!9O*Y{L_B@ueP$y0Kaf|RPwV3!a<5pT!0jv5&8QO3QMLsThGMdU@EN>Phw{OE z?6lWbhh>dk(vEkVmM0iE{`Z0$TK}PlsxeJm%jb=%mP5aGCTq_?Ap_6x= z_q*9UN!m#(8!bMH=7VDM&;nS@W5V2T$#k`TxZ~};yP3(pujrONORMcy4Xx*7FIS+~ zd92w2VUu1lWUE~ABo}2QgjZUaHnX@g9-XOSItez8w?*OdPd=94GLJQ&V{~k%_DldT zqV5)?C)J@tWOsVG?E4?%E$O9U7*dFPkR3Tni5f9WiqcD2bzybtNW=d%kNAc0y}puQ z{Kmr3AB>Sd)O86i{@G7ZE{%n@YTcp};0mDKp~S#C{zlwfQ3jV5T}IigX$2KY6KRu$GF(H(|aC5xV&tn0zq#dTD2G{TS_-w71i zA`O4Uh&MTnR^qw;xkZN@sz*`B*!sylC&ju(rRs_CtF62JUqC&-OkUHY6$D0=^i4tN z6mi+0CB$kW8hlx0>v7i2vOw1#?&2DaqBOvts*}Q^<9%3FogZ?(NHLMo$U0vb5cnsQ ztG3`$T=pdsbaISMo%o4&?|W0fee%e$mmwQZZbCG`4Iga>L#71pKf1CdxPBG)oI?qm zzVW6_LrOFS4`QnVJ18c&ic8kvaeCj^1{0W^JD^3}8qcz)2>&+=U>$ulbLDWtc}|LV zphO#*xCZCrf0Br+N;x@A(#p)PE!bNuymnS;rvF2TJxKXZTiuleS(dm^_x=_c-Qaak z!iJ~_ATbgHi_rLp4XwtI--b@Tm-QHzJ%A0lPmkwI?phBj3&C>5hYSoom$s$Y2(kR| z%#b>2)CCcdU(<^}Wr2rHiCQtrKBwv1oX!p>#`{wV(@I{K%FGo8Cjmik@LOy%{a03G z=&$9eOah%H2yq)c>Y+<(TXSXzaYx!$A3Sqo3(BSsmFb>E1fCY_dvIrO!jXEd>Qyo>8uH4lz;(Wjdxit`Qnw)`d}6o`YLO2ETge~ z`)|GlmBpWvs zTi!~;HxVg|-4w*=?BRB;;aDb9;gkPX(j_a$Y8W!PrYbfd!Ue#GpDy31w%AgSa~U;S z+;X*R{zg@y6ST|W&2O6v&x!XSoS6T4UQ_8K0{>6PY5Osnv8}iiItT~&dWU_SGDqFCdHS3S)Lof~J zf?x9Z{4@gm`yPR?85h~0;2%HxJvc}gmbc=RLf1p|r9ObjE*E<|^coxxYZ0v8P9-ow z>H@>7!w%txzeG!F##C#va7rlBMV=mw;NE_gUB#YFlBfB9GKVE*OjNU09h)WNah_|I z4x0>HhHMyHJFNpzt^f-i#223I>nuTg% z#r77T1d(Nv=}Ef;6g_jokhy>#;T;f!YUhdg62d{DcXUsisTFJ8)BQZBCu>O$MSE?^ z!_j(`PO{}KcwyCz3hz6)|AxfHb!`DO(TB&_1OY;RUY7O}GHpcs{q@f&5UoxkcCNO3 zCJB?O6)8f5#HVwK)+5R7y&&+ zg1Pv{b?vvU;JeouJ8x}Cl#Bu5w{lQ* zeAsx`5i!~zeemsU@SK74hwccODQhiDe9ew<~8G9&a*g*c#XQz>Xzq zb7l;Grb(QBoP$ni97f-{oXw4{sh&!#d-xL_6=^)Jgt{$zHm+`7y|{8>PVJEU=+ht8 zZcEyEbL9suBnbtVH$QS`%86{d#yF9Uu2T6sk?PF_LM^!fy$(W~ z8*1jA=}f#^$~WDlHpCnlf$Z9)bSnqfC)yGqTt5~4jF z`%#`p0P`eHaQ0OXo4LhE0DM!ShnAzf*!_+_5s;zh!EdxZ;IV$s++fSHrJ@*gu7S6K zC)b;WJUap7KgXd$bLWpj#tYbdFXp7M@mBVaiB_Q3q0PIt-5&XS;xy~h zd_LVVPfB4I>DI&MDe6YTR>(&8&*oZx8s57pq##S;Wfk_ck@w1Vg&n z8V);fCjpU^MkP6M%b|$#NHv>>tKkjWG~mx%YE|X(*hg3Ig{j#T0#%?xtO4im16?)( zturf2Jyx1`M;qzA=voGMcY|WPFgxj@s%8Vj^Gyx3@e)^zH*|1xb#*mXI*LW-q1`G~ z_KLE^->+vWNrl4%nZC=DJF0_Rf{g~WyM93gX`0*z-O=~&qIw>CHM{s&$sX;sc>z5NxyOj0HgvD(oIcJ3$>y&NSG>WNjG zC%$p5Pcz^xG&;@6$YB{Gp45L^YU_TQ^6q+hi_dQrelsy)2@2{HO7Cxn-vpXMi`yMj z?Y}BhX!G9kp#?}B0?gL$T87Aw!RnL+j=s!}?q{baZL+zLU~)#D*tdt*nIC_dZDTQ~ zV<%y!ls$3MRm&l(YXbo5NNmhUun+5AaF=oxXfig9#!|77NN@*$0;0X>fy8YIJeKOc zEu8=%==H(HdN|Fd#2(FLn# zxGHn1_5c+FMVYJ7-gO25AbR4mq$m;;N#KZ;!9>ROM>PQjR-t0&HX3$m+g2?SFDE+Q zMa2Qk|7gN$H(yz^86XVN;B_W%DmGT00J(`)JyQ0zFY_3~bOO1L811jC@6=x7l>7M?xwQjAo^9$6JY){|&x>S{6vG?w3cgy*^A}C6 zP~6a_@_=q6hRuJKY9~S!mc1ePc`f>db5PIHozM^)V;k44y|LWL$dBR>q@rU6y#1?w z5@7UEv2A9Muj*arV8M_u*>*0PIa&x?tGdssnr1?XQ#R>n4AnOllkZB?ytc~zvr5!r zl13Os#bXCu@4gjVc_FI^IU@VyVvxZgxmKf8@!|#@?SLH;7x5bKwwyFvS6(bWdCek< zVHPTccc?YxaFMlUl1i<8*YsK=#uotcm`XC0t2OQZ3uCAQTpjZ6FDY#9e<&N}+V|6M zy3-hf;e?_jWiarIE|*#u;*poUc$A2uN14g8>w?2I^mK>irfeubQr~3km%K|TO>pc7 z8QCW_7`t2IBFG5}m*qz=@%tk`1h*0-l;tt2L9~CH5P7UG4Pm=^k!ca1&qD-du(u*3 z)m!YN>YSBZ$=+pWxO6|PX>;zG<07_yi(loOKkOH}G(6>!uYXG!cS6SxaGU+;|K7V* zm8*7?)ooPqR}~m|gB?*#j$ZbwjhwPtYrb5zo58O$c|?f+OIq==u;`#9hhSFAV_fD> zt?mIb@poYBk3QyJxbP+Rji92##+?`IoK@vlX#O%OOQ%$`&d8w!>-V6Oz}vAALnb(3s0D{Ghs0Jw2pyIZ_+1_S}616cf>I)R>~pcc%p>$iucP7ReU+mw#4i2>qm>dDKr zNl!P6lJ{oLuCFTOh)!v)!LKh~T)P{)|0|tRUA5d}k=mqxbDl7sGk7@(^m_Qdo$sq| zHC&=ZoC)yub?v$lS25$(5Rn!779--1Rrgh7`SJM9!}U{)c8kjFS#aY~Lvf`ddlT7q za)+ke`J_`S*JK5gKQnD}EL8x2XyKxamL~oC&M{k>e6wAfmkCCEp6ka7$QMxxZ=6LY znSIgHbBqB>Z%6J82$5af^l7ylrH%=Zp$GgfDjn6I(P>J6Cs@~hyAC`1`U{z9?JYNt z8PKh8WulyNnKtIwn00r7t>jMhb8X|q5&VEaH^yf7(l;_`Z8O{IoZr_b!hMQH3RN4y zJQb=QTT9GhWvj1dk31U$QTx1(u`F==sG&Jv*M(u>MJag4&;fQcMX8LbBC<4C+8?Vh z_Qy3Svy$W#0VP@vo4Oc1wv>T$FU9-TxS_=nea!*$*KND`PjSnFPj{_FTK-)aj>oDa z-omGg{HD)?Vg=UUw2<_25r`r56!6CWXKSA+O-Y67=LGoR67{0{{s%z+n6q^o<}Trs;@1lx|b>+e2pECf&ArhL!4={#BK>)7Lk1(rDWWBl02 zKlZEHU9v;(rSdz@F8`kwP&?Bg=-sFO-N)ukwq^Mk_b3DhJC-~49@+dQQCp1V5|<-k zu!Gv+Ckx~)#W(&PY^OzgpW=NR*}%27vW`y1j%$50EA?JZ{+SlX4Ku5z>ERM9Q0eG} zc_g-MV`{wxyGD2DS#jBN*&K&7EJ%0hhvlrsQu|Nl6(92uc2};NcMTvXKdsnq;Dd)W z$h!SHOK_`qv06>DP=DaYL9V__`jwo7kiTTA+{K59SJ2Msf@WwBmie4}rTV-o&mzQwhey@-mZsAfN;9P$9Ib5ehBCUZ9|dm%l?0sNmDd z=>DtX5ttQ@DeLLuiZ=Y8CYFU@n)CI(@H$Bc`qPB8g&s;@3vl9v%fAb)MjP|E<6mb; z`5naxC;}ZBrxokIwiBl* zX@_~&EYI50S@J_P&UCKpQ>JHUZ8OlmTJs6A3(T=&{VyNhxJu5;Wa4s(v2O&kFC_}X zu>)H2RHKLO7Evohar@|A&>T$N*K~{g@cb)$|0s9Aehdu2@-peG_0Br(10C&}Z*~i` zz{{EZBM|F+&q~_i#_%fSgVWOk^5ONa%tOg*BVWXg*`Ws#i^;jdGp1dRS01||!(~CJ z00-VvSLUYDf`<+?xpMZgK{sa$La9 z#e3*u9*5cUuzxeSMn7~NW#c%dn@j|Xnun?JI^$#gCuAV?7FV^dBN?Eb)33X)<_nI~ z%bPjQ^bTqM(k}=a^{5)`zIo3w8z^ryR4UIheIZou9v^2hI#bv*>-Za^d0-SC1*7z| z{T&Ec>kYxoW_Q~l;*#iQGAHrUKxk==(b_rpbFszIQbZ@a{gRKo-QTbm0?k z6;m`~&uAu9vuQ`AfSy1&E@;Ap`ct|bYNV^5-z&E$76Hdhvijzh@9nz|(nnK(Cf$<( zUOtDX!a%6#9u=_Dc8a|VJM(40c@Z^R(b+?VI$LtZcAouaa_!-C#jfN193v|gGb;f! zIDGfxOs~CiXVk6vj{?Ycrp#oukMOP>pb=zmPm49{kPzs<E zX*DUa@AO<}EVd*ye3xeeSMMZ;(8IUYb7fmY9-AE}kyS%n(;tj&gL3zp1U3mRrtyYI*ZmuJAbo{&QBfq5I<|lFWoMDPImb z!Hu{U2T0-VD@}K*pJpZcRLu_4UFk|47_G?0kmaU3MMzd#`?yDS6aZf&<<`bt7`=6z zW_>O%<)=Yl$F0Uu+pmJQCW8CB0R9h36=75^llN)?W7_~%?t}5}Hh>Sqr@cq{j9I5C z2$#7}bunR=^z?FzJhLpICsr>$T+lv|&(hc$`jXhouD1B^IH(7hI=hZyhBtWHI9*AC~O z0+(3++>~PDzY=H!T7DE)TOfmpUhwR3XPy1-Gq^A*Xbyw5U94|6)jr-e zJERfIVnw!x4OB_UwjlLuQ&&Es+V%aoyA2|?$jhiPe!7s+i);cz4fUHJ__pI;(^gP& z<`rs(qb!ZJa>8DDzAosZVz=KDaphL5zY@$Q(Y&j{o7I2vv$n+@*D(b7&v&AWCC4R7 zL2ys>TKV#lWu2)%e28Txwrz&C2%D<~TR-EzY+Qkvk66ryw=Vzpu93&nOA}_X4>wi5{aJ6*ksA~##T($E$TZ?=SA*=T@UzsndWZS@YHH#xu zeMt$W<$i~Fz0|&AwVw0p$PE<-sMiJByF7I|X9XE+g!qmPe$!JnWcqu9-rGd>bXx-9 zjjO*>f=%+VD1Fwd?$?BNgdltHvy z`XXlRze~u0E(fhO4WPB|Oq$-heJZxYD+RTu3+R-v!=8Gg56qvIAakiuIQ8PcwaUM2 z>Q`Qm1XfP}L;qb);qB%XmtpjOf2|W`(RbX$QCY8L#ZFqc&pfhz z6{!Y4Q$v=^rwf|cwA~m9n4Fk&7Nm<3{WIVq$*p+Zn$s_Jt7p1f*g?E)WH71>feA-r z=WsVe7phJfQGcAPPTW!2tGZ zan|?cPM}%dIj_!Hn4v0h$i14nIT6%tCYiU$Prn(x7*TN~>>!5^g$`WUZ^#)@$V17Q z7$o$1qiJFP`WSw*>w64C?oI;aof`CfxzE^!_lM@Sq<4RpcB+p^+xbk*S!-osI(hSJ zWpA((M1fRnhyZ&tb_gmjm%F6(DDXlGneK{W?ZVBUo_0pSb#DqAOC!tWA!*2KU{8)5 z#f`6Tl)kd#m4J42K_(N%Z_7K{ck%4&Wi!;L+s;g+zSZ|iuS8V54+y`i_O? z12cS&&LkY5vd}X%Y69h^+@a@4ZI$abXx=aXYI>k*1siwOFEWx&IJ9fAIyofq_r&yF zG+~Zr5CpesnMHGHkAn|38)M{_kA<^twxhK6T?vzqdqSYMSr@ypzbc*Z=Gmvq1j3}& z^(2gL{50#lWEXpQi4>W4#>{yj;Bg+A@Ri*HkPi{vdEvYMY(f<2e5kfpd-~Bo{U@22 z8pI1W{7-&(g&X-m;0!Ij(xsP1@xJlPnK*lIpiVldbKU`ECkM5JuHxsCdjP341}(U$ z0*Trc$etawKj^Z_qJ^WydHcEwLX*VL)Ogdquq=KZ2d;b9*v4D9?Ondmh5j^gHq&BM zBg-quGWY54sC^;+*R2mgd0vfwGZN65?bTNXput}*&@IA!S zC54{~OR&r6JN|Q7AT$nzvjx_jriru6hT7NL?_6!Oa3WnBfkq|#BlA1uyL+71eSy`o zm8R~yW9JWc&>gT+fEc>NVq@R*zs{1hto&Fwjh7u-P`RYJ2yLmoWH8QA5`uO~5lOJl8+6HuXjT`8P?fENaH7C2jHZ{?&~5N{dbHIiqsA`12#9 z4ighNzSUq1H@lqe)b`p$s?lCb0o0A(Ff)-O8bbpeMoKL}k#sZiqtoCF>7D}WFOeJu znm&8TzO@2K^(MNQZP{kf-}<`wyCjX#rx)XN2D%ZZ-rKvtkbFsyKM+<+O(@A|rmouB zlbGMOKUc-BpWKrOakA|ylth*YMPKV4rTtjGvGvD=oR6%?=s1sP4*{>89>%gN5-D z_iq2YsgjqO`2G>BG2>TTfoWgY#TeOF2sFqcw)Kr^SCu16=f3n#_|NL46(EEQeq$*0D?T0hc8 zCck>@yIsI%1?2mffXMD+<&U2(aRnRNMKsA>D(v)9^Aow%)+R# z67ImRf(Ix@f+wuQ{X9++RF-4l%cq$VH(ezX0mXxyjay#yQ`FCh&&;aVX6sCsV-smq zG)y8UrOmHA68KGR8=QT0Zd2dQ{J&fP#<8~L{H%l*?CpXM-q;E*Ofy-#GlBK%SK75* zwnM(1exrL_dNJg`6Ox}KhYp(v$-F+;O*!n+fV=aVtjW(iUm6r#HX`MK%dpuU?};la zEw6Tb(nQPf$22w2q`muzZ!UExKDb&Uw01FajRqhn3e8_=%k<)0gd{j*2u+gg>xGoo zb}mTz=vLQ{oZjqLF|jq<91Uey^z?_DX!JEStsO?9vK*iUSorC!v#Ir{w!C3DVRuc- zyMHs-64ZRi&$E19=Ba@=FEY@8pACCzLcM!rwy8Y@X3fsx{sxOlArBrSt^CKu%7p4) z>{uL<4;e_9GJS5&ZkD{hD3Eu@ZGZ_IeFbwvOhLW7iezu#r!2yIt3UoENkM`neEsBB z$>!6H5c6L$&Z!YEGM1*Zm!%G2H>EQz9zKWr5U}^Z-hu;@d|%x^uUT)7#hI@?4*cty zdt25(NYMeb!7&Zgt3wo`om;zIUhbZbs)fre{){3-@g~ z-P4~*6M$o{5MVj{MQxVA$;5+FyMUGOfjP=fqU(+WU?Y2@qEQ%(sN+x?!` zD4>?W3A#HveB&Fs;CeyzuVFq8YLt`+jg`qs^rliLqW66{}HJ(GatjXo7qNaUX!69fmll>eO z+_IzO=|Yvfg$XMZMik34pzsXdJZSzni;q3mSAvLbpX$sCl|?HXbzdM)3*Rol%R{i4 z2@g|`g~VkyT_>P>9Zv#A3M315dZQe<($>leI zZJU4@Q!8;E4z|C))v6|ZovPu0^z8af61ngV&Z*>7H=1r8eW+B_viS-ILHB@E+pjG5 z%@+EGi+s=vv@TU^G-K5XA%5EpiEg}OYmDdB;hR#NGgDDqUqe!$2+I$SqLpabgCEG= z)P<&*6hw{)mejozis6W`fzEC1G5vPu@#LsbxSbB5bU)}GF=#a1;vI2AYLb87asuJP zR`@Hs)2IBP&dSHg9M7+(`z0!B?M^H$s4xg8Mpe01N37iAhW*9NO){8WvFGOiv&n+D zKF<8$_ORV^j$U7=XISFs+iA6AN%Qpjt(2ev{3ax>H4GAd;quxTqO0jD6ELli#?<<# z&M^4kOvOJ>QdPBtjAbm$|_CZ372u z`SG5%qz=hLbvB&a`E_30UgXI_%yw@XVu5*aO=3q?V4F4_$?TE3W{y*x3$?9I6eax% z>&DNDx*J4MzT?t;zg!7## zq4sBpGf;l~j!h)yd=AJAo|vb*v)g4gO6a+?#xzd|-Bp=ZK=5+)fMOf1E8PIftZE*^Y* zrj~ecYCMjLW}w4yAqfLp&qoOubq@)Q?YYEazfr!N7ji2(GKxx80+c)jzSvMjKrI#5 zn-GyI_su5gIL;*QFLVgkx(6c*meM2MaK=?+i;=`E52DseZ6-%t(rgV7PYmvf%Fi?y zj3O)7O4hvixt}sxeXK;q4w(*E zz?a^;7C^N})LFIvu@*wOF|VP#L)3->bES4uY7zG0paC7*(0x~QKf+tK5Yxns7_z{} zH0dJSg%n06#=VUFGhfn}BL1m>*f0=Wtg8|tVTwIaQyZbf5Bw56k?42Pv1VR1razqLeD=H{P21yk7}Zpzgwr zgD%09=3a*H4Ef3G7KgiT27VKn<&CgcxvpO% zYFV{aSzGu8KaDNYOGcRfL{xja2=A!KhyA>Vvk8Z-hVmvU)qpxBD~yn*lp2e2l(Kdp zZa%7&H=Vvb>53hTBnw-k_U9B!n8xQyk3+V4H_`hw);4^?JH!{rab-|S{c*DH(pTD( z&l9v_1Ad4{62S3pC)Zn_sqCM+J9h~d&K*8jLOttxT=9jy7RbMqd};OLvHZpiaazDaO5P|G5FeUVrs1!7!=6^8%u`w?~&1u#|63YCXU{XHRd| zG-c>J*uC^cgfl+<>i~#36*B96-hkmZXtCQMYy6jz-GE;hhAWAI6_l+H*Jdie=}y*^ zcWrf|l9`<_P_z;6<-}sb93q07r(f;Ew&(`|?b)!%4+h=pwo+%AKY;|BdZ%*7rgH>AsH^<@M^4X!x$)8OOkL!eN4@l1W@^9(nA>XtdPR%kGNCm5BTeDv)RZUz4@@t$2o{^)XsnCxyA~?@xv$(h@iL?)GGyePaxQCA z-jx6;)X%WqNT#vpK(y1xOTaX?JRg9sdQb06(u;%+2e#<2u_kPQ%~>C@JS-$BjIkm*)?~ z@DfpwiPItY!YHNQq#~VG@x-b|3DxQm80F|}8;OgBR zRIdQ)P1K)z6#ewtn6<#WvemDAVAJIUjt9Vp;avDxb17!%T?3CFV%n}9mt^tld z-mb+F1=PitR0Epkc>lOwN6M+HINUwi)z4iEdV0?4ewlNDn@*M-->eEjfAzwW4Crls zsB{7}&qs!%!?pvPDC5EZCaAUz0p0)j{zmb$eQfCuLd_{MV)8@ib3e&+xmTK`dmmO` z@{>!##?AtIn#bq+41FIpGD2wL-xh`B4+)6!TFbfX{Nj9YOxp`UICeQF^)=@2Aq=jR zSz9od2_umF3rH+b`Nz>8>hF37)vy&GsoZ?91jEjosonTU zO-wV>&%nx=v_sQpo3EjZRN=w8&VGDJ^vL>Qh@3KJSK0D_uRYN?9;Uv|0x~Wc^(JcDBG3Ss#Z3OUFeD-G~FNyIt{nfFVHgGFJ7R}MN6Lxr=J0;fYPC)!MeWm zjy}unWJ4tRbE4g}mV1+A8S_YPH(YQZ2S@f(u}X z!9!~|Nr_Yu2L`ij=rYfZ+G2!(KNk0vxHYu)o0P@24;lQB((te?@6O!2t#1O2nQd2$ z)VQ1r0m`a%&z3C$N~+-e+)IErBfRHFL71_!lSps$didGn_)a6bK|T_-=0QjMs1{mh z1+0iU?q+Mm);XCUcE`%-vM%n6`cjYaZ@Q{93ok1}FrBMJj4@E~H&u z;sd``eZ>5e(d|a)n|sG&y@ir<8pg*%JB6{zCH3eDqEM0C(Ll@Rcv4WFr%{I)NK7giZ&40>^=4jeQ^&X@XnH zC3DNBMJr2_{ea7M3*YcNXXY+rI+60OSFnhGS3X-4A9WW}cZ%sDU-1P!x@XzBU(r_# zqjWx76b3h35A*<)*pd=6CmXt;1>m(7iEhfSAyaYw_$=+4vd zFs0ca(TUxqr@c6T&@jhRT3KX*^(L~6|Iq=8Dr6ZmSDjm!#5}Ob!0P`0fAG0_-;IBhEl~cHR+c;UcKR z|J)H(RG$81 z#`WupdQJCk8+OK7A=2IuZ?|{)143k+zlpl)S4QmioNP|yys1(TiI6(Kxa^skY^IWh zRI=pLxvI?hqkn|J3O7eeU9w|B|uX?Cz$UjZiLQE>84b`cV-11U#$oZ;uLwfXeHe7NeEX%CpbiPNh%GZ@LKGh((kg3#n zFofYz&jLaWcaHBBTR9FDVd@B2ag8pfrH4R{6=s~-n?kS$4-H>R!MBx^*GTq!*?bc$ zJ)uw?Y8vu|C#J{8ecEbgS*1ub+gZK|nP0l+9Q3Gz&YqF<#dZ`zKGw)enh3{Q_5cZv_XWZrhJcf$T#@NyUGW!33j zfUWgw0X)trq6SAiO(F9+v}?iSZ_gT{Kb$o9{HmQ8XC#Yq2SEOW zBWbRdb`e(zL04f|avY*;bC2)WQQ7qYUrMoe(5&z9RHA^JP_J{cBpg2}5t0dhuqO8E@XWx$0j95W{^4S?etzF}ovkurIqw+OW zr{0=TUK*YqWPKF*r_U(@R59Kx_H%bh@8#UnUZ~`~+VKE;0EoIWhNP89uP8#N*;Q6clUcw;-X^{0Xn|{rs7b z>$-!oDTIWL3+uu{RqaBWoE^s=hV2rQ#0yfH0lgj20Ieo5#h+ z;m(mrK5#U!{7^}o!8`Z|esQr@(%g1V_jTVo%Vo^B10|5HcuuA-aTe~Vq;Z%)l~_<( zHJIx|!qf6tlpirO*iGBn@`5#2wY8KtVyvP!vS4n#4eBi!uHWEf_et-ewN|#g;e3Kc zK_hV6{s=F?7#v!dzRfI?rEhv(CocS&E|%{1Ro|q0BU{=B>POAZYexVwUk!>TtV>4o z3b+nq?*jc_9aoKML)}?&>Ev_#<&V}&YYwibFwR)6@eT{`4UKpM%?>3ZFKohiG(JVc{&NVA%G zj;M8_I=~+7Y8>pz-h&=WVqdDsf?1xD;> zz(+Hn^F@YIl}sfiWm+aSw_$!BavBw0gIG2SYuFTb3UUqlJs?%`rb?pxXuXN~PwKP_ zU-@qf|C#P$U+`6U(}2Ft zu55b}Rd?9>-|FAmCPY{Jxb-5-9{}U^M_#?);{jsvzYpU+NqA!PTmuxVggZM#I zVA=FTL>FEqXS0VB?M=V4Y#7=Dvhped>+}Q4?ub^be*F80P7^nMm#27rz@2aa(%F$X zYTD7vGbdT&176dglAS}WEraTUiLDZGG*J194T-VYd8J$6`R+PhL?8!ok8MgF@K-A+LMX<}jI=8;ryC^OYviTtR7oM7?@ zc)0*g5ur0-|FhlE#D_`;4dy~^ByTcngQ$3F2!$wK8ZECnG9fEQ=Hk|VpyrTCij2j@ zV4}q`t+rRAn{Y5cUvlDqNXwxRJDsS9;n(Ir3j`^lCb)VMEFHKe(QuLr}!(M>$-!gT0tx`?y_eL6#wev#w;O5nGFaQDlDymR2X1J5iopyC|02U* za{J`5!x(z19VlAtJ$`sv!U#so^6kMtcketok1%k>w?3Zi`qXvFChA8Mc9r>(JdwhENHDuP2%5gb=|lK z)I~KN4vq+l#P6Q+t3-|D@Hfj!aRZx{mYO3MzAs{|>)E}0f!yK%RXh%#s1&q;7#MDb zRCUBstiFvaORxDsaFsqfEj3n$lW?;b?E-&{+$8ngnw{Od+U zyWu*n$JG_5&7-A4Bk~Urq5JD_vdamK{M#&y*{jV=U0tBsy2qhAZ}-{xb)(75-zKQf zvfNNU%y_$Z)9W?l4*wnxW{PMCIq+(aVs6wftmTW#gk2b}r#T}aWf#Rx{{=t&VTm)! zn}2_EEr0IWW4C~vmbCE2zOX++h%y58)9_Hkp?zQu>O z{}3=@xzz1Td;HsJZ?k7eos8^Z_s%p{q$y_M+LvFpxHp&iJNm4bdoOg4!ygO+hN^DM z-{-~JhrKK+uKDS{ zzJ-y_GP1sl$yv_`wk^@Drsb85EnSy#x$Yir7^S@OQ;oo7 z&3yI}&)R>%AtN4~$&OBT-|44ntt?sf8u;&knen(Zh2)Bhv)4HyF5MfeQ*huj;WqUE zOciAJ&JMStu)4GPvlDJti;qyy2!F82dIAujUw{cfiiSR#bHJI+#b`=_+BmTJdR!StCm;n z&gZ~jP$NYeMaskCW?r{^^fUb1Vx{vP*=8)uDwi1_JH%Hr!*(kK|7GjP0ueTC2%YiN zP%2n|tMK7ha&k8{tOe`Crex!-vFj>q`5Xbdnu0JDSO;Q9n@s$qj|<~rYVkm8*ehQ-Lc!N^FPZT~h7j6lRV8*oI0gGWJVu z0&Otmcd5&uU7DW_J_M4ny)lMk!=yM}8`M8pv6$_C4Q2YElWANpr`r+`9GlQ)fP59~ z&?rpR**El6G(ExNIprNYd?E4dR{h$*$q2;ogZ$x0N^wiPa1kMW%i%!z=V&hY)e@Zy zFA5-_W;!OAK!a#IhICPJ!m8r3E!Ux#0Hj_0(Vh}d%*qalI2bopR!p?vR{P@y(#iaY zDv(Ld#0er=C4+6o^?4K%8~VJ8=~!7EY!HO(in}xnQ8zWZ`Eb?Wfa?@RKKptdlH?I$ z(1SbF68xb&rgkkX*C-DteOOz<$L@52^)tl6-u7Vxy-zYT!aVTGzcJNvI#u;n7cY)X z+%8r#3OfcB7NT?_=wP zd;L*Om(eug9o^VgVVsBJYXgMdpZu11=_7>XIVb7AWPp&HlY$q;;6+Q#!pHdK-b=Na z|EC4O{UhS(+>n^2UK7|TX?ou#j_WC#??XWF�J;YMw1lGW|PSfl+_DYSN$wk~qYTLqC1bcP9YITf?sd{*IVV zj@MfMHp~y*5@m~!gjcZ0yW;H;dVu$PLrCp)T9M}qzAT^H4BRr0B`K2*R@99?aDY)izyuq0|7Z;mJO-!!mPrYm8# zIVQ6NdX4O3l3W0T4Vow#j2Z_}+}A~eBv%YA#l#sFtZ2#^xbp34Lk@-&k|C;$zK(y# zk-m3Enf_&^Cf!aBF<+V2b@1zCtAHBW02HhC`2q6{M@t*nDy7@%O|_3KU}ZP|)hLJ} z*@}K6q3;n)RDyfemWsF`c_A9HrY6UYhc@8vc?2RM9B7;3pzbXUznP>camSuj z^!9y_Ew~&EwUTK7bC8aZU4wCEMDGSa)P%X!0Aao zYW6KF?n5lycJ3^8)rFWV&{W=Fx*+i~L9J`EH#uiQF(<)@&-X>xl0(pGFQ8t2A7ggb z9lE_UfS4X9!voq?!!gW|dtwbyB)-2hV+dN5GZTzST%u&O6*AUcR8 zeH}muDdA{!E7wOlleW?wTU}i9Yb!5$g3+J5?z;~5)RN?mxwn#>nwZj2eZav+JZ|L@ z@Q>hJ(CQ!9xJupEbfT#RdL6rQj!NO>Q6%%qE9nDDxcnUP1$?3qEkWHR^c50p$Qu)O zN#V|)%QwRaz5J(l-YG&&tM4hHDCJJRP|oe1W)8>9?o`l%jpg$a>wnpM3xNaI@_+#$ z{Rwl|r`@Hnc-f3fPW&-x{^C!v@SZ8*Vuoe&!uYZ`nfqvXar_)Huv};2u(hTUJDnEH zH&NeNDj`5b4Ya)f2=R%bhM{O#elZw1k`UET^$&?E>y_YFtepa)yQ5qd=VKnb)2)j% z6=R;=>AJPOT6-%z??rJc(et~Ufn|I__H_8&2;*C;%@ptY(4XB-S`}N32YpRG@yH|u z3x;CQO#^})S zm+hyl0U^4|kpxgJB3D@fK8j_T;KQXv7Cq$=^z)5Jcmn<T@;0 zQJ;K!**<-i84r&GOGt?%cmT6}$1zo>(9lBrrOzOt+c#wWm`;6lRxGwKZu{ABB8g!z za~>Jc+g7KW!n?qVdXr~|i(no^e7E&-<8AKt%G;#=Wx*3d((&69cgcG_<8H*69m7_R z^%%ySa{Z3MCC?)IVPAbnr2K7MIQ@fzo%MoEw4-73w~A7#tIR_8by?EW3UX-2a!vw7 zJmtNPnhA}0#db)DMlHA;ht%fewvczWJ*p0n*f|a~-aQakI|TK{*LWpCcd@L;O69mTcYWBU4)@fZCLA7UgvdXfHDCJZFl{#O zy@-WlS`ggx2(kr@=EuIad_g_+BI~3@?lF+z?4#d3CkN002V|UV6!m8=|B5V8vO$Z3mUvzw_Y3!0+ zzbHHpTWx&?mxq7$;d>YuMPnAWxa!<=RchIUA=G+RsFp~rXUz*EqTie#X__KP-GDbR z!<}@29`)zPr{P#RD?A{Lq!|($#PhPYsyCia4)*)ljn9*I8C+0aP>~f>v=GkUTD+Ts7+2Sz}ECW1UayxwaVeSounM7KX z=+do`o&Y$uoZRVaMj*Cmh5rvYF=I8dKLPb!*pB*$Pevz0rC`7y&wT`Yd%EXDkp3T( z&(O-2kizG{0JDO7-#LM6S}|XDKW1P#2Vn(`(eiJ@__&tvj(m+p$CbEnX%w$lgtC3QzKb)yvS-!ovXIvRe}W6}Mep zoY%D&*y2^%GNs@{MG-WW9aUt6m_vmuOm znVxm=4O+AVlLR!@)$zCka!b`?bD zwWOUgXsd(UhZYe|0naBrv@^b!`9P(K~9fYS8*Ac8)yb+S?vf2=7f9eNTIyq zk?A}-M0>tSZK?}0)W~9Ebdyu{gXgn<*OCbQF5z1>v7+X79O>C8;}+{ z)FxuMO!VNr<;`Bh3mHzd=K1F&y9TSe@R=VsBh<8mNmI{9|3TT!keKJT^uay$v^=V= za9^nh0f6;v=*stb!N2t_6UcOt z1!SwKyn@FYukPf$>w8?IXxn3_$XHD+y`!#pUL^9x@e*-k&Qma>D{KbN`BOVeqw+D( zpWTK~M-X8!Bz1u8shF?M@li&(wya`ImnH8~sVtdS*vF0ARFm(d0XEc3mR0&*#QoLJ z*9p48jUqgtzupSve4`upZp_fwu_G^TNhQLoM6f~`+gf()BF(xSY{G9|xkW;aEm>fG z4k2*q#e)`E5g$csrOWw4#u20_ejZHMN+DoU{RbiFT+p^1JKrDY%U@nLks&eqgSO{OqE8jm zQCl7KEy*XG@==jlt3-G=*Hxlxi`4!=rS0YNN;tc79gwU`3S zcNXW8+U6FufRY{obQ<)Oh_vh}ulj-AB<`eB2k<7}L_{)gw5Q+lJagA|B@uzIPGdc< z>dQ*<(M@amZS@)nHRYh8D}_i_mLC756WirAJsz($ezb(4E0uO1XxH#Oi{l^oJd8kS z5ka~2vGU`8R^+?8hG71ag+iBn4aW>5Tasmc^>`qU&lhy5Lm)P-8^>Uz6cN9W*kCw= zTIU)WwbC>U`q4c}hPu&Sm#8HEi4X?Bc+|K*d(|x=hu&iNF3QI@cy-~tchvW~*&*p? zrlc;2<_%KZjVOm2|M|a=QyBFDB=lm*u3>}$_kijAa0O~C8x^o+l4O+3Ny)JKX`Hay zkzI2i-c9cA5RbXPe+76MJgP@NYu#iv`b;bQAt}iUQJo!Yb!e4&(QI;Ih*>A%-M{`T z#tss;mc~#=D$is7Ou&EBTNWiA(V)+Pmif$i+TBaUc1>xOY25LlUh6Bm_RS}VKEtZl z=Ls2&sNZry{_|fsCs0PVvh(IV-TvU6Y*#nr>bQ4S2{7X2RCis+VC(X;iNFq|9q45# zE+bvy2O3>d)Ng3)9M5W$?{~sUVlxsZD|}2fLth2(G`)|8EV*{O-6W{z49Cr){4YgD z#%eCk9di_5V$C|_Ed@ zku-t2=<&7A(KmaWehecwGFa#KLc`M@JtF}mJkHs*dHNok8&TuotGM^{iPtBfIQ~tT ze4*08eGwcg$r9fBZcFfFI!O7g;X8NM!#WtOzVv3WB(U|+M7BDG@sKcJ@?da~yq1F0 z-oAhn4;n?hYlyuedf7F07=~0B)&(%%PffIyt;Dewd&_dw!0xUVgLv?w5VF=&_{9Z) zfiVdL26HZs9+;OM_axB`M%=&|naf#T6D+WZwH zXfL{G##LXe^+)v&<>OOoXn!gMS`o44QAeD`qtY!YFQ*e(rB+2_zkhd+c(7;scj{G# zf|~lMoweso!}*x+UQZAfIC`L+{`Pd;Y~Vq4w_B0R%e%yY0*6M_^=K*GUY3|}+7u{m z1Y5PVyxedKHWPQ_n>&hQ ztFs{_HAP!Y8?^G-?h=3$BcK7~zl6?qNt`&)L0zxm)d>LvmWV+PPV zv&biR!7_XtE&a%;b(Utx*d*KQuEFNbuWS&bFD*THzQB#uXnpuXM}Mll!H)Q`dIq-P zA0Xs}pi@24K(T(LGYEDb*RU-ne^%9KcRonxWJd3K*OmQ-T6XNL{5D8`jnV1mOTFsM2>Nn+eaBmZqo!{r zS@HeX>XhygMC=a^`rd;l8j1{g#?qU*2>2rANraga>(Efm88^gzbTF^2Hf}f*{Zz}# zPnse;S=v1U4Gt7^Z8k9!8xohlJq_85UFd2$Z!G*?QM38V8SJ{v+`>i^pqwG8Y^ngY zyz4#eqv1t9&{5!`X6CjyXQLtvm$ywwT)H>H>=$Tl%hYod!05B?EZ^_M7eM<}6~noq zC@|Sr(AIEYce3U-Ys}TWqx@KdZ1jUfw&6l#idP$iZ*>FNG}yqMs-5$s%>slm_^zv4 zQyeg}TXV6Tqz6@SID?}4Zb6qNSq&&11!Oc@P3{4jeis%0sk_hs&ib8}LN2yw*w)w) zA0y*B7TWJ!qL}i|%kc_Qf>xeSpVQ1FDGB+F*?Nmld$g@N34@z=(VBGhlUYKul9@u3 ze3us{!t<_X3ZMC6?JV!@0o|pY9m%T=eg>HDxksn`G<)Nnru68zbVq;m8;B7^@zaA0 z+VQ|cNlx;{iI$C}xRD2qMxHs6bzw8dh%3p29c}yr9YWSV+&=M0;b%)iNWQo_8^bXA zGtDOzqY6JYf0YTfZukn`LZ4Oht3PmKb|Y?z)Vn;Qwr40ebyRgp!6fYJN3CRC?6gU7 zeWiK>0mv_ouc(bp1Y6W;wr)Ia36Noyyp7G5FA#lA3#Ww-L7p{J8-eeMEnn*&HO;nY zazJ&(Lkg|W=r8<)tiElHiuTYUk~Ez{4bv6_F=2K!K7IE!ryL??AcKf_ja7Fg!rv(_Ml|@^`A8dq(qkX4 z)c>*8AxvGq{_<^w!RR~9u*+xfx+eDl%A%LoKP{J10x7SV9S_RB3Hr!pNzWqwZgHt1 zb!(a}J@$2OVl)5jxn)3Pjc^DW7il!yJb|LwY|K}%yCU#I00Fl_YIB}JKD_%s=C^=1m`Da=7xMl zZnU?%cB~%5VAr8{&tTTcU4Eu@Y=@?Rj-=DwxdC?5DJjehtA*9)+>}dR28cZaPsg7@ z8y4>}(m5#wsP(7OW@%P0sc(0ZX-QrvGY#~9oS9TVYBMvtvTI2Lj1}1Q{s)FH^xfao zh{G1F`p{+OXADE_{3>PnVl`1oM%A~@0eZ(A!wrEpPt#81{dEz)s>$o zo|TB_Q}GL0H2Gpwd3ho8LXx&Q?y^6#HDT$Nwt|Ozht?Gw-I;oZKBFAf(}c9{fedNk z!E>zb+3|e3H%lCN#Qzp9y=+%acIX-}gPKet+{}o5SaP^SNgK ztHjuD+u|6^(sBCaj@qp)2OCu19o6_d2cETPHRy4CWnwZx=T}a0y!qJ9NN=GCSNYGD z-_YOgRKumA&px7$bAAgc{9f*atD^VL$Xg9zYDnyGD57{T#VVxz3J5jaYf>XFn*peC z0h;{Op#l67#q$Yi?`}H(u=ym$>WMYu;L?I{twR3g9zl2v5@+RDvvX!|eXB3@JpqAe z)x{iIeb-kvPKniRJP(l8H`MD7o&_()yIqM*+3^f}a?afZue5dV+#{)e~1?XN)vw$FfaIAONNUG&suhH1$ z(}=k3Qy0{yl=FZdx(eKnC9kLNSOUF#uAr{}ims-@S0=2V%)r&LBvsd`i>a20uYx!Jp8U_tHe2FoYhu#Ma3s+ zIVg_Jzcr02mC&kkW%D(=bJg1nZsM#CHx2HoSuVS8wmEU@CpI0DfwH$=FDydljzK^9 zZ!x0-S%t6cV`VFgv}e}=lgEelG!8{{uG}elYO1qw6$FWUsKql-*QhG%fx=Woe2K1I z?SWsC(vJ@wE-9EB=zN%d?&4x#er^(D6~4o=Bo@kBHJvx*HHbXW4L;{F1j`md)c}`^ zZ-IFWAF3$f469S2f?$R)m{#d?nsCZ#b9Z)qhoDu>(+t|F!Fve|FO8f~Yde^7_?e~} z1IivZoI0rW?4``l;c=n%OqJx`oz_R~7o|v`e{35^3R)!OUH$ zl&F$$w$+kLWTiS0(KuwZX;o0)3ubhhT41@SO0EO&f};C|c5a_uQx?JoLJuzMye1s0 zM)3_oy~hsI0oxC|Dip69M)jcDtsZ}?eaB{Z0s3!=GglKCc%+0a_V|>);GA& z_oglS-mLnORPUl)VOM8Ai8zn2ikvKcn`G0?xoN&sMwAzNume4#{2%741L>xJk;J=k zwUBXCw_dk-aaJ6GOjNUh{$-ou;kS&4evYY;ha@uGv%N&q8&f%Um(Ts%Q~I0)h`5T| zIZ?76=SN3;FZk+~kK^=_$n{jt)l}eCTFYTP!xnz@u@7VP9__d7h01h!*MZRUK`Ghp zR!!sSdXp1dV-Inl(9y)D`yFge2tTbw2d|5nFWI$@JxXm1G>X94D1xKjq-a?iJ6iK- zMw|{`lHuB0`u}2!ZUS!v=1eesu;!4FJF7MRl1|o57;al2ouJ^t7YKx{otslJAyi)$ z`$!bX0H5Uw^Wv}54Bf=>n%nD%o)*ASeXWBIGiFQ0kUT=j34$E+Q0qCd^N^V|t z{Gcv!*~%qOgY;Q~*7#wBJ+5JUk?wolIE09hwH6gXc<4#3ux7|fN>~5Aj^||n-lxr& zK#(U!t)1pIFRc5dZ(=vmFal0cR7toPvN{&^r^k4OwT@dJybbe>phHj&X}X9wZ{m|q z2(ptcK%S=`2Tpp+19CSLa?h<+O}qpa&W!5748ub{9pfK><=>`$QD1keyJd>7YC^q!1=JsNR*w9v`I}pBplv$Oj@&KyP5F$&aT8x-wJjoCJa7Q^}gVHwt#nJ{$BKAcXtV^ZBwBbnRSQeMG++_Dp9lZoleUD|E#pMRq&yv#cj=!Q*bE z=PkP35FQ5+qSg+EEiR^uY9aoQ0KwS%x_YM@l;2JvmNyh**~712*$+n5!ppA&Y1iK& z7G~E!*K4Y1%RN`}0*(S>OZ6(mJpjrP)2*HTY`v1cInC|!#%bXlRfpG#po@!b4+i*~ z3b5-8&(-%$8{(C*aR&)tqX;Yaub17>1r>HheyZ#|v^ukWY^_&1P{zvtW2PeY?dVM`Gv_IA?be5d{ z@lONSmiM=Id^|LowpVuJNe5K!L@Tgg=`QnP804K28w4JT`tS=;R~(RrtTwj8sw074 zG4=)62(7B^^H*JHoqn%jjGPrT8iK4^_{Rn zB;rE1$_A1EUc!TdqmRoD+kU)VTVo)KpB9F{vHa4_uG_m4P?%FF4n-v=D5W!nwz;Mv zYvliF0e#De5y09pP4O~^pe<*~S+eswJx}utA@fP%bN^Vg#*K+*mAT9#c^ZQUmoAc0 zsGIc}-ir@47S?S}O1op$(^E$oW&7bhW3cS-jUWMvpw=Oa4!tn$OKp=R_L zVsS*TD#;q(&*2uGMnIdw4g35?%4Lq1$i|`e`zMGqH=9KZmryQ6%Z=v;T+O9|g4AS7 z##7k9@M#KaxO3L;18#aS8Hh0A#M?YM0&J4@w-q*Mr>M)YS;wm`$p9J(9JMl7M|e*W zRskNnjg$Ml-k;7e(P15*Upn&Bpo!*UUyj1vmJ}M%%stcc;ZD%k;Q3PjH%RQl`#{q{ zi;Ig2x1I4=>|A!3QjPbsus8{tS!G@k!^R;zimswwRF|63K z&wyer#wLz-!dO(#30Ld*k(~@l0cTsiOed13j?dZf)N4cPdmN0H{wmFN@(I6)`t7Ph zKRb!}mw9g0Y2WNKx-0Dv40z$9SQuXYxMQvUPf+($9f*_phrWIC<3r!UW(=i@`67Hn z-#sG-ge-Qc^|evPPTmA=W!w7pLTSVktk}dfxsQXsPCil?@vZ3Nm$vJ6XMMPwhi8>{ zYT93R!besj41wrl-Ho!SEzLWb#uBNu`rm6svqK#Rv0+p5XNv-Ql4{_Ygp%>((+|#+ zdIa{U|Gdc;u3M@6TiX0@n{8m_tEuTt#fRYba>03kTEU63?kWO5VE`TEf>(QoEJp4d z#OxyC$~|f_5!mpTtVa%x{c&IF#x;%S(=&k3`5|u2B5>yz%vm@vw^*h}R507uYL+z@ zW7T6-C3+|w=>#(LnVlHDdFj_19s^H{kHEbJpF}M>a9v3&Ok+}Z-gs0|w^w%Fp?{wv zN*s%-u~#rJ4cysw)yd298K}U%*!&@JunyN~7;oaq{`eNLyiB@^ z{ZlBvC^>0S5-IPb{G8LfoMe}rm5Z|2_CUPa3})5aI7dDVIW+H5B9)wn-jE3Y(G~N~ z^ce!FA@f|qXJP%q5!68(w^tMq$uiM8+p&tBjAF5$x~ zp`+v6RhaWb&G2K&(K5){T=Dn=cpHdZ_F&*%pGM#czuZZ8zmCV;zCgpX*1fK&RQJ)t zYfrIP4Jl!!F$o6O)%7m3Bx2Co7uD}$d`=ng|7be%x1`hd{f|*HJ*Cw6%vgj_k5kM{ zspGh!fI7`+jic09SuR*Qx#5l`AfOJVmMJz)nHyMCWNw*~OM;dQnhToyuAt(s2q+-h zm-!yY=O5q)j{AMy*L_{*`MUObS@abxy}Z?%<1^Ke*pmmEUob@S4$n(P(-o&>zqHqG z%P(U&lG&br3|q7<*K_H?_qGaRu~DA@2DjoFJ)$b(JZcxPpRSqdU_r>&j`N~y(Vw6` z4`KQM!_VpT(sh$3!I$Fh3d-OpM<17+7Z|+dlxVU=yTD5sqahu z?gAdVZy!qaCmEz^Dv`GoaAX`4ELm|o?G?9FWW&b*K9^bPO4W_wl-jvQC55m0LD?fPx)J z6VD*vrjmWK|0I7I`oE;AXNKBQPo9T-9tCdqZ&%$;J5j$e)28*0Y)a`rdsjY>Z_e8M z-b?i^dO=3n=o!euU#V}1$2vF}8^jEm56Q{+msTEzVVhsDN2WKPKfOrt-TbV%^pm0dKflL7mWz5+j$?s{t^UT@A}`842YD{t-cR#c{&t2~ z*}3ph6mV?tg4D4t@TRw+tNhLyp|!^i1_~&&+fFjnwTRC$3NO6H^jwlrl2ZNZ&0?WV zSg_n)Ky21nW}eI2Sm8#FS4FSfMH>$Fj!V})4?xkK$06m4`|9I^WZ0Q}x9GkV+%oH~ z+2VNZ{Jq}if;l~rS=phAGT#e++_Y2xrI_g%8{O+<9esSewPn~zUq8>Or#&#u;t|T@ zla3EM9jYn%zvk;2*j$KFrl#)WIcuOV`B%zRggO)N`kef4&%x8wu{KhIXv@q!oJa z_&PTLBp2<}iHu=Zj1*u*L1@n%4?llwlG@Ys9+s%x^?u~V3i~m=T2j+_+oWo-2-oGa zGPB9D_!Z*{`~08IdS87N~==qbb zP-~U?WymX(5DhDY>|6LAND1XZqcZufMo4@u8F6a*o+ffIdX|+37H1yld9(ZRZrZWj z(!7pAN3W4h_(-K9%e-kOrM)~L%&xGv%#dnWiagS(bnMRbXfqph3eG+&d}lr*^xR7E zq!2PZDEF@{3i~xv8f-SOIzgbXb8BBZnEWrQsTv2VJ5I(^&dg3DTAa5ZTAYH}kw}(V zICBtY++ynNtZnkse6Kvkd~Q(|!Jzr4vRcW_CIH?;HjIksTNZ$*LzfLBy<+Z@8=ygh z(54kTX5Mr3%CYqAT%P;x;XbyLyO)6_6ESr}nfA4B#u3{ZayibGBS{3ohPE4ctsM44 zfxdy<&)t=?lrx(i^+v^f=5>eJOOE0f?(XbHo$Xux|@kL|bfJNCXfvsww8liu1xBtutNW5qXK7LR024t0Z5o_mb1(l6?0 zT)?<8J+n0txuGloJm*{><^e$@Xj*aZDP38e4IhB4@eR37tDU>f1MSXWbkS5I6C#&~ zl?`>h=Ot-+I8y_}*%m?7vy)d6fwCtpuhKGWppefUOV%SMHR%Qn@W6ZQ&;2hqzp~pc zX7X|zAbvZ0xU*of|%y*S`K|MSVHvFP`*&@g!y;+ z0&C!e$gX+8+L(f_&zp!xZZ~}JyAyzw_H_evZ}V4gV1i4nHE>|iAN)GO^irb0J?80( z8oZ*li!N}1RT;&Jm3kj?v z-lm4#rmMn%iC1W&L$Sy-d$U8yyLH7j8esIo)S3SIo$BrDeeZUc%gMD30|41lq3Jdl z{aI7a({D*;US&yn;w85+(}ROUQU$3M;Kl8;_FlsLULW#O(?-k{eG#|UV!=J#3s1Q( zRSUKz+B-k9GzynyGGO!Z&9mLjFE4mQ6?`{;EeZS1-0hehojXDT1zk zg3XF(kI{)bYSJt8u0^XB;*c?{S|?4V2f$&{FYZi)clpR8qskrX>!V{= zKnBDfgMNJxkX-wtO}J)9HDTEEmidRa@B9T#@4e6(a7U<-@O2T~!(rg2u@xl;F!^$> zwalHm1MVD-c5Fy|PHnai>z`(!KI#F){O?>3L_8We`SzdFk49RSVMeAIxIVLbl5;^~ zkq`N*P>8Ub*WizT;CsOgLro9SEXD)-EKDTIajTxbio*&A7em-6`nB>=0{qILKQF;V z-L=;7U>Z2t@xY{$UN&?Goy^M&3B`ZauDvCFPHCX!<>8Pcu7`l1_6yUS>gKvX0I0}Y z0zClv!X#wyNJ?T2sRb%*^D~jC>N-lCpgJk>K~4KmgL)6?13d8NGx4XFRD90Db1^@> z>CVTaD|Wh_BXJuzjf97-TMxLhS2@IJiA;-|gkVg}S4&^o2rKVz#0E`&Vl_W9jD^i> zP3El6_AYKYo@sah2-$%T{C@?v*>J@34$nvikvz?jq>Moo11I@IW$(lR>8G=9k&h~y zE8ORNgOA);7;0;b?HICf!X_Aoc`x_wP4s9}>CK zHMFUvf?j!9$Z9loK0${d+sQ*NZ*uEa)G2T zwqFb?V$(O$P{o*rN45(2zVE2TXzv4F0>sK?8J1+(_ zq@G7D7txdRbtRPhvKfb{VUD|{Pdd{t$YAr&XM8wY+>>GGK9+ZC_p6Aenb(lUOIB=#=Kb|zVH296Hqtf2cYfImJx#EZ?5`i-ei|*rl+m^~AU!l_$>AKiNdQ$z8 z_nHlNTPa}jJpf39G{)R$jmL|&|8BS4pa0hjK-qA*9A>5hNZ5PrK=4nFNQx62 zIi~R(qM4hm|DxbrH#CI}^DyMB=u3j?{x|7<{o9JjvZSTVwq=D;sp zk+eQbcjvWe*(ZGeeqjCoY&KrSx4i)DAew=iG?llsz5ndwyWJTHATAUY-JtB>`mSn^ z09m_ya%zvU3`yDW1TdhTaacQz8Xsv^zOVU>sW}<^8~v>Rrpz_A~_08~La4igdaUNX@vAoZ=q4fu@vva?{Zba|Aky-mF! z26rVU*cf#IFtpK&ne(+Uu{2KikgR}v>69|cDLV9B8DgUd0~jnf#jKf=QhKi~T165c z8Uh_h)W-wA`a|o_iCxm!!tfhdWTMp-h|Xb{Wr-$@#JP> z*|2e=_J=?{`R^t`Eku5^{p+uoRLNF ztsMS(qciArKGG1{_0<~&Z#Sh(`=$HVebB4$e+0<^%S&=9{xvkNM;f$OSRJ#&+TkZ+ zQ|VXM#0Mf|zAM9K_(;>&)EH%KP*0lVpZa>b0d*BL6w5NKsJmxfQ)*^`4 zHWbRdIu~?EC#EktOt{GHH z=52Q87E--Wzygny)U*^C>EiM}4gz@4`7KmZDnNKB-myF<+|||#A4%T)U-Z%X(33T9zA_K7hnFDNHCQ0#uBjfMN%|8$y{@IrnU@cG1am~gZ8 zdpx0|1Z=n1c;(KBY{@1<-V3wK*~y8RWmg6Bt-wJN6G^iJD0n`g$v|G_RFm%Q zokc>eM+6q>z1vVUC&pjOgv}pqyb1IzC{x_Wk%Wm}wHGKXM)NjLnV{SKpuNgu2Jy{m z77lG^jdeWa^QnaYQrvo{7UPUNZ;XC+>X=NgYvi%tb#D^O(C)1lp7po~q5_i-L07-D zP5>IF$B2*gV;#p7#m%NqJDrXVHPn}VgPw1^8q?uaP?97H_$Sliwm<-HaboJsIm}+m zs;+Bt@>?7Qs1GO8Ej2d8dQAaBMjEbb<-&%X8%j97CK6E=8?FjzRXR3$9c1d9(>82B_{37X_s_xb4q2!$sc~Zq*n*#p*kR{8% zyi7F=;r#?(oEHSgzu|en6S{tKV(WjRrJrJwZ)qAThj^4%l2@iKy%1sJ;IU(>y6mBy1_04JkZ~`O zdgE59x*Ndben@5*cd_f2(}=%XJ5w%)aol{oUzR`crVoGDmF#z$!)SI`E1-61y}i26 zdkNM&<9Ps`ei$_Lb#?dJ*A#M9s%*Ntd$tT50#+CDfjx)KCimG~FZ;pWn`%Qz`{?;* z_FkKnw#v^lAZ&tv}EkUty{TVLJmrx#uowM!& z;1Ml{*9)RLSb`*uMM`q`PB|W(Ll5S|VJg4VT_6>J=iQ1GmdK5u&ys4?eZFfRPQs{# zWludO4Q7joO4^FQxw7U1J zljhsYJ*9#1asAgMB^J{4t-?|K_-}w!Jby%`WNG#9AylF z=RJB~`k2OD!9CagoD(*?0IXm9*8N}<#%iVgsderP^ssu7o~~y%l_pdtoJ}qz-pNrHy8CS$Us@ET&e+grdQ3|l(h2FdaeHIl z*E(|B%1@CNlMIuYAokA(Hwz7P$Rmbatf#Jw3|4RU1crHA)K^6=7uCvC3U|WpLvA;J zuHnY{KrG2+RUah8owtEZV zc6BptZ4*o0ka_!!MGduYSNaCiw!~pA9O*}YV%tQeQH+@uM^?taGkyft)bzo3<%g0j zTsxnX0IQ6s#1^QzgJuD=#?l)U0Q<>3IkbL#%OO5*rq|U7OwJ%tbTy6Pxp%4p8hhXq zW2qUMZoQ#QbfwE~_yo@LYZ}6hDu>n1@*rU=tK!N55G=q21YDOVFl$WlZ>|5WGg79{ zR;&J$%GEYm2NY^&izde-D|RT3WOy^1yB8Ao+?_Z_fk{MX7S@xq^#NaBp7Ic7Q1lJ~VwOCI6JzSJEZ@f*bNB6~&*VM)zC%uNFNb z0s+V7=ObZ8(m(5yyerP?oIdEHTr}NkPI4Mc}g{9{(YS+P{~ia74*+tj(MC zL;yVXx`~#)%x|xzd!k{2Q!M+WC7UIDv~leIYJ1zfSNhDJiP7ATvt=)9SlGUMj+`RL z@U56xhfP;6Ot;uS9kki`vdV(G@sq!I)U1QAGYjmjRzwEAKj(zxpmv9C#nI%serS-4 zN3ECrWu`&Dycs0eH)@jf&KdQ0^Ve?cy|De+#zA*I zm;UfFPUn_mDD}Y0FV0WmY|Lkh0Bs_7zw=f|h?n@NkDXZ0Y7c`tgj$~&%pnsvR(NW` z&0>VcKkPU{t`-J&YaD+CCniVkM+KEHlq-J_XaIH89v2z$SQIGN0t+ z?xXJKhwu1?PzE$DloVifKg{#s*eZ_O2_%k)vj zk#YyVgx_f8^YeIoO;mw6wa4S$-J-i^d##1{rZ@Q+CWv7@)H;3hp&hyIdK**oCZk~& zPL>K}C%*a2Gjst{O8o+v^L;VXMU&E?l&IYPi$b{HNQDvra~)mapR`0(o!4 zqhFP(ExgGMo}TUlw6icfq73I1vX_h`TN=Fe^S^x&OOBe&9`wU?fyA@CyA19>;odFY z=gJ5hIM|{mAVpKHLnF6J&N~@#KWXzQ1^xsI3e_D2`Ec!+{J7KSnDMAToYEdl{IMom zmpcFhvuVn&W`y>FVW+b^i#GarS<#|!zF#%W;;gz^>hE91yC^ zjI~Tij;e>byNbvgq49sGiqTgowUT4aiol$+C4RiyA6bCQaN@8T|9riMqc)QRXTxwM zXv>^lHzzyRSd-jMs(iB5`x;ZNiBJirige~sW4nr`#1bFsNMSQ5c+atVx)h)*+es~v zKYNShL=FePyvgvg3{%fJXBa*bbwM4B3JT{^8ufLtbEKms4`HzI|B8W-2GfmSnW#Nb zis{Z3-jQy-SY)%LgD$GR10qBqqxwG3zG)l0T6;ffc_PK2A4%%|$ul&a>m;Y7- zHE54)(72-SL&u-q%JF|Q|60E6XDTRI|ju4HGmk$gz zy9?UxvtJw*x{u~49~EHKRId8MPH>H=6)^bssrs0gpt;KX*%gVWd{*@IgF`isPD=X< zPLevR$Nku7mY&dV0s?gaBGDexn!Lh8oXZBOE6|v3M~+!ev*M#IyiR-}S9?Y4i6h zXHtsbx@24fJ52=fw9z$pC^Pgn)hJYBVClq&hx`u*KJStdC%DYo-o2Dkf*>b)dEv>( z%`XX?zlI)(R1}|wbkI2Hi1{Tx&(V;|z1v)0gz<=4;yngde&~UJ`_^38p6WkGS@obt z&BK9%V?4(}^4^;3@#{gwD`(40N8(BL?ScQ+;QVr+2IMQJigs@jVzFHYu~sIW6V zZf0Nn#LV~( zy;^+%FAG@B>!B;a^P-yYo9(Q_p2o)(%Pj^oz4Gi=9@s^)yc#rb9ii$aMV?0uTaz0K zLY>0;+57tkaWZ^P<>E-3Yrd#=kcJ(yDCJ$1bu65 zxux)D;n8M$kfiW^z{Hw~4}HD=)h)r2ICyHQJvI;l@T z6-M3}5^r+5GtRO`$j%K#F-y?a1r1u}Z&oS(sw?Uds-79(9UW|fD(i}@pvK^S-|2{5 zm_EfkWsAN=@^iw6HkDZf{aPgu>BsW=kLYYy3y3Dnbh79r26ENrl&^D54`)!-<34Gk zJO8dUzwBp&Zo_X|fc;rl4dgFD$e5(j%>9@{X*60Mnd?(gHEzo^#r1`O5?R$E8ilHb?+^Yg6n zLMkGRHR5|=u>2Xze{uRTjciEaH=U!$SC*++R0Yd#Y#%tPkM>bKcy_HU@1(|fSu^k` zuh!UWmr-W7{qQPG42oEo>f^f9rB_EsK4}?>zVBN-ry75Lnh%~$Bi>c4yn#D%{4m>&*?zr>E)FhCWdUAaintxSK2= z{*Y_wpiV=87U*`hq&(VbyZOKBcxXGL@^kCcmo@nBu_~Bqvx8^z*1Th($j`N?OZkKB z&vebdLyqb9D{{jt?dyWurq{L}(vvJ+H+SN~IFU`bPH@#k$7%LtR>Y?)1p0N{%LA%o z&5uY=%iba(U~-{f7bbIj?N~er7Y+MUFrbjx8_mc3#AYW)%ryM{R^G}vCHivU9DpAP z-8swz0S8Bd?R?et0xy9~6CNfvBp!yNkweV~K08}ee>Ik6mtBuKZZzb-b>PiWvJmm3 zWqecffndg%VkrfHcn|r#76k)-@LPH=z8vb|dK|q2J{_3Sn*dHtGxUeZzn=K?9p((B zVsOejWyu|7xxNI@93rSpu1E-rTA|yfFFTazyE@hg3&YVP@j2ERUP!fvZTWp6LFGT_ zp}tm+?JT02|MZcc_{vSBp0f^q<5Y0D1{Z_lAx9oEEq*E67;fvqgivuW02=d7YVVan za%g?fB?gj5QU@mBZ@*Q1Nzv^B`#=JVY=u``D4TI`ea9NLtRg)4@zyyVm9?P}LReVe z^uFjEi0sTy3?rl?!tNY#^$kIjRLz~poo17_+CE?$Ww|pmb!q;+mu6(@n)%uCJ&Vlf`Y2y+f zFWqO*($T^pk1}C!TeBeaTN~lKf=8%gM9(GLxYUYg&v|*p*{4u15zf0GZ-}meJV7m# zC;I11Hs%-hHOW4Hx8*X}&EDl4+X4(Qk$Jh{){S?v(dP=iBwG zV&}fT+eT|zZ+>M`Zj(-sq-~QYDJT?{uSoa0=DDT*?sK z{GH!;NJNaQ38iInX{zx7o*NMh%Z=s0s8^-rf*Z@C%kE=HWi_I7hbP<<24iVOrQtM*tLXQU4@dbpAK~H= zCrWo>o^6Qc$ugzcJmd)|^>|nWr*7wY@kTtm=sEgf1`A(6`;O$6YW%ilK6`jl8dQdi zJVZl>8S4gn*iG^>fCyb2kuXr*mpm5n!BZqWyXlj@>x78D(Z@!bicfN{KQnwpvM^@q zG6|}e;=5yCz*JPlQ@z!Zlw5GIdnr09?9yknr8ie5-pl0tn&m?Gah;?+-qn~JuzQSk z&qNB{drgl;HV51FxYYL?NsCfuMK>E*vA`kf!%Y%d;jOZ3c!){lR2JM$^-hx}S2)&G z4#|dEEN+j&Vi6YpJW%oX;*UJJWF?EFI%k02{5zd6P;3ls9R_~3LIm%ja1wO-!+9Wx zUtMh&-oqZI_(6uYsh{~1^8u#(sz~(rDOdA6+??Ud)BvOY0dWdFHGs1Deh7a$;Mqk= z^~S=Pg=tT=rU`^AIGKK6`EXwq)YMP#H6~sdz4A3P?_?{0bb+X@T#US1CLTaqGDfDe zE0j&An>yV1P~o2X>bef)4^1bO7M5>H_A?oWlS6MetuTpqD9!yCwfRgn^JsHpdy3$N z-|~%}o-)*WdtwgjZ7tciVO6spr*l5JQ2?F&$TB$v$}-swH6BzA%Q^=W#Jr=)rICAM zA|kJALS{Xl)@`1P&Df8PS$yb;rGX;wHEBvaTeF@u^g8W`4yv%t@`^5YpYD`>n*3J( zWRr%CMDH5d7ZyPys-t!l_q^uKG+M3gSX4oD{oNF(EqIY@;?QM0POZql5WV0&&Tz^N zU-{+3T}RhP9cjYr-V{{ip+V4Sb(0>eWT% zyYwfJ?y`%#-PrM@z9#-d9Aigl=JT_lmj$s|?0tv}gJ{7KAw--yaO#bdSGk5?C`zmW zle_RpUILSgn|yP{3*2M5=Fp3H-BO6CG=s^%^VivAruh|L(EG!TjNcKy@`FNiOYcL8yGZ4v;6ngt29sO0+1bk7sI1Ap%Z%?rNlFRke{4`rJt zoK43Lp(1YO<1B!%sE*+RM}b?#mgs7I-mApZNwCqnWUp-@8Erywa`IL!HU&IugNN#R-`Nbb zBxz!-#02qBOG}!OKhH$=KoqPKtx$1O>K8>4XgUotzL~T2_04hkXI%|wR7zzzvjyJ zxrvb@oe{yjtDAXgZlbxbagg%BHq+!Dn`Vcx$2kM7+M2QdK9wWlg2nQ4kr*;~4CQ55 zYC6SV*tvU*Rdovz5mE9pzT4zT4~LvkbMy*z*>V0 zi#O*7TD5{)Z0rHr+@2@}mw$+Mx7bF!r!Ou|>s0NV{}~!qyy}Yo{`z;E;f^8o_b-yG zEGi{GCD$&OSjXPA0-(aW&L7LK=zjTra;kJbNEz;dts6HoGT3?XJd<@EOK%z-Yr7fa zC?_{vIY;&EJcn~6qWTlK5SPu3g%j~K8+mZz0JVtU>*QqXKuc}mahKnFUsK-g&wIU) zcM+7442M}zfv>rmAF>u z;JH$%H8`L#x0wns>b@p5s2WgLSzQE5p`(r70lM4*sV{WEC^-ulxkbvMqkja(H#C=R zb>3lzhM@Z2-%3`!ikF_M6J^@sx;zz@n2H5FU`=sP`Pg}ED_Gz(LHcXB;}`u&mt$$z z;My!Mb=4I+rrgYA4VsYaax+=`kW;5NEH{3XvQa)6cAX>mg+^GPd0ujsRMcEOAe>qm z?R5ohcl3pS?P`brc(*fZPkaH*g|GivM2EqU%~tCG>W`eL<3O|UwfjAF3x%gH0Ex-# zqrb5S7gqXzF|=$C@>d?;3Xgx)leJ~Z_F6?jtKGuU6(AHg&4OsEOR$=mLt0mlUyMuX z?Mo-FzbbhZCK)!O3$yo=aHVj=UW4Dh(Bt<`oEdK4x7v`e^rYo_#G$H2=7avAze{vq)tIOk^6Rok z+lwm?VZ$TfR}kl@rRJW3`5Aed;~91pL27A|21&8%Zj&z5F%|~N!oI@i`@|3R*;jry z+gSidCrHEKsL55A`V&}Ed+hj}%i79B zpfdp5B{1v+|7238YZ#4G-8meydMh_8S;W9ZgK9Fpq7}**(Y!7b935;b)z6$Ag~|2(ACu3kc`M+MxOh!&`^p0j@h0 zS*&vYD3HKA4r-JCHpqeJgLg{8yMEQ@#?FjVW@w{3Ig|H!$$h<20CXd&a$r5m>Lo4D z>0reKBI0-%ogjTAAY#BV$iSd4suH#BEC|r-{1+#jL=$uB8bh+Mbj2ENZI(dz*(4)5 zo4KvX5gf$BP?(y`0Ny@m&nu2yf2;KOk}Z;I^y!NySv$+qu951=jt0Jg#^37}4x5^m z4V3hnL3`n~p|hZ1LidnoByz>w;l4pS|y9Q5Qf|OF0JNv;FdeQbkq92Dqs*{^t zDAH|uogXPOqm8U)hd(FEdB2{X81u&8W4TuZ&5X129_uaxkRjrq_ns&0cHNa?^uB|W0wavXmd~39%`%NEcr8k(2|KzB3^Oq&Flua&4E=q>kmSolKHH)0Sk@f(AzPPkF12V1+ zp66{M{Nlpo(|v@~J9F7AL`ksxLDlA&YQ4UNg*rDs?L2iS9hhH#*cThzF@5Gr-Q(5$ zmBCN-MT*+qYxNYiY%u}UQM(+J*piK*%mg0iT$4{fu^jW7?Zn`ASORy8H1#7^2J5f@ zTcw-_$j2w4pWF`0N-CHIZosH)K}c|R z)XA$v*y;Gpn=R6$S2DA53+@!UFd2;@n*}Wgg$u)RHU+ehND(oNbCDH zoUQ2mD0!l2r=TCwcZ_PAqBm8QNjK&sC9kX zOpsDoq7$ArXi{fmv1&Hf`;k9}$D#{>NM#4oCnf`8XEu`i4v(Pmk(J|w&kY>GMe8ByUi#}Ydq*o42l=nO1zE{*H?&>MS>g=XL7UNoGT+S-=I32*9}HJ!~z-ERL46m>|W-Tu9H)J?a+I163? z??=3K0j)Q`2!9GpY8dZctDN86sH%6_#7xF##rKF6@vmBEYktWRM2)P!0wb@V+M?6- zxv1`iv+x)D!{cdS4iRoTBt2h3-6YT6h%+G}3igfdM+~~~+wIv2ca$h`g<$^OR_yLm@AHy#IMvoF)7(lL<>ULwYo~S}U zynsed_O&>!Ex8gWQiUg6Q+8}wOM>OMR}-0QP5N2&6%{X*Y$xh$1p10=vrPj@V>6NF znS7UDQuUb=x%%DJRWACVEeG6Wg#T6_r;b267E`kJSX=RSRhHJfXL9qfsIy$7wynjy z%aKePd}^l2A4O~f?DS0$pJDHtg)_NJ9yB&P4Y-;##)>;{0OCp3slHPuo&1cqzkpZe z8-Y*TEx9=L?tE1=T3{93Meh2qMrhiKl^B^hg<|3&d@cPsG9*t zPdu=SE*1Gul{@iT4F};RRzIE<%fFx!h?2~GvDM29vUbKm+&f^6&LYcs0t;ahCdhpW)GM3@@qSsuq-xlP#2HVf z$MLh{#jK}FrKieb@Qmd8XDiK1A9+2z?Fqm6TlE2r)U2lju<gKiuL z<*z;Vm+lvD|CQ^s-8ppTy5`yWvsx0|apLm}LEQnlUfoC>53gFvvQ}+=^dg$oxP1;J z?pe8AV-NM;eQU+8vpHl%(zufXdJCUCq;-r3+;92Kk2^j01p3Eh#a&5$6*I-oprZ~| zMlOQuc-w<`d?ls3HBsGJw)V$(z?2IyuE;YVah-Q@u$4wsN>ex=VqtCF${r?{AQf#8-T4<%e=7O$nFNu$4UtY-ri9-mu?KyR)lZysHQ~N{;~p@6$lE+vF>1@K>!%ilbIHNY z!JXgp5_M$>141oOzHPJY_~&e^*)=2u8;51%qZzsrGV^2IjcrW-zn~v-Z6gJGt3k65 zCpYHq&=y#(^(%Es-LadC^Gfq9uPb1UQoIpbP0mzobkmaSL3cCP9>TfXRj(3VXyX2w zqtY*8eIL39gtjJ5nuUtrKE4&lDje_XHIqI$kBL);wk)iycG_~{KX3DsA&e{=6ycrw zwq9VM8T|+!H!$Vk3NwB7Xab9^JAt1t_s&}5*ANBLZ|!yh^KWh}tQqCsn0r@6LRSQs zN12nGBwzLZX`B6QnFM1{BK;&Jj7rUE+#%9m*yEUEu z6~O`5CrKK(7c|{TL733%&&f_)ue8wzN&k$#Uu(C2a@I$N z=vw{5D}`h7`I0>_c@^mwba!L`2AT8XHg!X-C8G zZ-GXSfqyqjNWGKvvqsM*?!~sOs}-t?DUH3Z-HG^b#n`^}gZzNSnTTlF<}o4MX_C9# zpKDp3wd8&Ef1w*}g@<^`|xduYVy zu}vIy;U+YOY>#}R-S;wI%up={^~-JH$v}p+DMgSls=;cmhPF!qV1~I+_IGXuu;P)pt6N@1t_LG#=yNcV>q`^6CRLt&+L z*LltY8{8YgaNu|vx-NlvSu?Q2EZJZvLzzk;Vk?;Nvw3w=9+xaQ-qIGD zfw42~TyU8^CA(}T05h<=mH-$30+zd-xIrr|_EJ@15-{x{wKo((5&YZ;igKLP!emS~ zICZWZtVqXtt6E1zN^AO&*ro9pU#d5Ig27e?6m&sU(l+mDAznCsEPo`hWF-h>jkX6V z;4eo>&(dLO6}bi`Z!J4s!Ed{>c{MY0U?Z%?k*kHSxHmmx#13n3SBhJfr^zzX%ke4X z;==Kq6?Ih4k_AQi1vA?%ZV#s7qiyoq<%3a3c?J8O62r|&09Ag@WR48H$c_|H#E;6El3-<760$m%r_}z{rJAJ@l6A>v1;o+*T=JV8l3wBye>MT@U)AkH^ zafA-hBi27AJa_kzC^q*ZmW%aWDg=5n^LbC`T+$($ExOYfw$9ukZv24>2;Wy112C~D zxrhUKpFL20jg~t^&NJoJHP!jA^iC+AeHjU~4oRPrk;~w#@a{iN2F&7cU3`{b z&3<__>0?i79cB+{x+Fl%HPqtOIq;7X@UY6UQdU4K6MeH#tF zHm|rB={uQnBv!Mbag;0BgN@kuk06sFR-*$M8|DpujKiO`CpwBFdY80zZ)q?Lao8L< z28!(O@vMqVN%xmVYlAcDDP}4y15J*io?D0LFl3Ab&IF``bmtijusQBVOOaKo-0WPa zAW^iGC*0)ltFU&+l+Hj@qAnwPalzh>5}C$xvcdPh^-o=x55n*W^@w_Lrtw4_LG>cc z!)D$#t=E?YGYZw~8m5 z2F$=Hdk}8bp?GV~NX*>d#WKd?pEtUIco?&qvObqkCeuBd6#65uwdXGaAUvQQ6#_;n zIgZ|fv6#w)xLT75b9Kl5Mc1hVqn_*Y*~`lJo#E0End&cMmD*}%H*)t^n7;5Y$}h(D zA*lDXqR0g+BON0b;fiop_Ozl41}c4TX0cOVnUsve9KtjK>O1e)y+24=w`@GGk2QH5 zn*>1jFs|5eU)&OPe#0Cc&O6JsJ6P8rVrOH+AUQb|4cw~=OJP>@#+qne2YQ$PuNN?} zC^DHKN63_}Rrt1EiMcp43u#9xAA!#xdzO@!Fn-Y34E$nI*7j@0Aub7x;VOR5N6mEQ zImAn6Ad9E^zGYf(7j{mNQFuZTgqe5ke!G|Xv_H%S0Y)6cblb0<=Dz4+=n{HFVFeaI z9j=XJuR-4G;7%+mbWK@vwus6`Z|K!6I2gfV8|*xGntFyXX~^{qyZk$+3VM^nt7}>? z^GK?;i>%5WJQ|^cIEN{( zBQ_3W)-A%#d%{uvC0z86Yq~&kv02&Mo`BWtKBr{y%`0Epxq}-#nWSEeDA>XS-YO%V zUGq2J<>g&a=mtSvmy^YB7Z69C7^?%h`uR5H*x=(6QOLQ2GOP8el1IkqVNrrEa5JH| ztY&@XsIjUf$)A?9U=fp@eWeRY(oA0Syzq7S+@K)fInD7+j^TZq5B(LQlR73jAzE4bB5L zt^VF2-7l+Q_g#}@pZ&d;acnR~#-0dH{Cpl>Hj$qiv=Saswt=jQchxi$>Ottr>q+Q$ zW7=XX0N2JW_c@19tLRC+(JQ&PSRxNrw8Zs{yFhAVpNu zFAB6b!J`)X_sUkAw)8>rHs%$NZat4=@se$Zt+J=PHukguW%9gq+%GjK(P?AlYva=; zN23M_adq22#*1f;RjziaE3@k@e5sWveROr_xlGb7`EB-kwEx8l2Us1#{@^0PDw$b8 zTNSt&bdP$D`z%H1pf?Nkia5aS;{hV2e^)1v{fIq5O4tdQ?(F>FRAQ?y4acBX5?A+f zqO1(k;tKSZzSoug_BlOs?J}iOQN!O?*Tk;6bigWYOgn~Z)urRcik*8Fv+Kfkoye*4 zFr7-&jvz-`{o)KWa7EU1oC2}8|2QC1rn`D*hI&iXMa9E4FU=5@Wh5 zh|K|AMUUuUIi>(eiYXZ!#dfKVuABP}3cm>GJgOYQPzZ?fT7)6GfCe|=woa_>(75LmMOfU?w_?N+S&bo|P$N|D62 zM8t%zJ}AX0vVrk`O>`4uhVAlMQeq>UAsNt@(bh3V=$@CfO|v_Iif%vYjR-kk?_qqyo3|`mfFR-J4y3 z&tL)`d&FtpKzB=y1FL6N0LaQKx&dg(mE`cT)eQaWck=`hA$;vyASr~1>|TE?ZJ+I` z%CpIt`N@5A!~fagN&l22*A76_vH3b97t-(fW;b^O`|kFKl8AM2Z^NdX`HiHFB@df* z0hb6;Y(C!JJilDp+K)G~x22_SPJ z2@nRE5+IO?DIo!pF#X>Cp6C7nJV5fk-@W%*pS5=9vmVWvPPp+|J!r{JF!N(2uaol; zv_Js)_u(}gilJMQ-q6+_25kD&4_m17Yc_=$@c!+o#REoxg%4qeYFvX3^a^PNAE7+z zIdSV?>iPWLmB3$PU9b*!yM@x;pW|A_o!3|H=E7gzt{Rk27uKBsH!L4ErssBr-Lll# z^q0Hh-=u)J=BQmYOR&q@gLwzKox$iVcMKnRBrJAjmPP&n;y>O5Tj_)qlFBW zF?;l4ukH(*S`nnaaaeN!Ja-U1+!tDWrMkSP02IQ3~3E4xBZg6WfZrk@~@(U!6IL z7KI(&Xob~yx{Z}w;B%>(>ALXE)#hc0I`*7288}InlxB$PF6#}a*A;CUHN~3A3l}3V z6*P7>7Bp!2_HK7>3n+LVl20!y?u!A&$9C&{W3kiVeM`+C~p>p>LuQGWaN7<%GfyWyF*k_ zc|JUk8D4RvtZH-RPbv3tNQzghZfI=x;Gl%h7cO&qIBtvl!l|&ys!+-W;*$fY1Mm-I*vQ)7u2f+U`YMT|sKq#(22Cf3r<@XIPsu z3cb!>zeKk?lUsm-K)W6;1fIq&4p(?Qv^%q+IEnQ?8IOTrzj1K{ACZ+{HDx}HQ#<|L z1tNZ*WUiBfw>;w*wrhzbrLvi$VZ4z(KPAC%k!QU1EM#lr+Z;%eesjbm`PE%>LZ@h# zO(_<>_HJuOZ&5cGgbBqGlxA9x_qFUR@lT3bzrb0&0yf13qF%%BnuM6iL$+RY<9`Yc zd9o5jRb0WNB5@_pIM1LUa|?WMidXd1kMb(rTg4iyj$$tSA!Tl!{DjM{6QNxn;XQZW zt;fXMO`Si<^>gg``Do(t0{nwnLqKogr@(a2VWF6o?|`MGadggj(HiIJ%xgT<^pL@PX zR2`GQ8j@ljcfS%Hd%Wor{=hRfc|3Hodl%4UoInwOJB< z5$#0Ne9WbFiGH@+9)%)Sv3kq$GoY!M{(~H~UE?GIx#~LnTSNY_!=dTS-1jRjlJM`y-}(E$CU~)8PIP55>2+p7_B(Lw7j98+_ds z=@vgQ+2{R`@UBoD#k-bQe4PYMF4o}4o-J{@>Sf1@->M7KJ=UR4H(R}{PY5Xbs)qbQM zT8Dcc?1&l}_@H>}cT3Sbd3g&d^=o=atWhwTux6g-O~bJzmoKo;)~Zw3*s_&?$&$Y$ zL*J7i&BBZPN#8@#rDRvra2a-kf6o78{q5bqOEwrAGl#i$n=k#Pp;qWz7QvzzNbKo0zwW zbW5{H^H@168!hU_Tv#v(DY4N@>dFnnxO|@&zm{ahk5+Mb$h3#kHI%eGFP~OPV`nn- z^!k`2oL>ES%x5F!`C?=xIM=!KRIgn zy-eh|X2BbOEYHkTXs<=K_&I7vH_K+R7yMhI+ttfdio|d_mU6+Xakw}*vaDe9)F|{> zMoWluvXzuL=Z||z&pij3i0z`ZnWiWEBe5sT@IE!q_K%fhj*p>|2}hiCv3Ede0IRyu zQkJ`iwHo+X*fe!hgwV$Xo^Dya3m;JbT(*=*;^{73U`-7F|vuX3om_9P;mi@>e7IN6T6q0vX8w5U|8!N*2=B-(po%Jw?;|5TuIUJN7zLz~V^9rM!E@ zk*#SAaj5h15+u+g;_k9{YlZSt+`3Qr_T6KV%q-;2!pH}-82MOJf}s^U75C}@zod?k zI_DZ$l@eNclw_@JHw9Lr{HOrPaGVnHj9An~$Nkg1y!#l&lA5n93zuD9AUG8DaG;e_ zg4<|*)uM4+34F#X!-uAgRaj446#oY+w=;m=nYmSvdU^D|Y02cpH6FZHx>5$OzA|l7 zJc54cm#dgRiG{j(mVUDNp&&*zd!EJ9n{s}Z)omL+ZKvypGOw~GAz&Wk(AeoL2lr~r z3Gu~-<(wdA@U;TQCF=-G=<+lE&GonkTpN8 z@){)MzWsb86W$>%t_Uji^ze#uZDd>s@CspKW#<+<=@=h0#h<6Hyb)1k2}!6$+q<4V zIo*4<*sQ8~^Dp`q7suiACi-1j=Ek8nZ58`HpILlCJma=01`_{>ZDkOnfh`Ovx_p;~iP?uKr8?HMJLBJN^!=5q>?? zGDwQD4XB79e*=aTI~^##R9xsu_==YoCDH9Oao8c$=njpFjEUm%v0c#??2 zN*Z(3B-g*pwu*NDJ`3exqt#l;DH-yb6ohN>D~1fdqXE|FnyLUGX)o$~mIspG zNqD<%8N#IBrdgmbqu_yoaS>8pbb6rl7$=?eXlzj(dcGLHpFgoH{QBmr{coi!Upn;o zX*wlf5MtHvVC?FMcIdKe>Tl>=ON!Hm`*vt1I7;{t)z~=HmcKH*+Q9~|c8vBvgtw%A zw1~sjWLVA<7`5)c7Qj&c)8BhDDSZrvyxNZL+0WVhPs>P8Md?Qscqh~nt0YVI0TuOq zyDbiPKxauMeti>L!2bl$%T58i;nHk&gP!}t>bQlHLg{!Uo9%sg%7cT@MLeO)P6YC$ z!CS)1RelBd*L4NhO4cWvM`MgXoqIc>&Sb|yK*I7@K#dN_OTPtStrS5f`e+t1ZTK#? z@I@SM(`mPMEjJf)F>dennlK)j!l?rrGKkiULM*L(?DEnsr{rjT2oEo0geBpvY1r@l z+cVwXXI>?oNoJOvz+pU4Q_t~5touhXbx9ehVx;qf{1#3*062uh?s8c&uRy_0VtK4U zzJgdReb|K{%wa@WPu9!b0$MS$d+Tv=(w`-f5X3jtjYH~e zcvcHK_()a@ap1$ka`g0UIZb1FmhF=gzI}hVIThfsI+XvBe+ELhRXz8is`~Twb*>1L z!lo~iD$bV3hgm*74r**K)hcdvtuFtGDkn|Ci`^BL>IESe&* zr0dw@Ze8c%;G>7GC>JSXHa^xc2^aZ?QPWyBu-~x-%2wi82JTJds)Jx>6jv6KH}gu1sYb6Xhk>}L6_`gv^0{^M4?^65umK03NAe%*bAe|{W1wv zz~4X-d}4$@$)1HEO)HB`lAF^&ChBerN2e#lLdVASuti=Aq5@Lu+m~8VdMIjL#q7P( z406GkJIn(I2->){yYr-lAAL;1%wW;P498Oj*gdryH)J)ETQfgfBRq~vTWzCmNM@oK zpV^VAU%&#+N+%^}mB`x=ftLpqWAe@6J_}iYL{rpk3d)NsPy;{ZmaEZmqLJ+ryO_NV zTvk8pLYgdXu=`ayjNkZ82w{k%LPZ!z7kdUXeh9lY?JNI?oA)3L(t-6EoJw}L5#1*T zDbM=slnB-B)V1dpsa3dE^4jdwN7os#kTg+N6kYtCZhc*k@f{^#0eO2CHOJ&lOzAX0qA) zgJyhUS5EHp9LV_yWCkQ;P$CefTV-|3Yd}hNTgu zo>xy?Snl#aiQ8BTa*{R^uqc*F@#k49=FtwD!QS?MF&gVJD54sm0=+`np67$OI+vH1 zhq+6P-F#F;0i_**cs4eVXBH6q!;4MzrLQi?mLjdBxW9>O(8#h2(r;xZVEvrl13&RVI>>Zl5aSvEHo8!d|6f7q!^K~(rq5jFKY|J4pj3O(%HQCSFg$5qOOs9OWE)5d~R0QG@jz-F2<`sg^f|&~4@-5S7;aFJC>iFl`If&90^ zO0Fq-Vb&06W+j;mKVnSNGI{1&Mq!`Da5?)Q@_mtMnUG{(wslfV_@mPE;H~@k z0R-NCgOI}cM_&ej(qsmVF+O2Z=HQp>6#bhn;zO&bjs1bHk%wi@Zpb=#F|KDd8W^ku z&>8KbPqrHAW?47*Q)ed3*BG`ieGiqqax~J~F6FqjacG7ka`Tz~mvZC->UO19*yNQ3 z`((rNk6?X#B+g@i22<#hag-6=2RbWM#_eV(;!#%qn7uH#!kU2FX;u_m_u199?8q8B zcBTKkt;8~GW2P#M!SZ-6Lb$p-l8OnL`pQszId4q%Ib}ZacWN4;WD&wfgi6lvcb3{F zuPO@BD%^M7mjUS7vs)4tW^Jg+gMg&n8eAdI_#uR+yyA&oW}HCsc$=s5jv4Mp1y&%} zCl4E^9wL@mC^?%&th85Ybu~&BVrY!0Iu%ka3d1;eM2@aAMUDOYV9>lQQE@nlpx;0- zehWUV;C{*KrG4p?5a|4Xy;3_C3QT8G`c@Brfyz{jRD6UMHOg;RgH#rPkG<2cORf^; z9UTtPW?r!^c6gno#lK3l#{I9436Q|tUXN3v)ANe#Rr96?ob=e942R_ShdVB962K3` zM*e1lbz%9iQ(v@|BQhelRiRl~yZ4u$H&&fTDcdR}H$`^~3Th_k&SVD+`FGsF8{IYF zc{!EK;+u&~$xbbdH>TI=Y<$jucZW7%=SqOv`vYc`RgOk{S-m^+A8-e&^e@4|SFTt)bpsBmWs!C2{O)x4*8O4Osnk&R#Xuz7z*RKy=CA2ocRZ zbaw1wz-n}cz?7D5+}Z==-`U&aYcn6rp1+WvWz%d}GJTjtpoKf#@iH|I?W57Y@(U!%e=Fw)n559!uA1NJO5D*x*tD#W6k`U zKb@$loW8&yt=p?#ozeov89k8pb)qR|~bFk(d zi(SM@+m-K`Fnc8zi-`pD(e^lgDL&8e2#+%-B*$=%EvO#Xuwb_scBt0}rYwsTz^wg} zEa}OF0RvD$PjoXLM8XDi_sVB8Q#VwmasSDh!TT+o<*&Olm6fZz5wD8v9C|WQZc~!ZJP9fkiGvSEBe(Sx{;L94CnH*M#agSNzQK39`e?jkToX-B7eJ zJu6}EV9H8YYC#d5Ho6t8dXA?EGvye)G5zR^kJ?er)~e&{_*Vxb@6$b-Dr5X|8g(0w zh*Lqz=u@f_9=-A#Yl>gpR#iF!P3d)}$^-awwN--?-B5l~*Oj!1O|l-=hdKl);(}^? zx44l6fnlTY%JyAhMJ!>>GxLXdeE}Yx?Ec6**1E+H-2-IWdZ1%(r#e1c{XEHlzHD5+ zRhtpAclG;2!b!Rt;|Y_?pter~oZic5?T7pl{`zD2XCnK7I{)wx6%Jb!E60MyC28`MX0ag`ky#in&?) z(tm{FR8sTcZAaIdhmles+wcs)Ay12M-Z#AGC9GWI$XfXC?QmzD$moUACzHMwF^{>c zSC$q|6ai0T*ed;L*>lmjp;o}T)Ez15%ebPrR&g-O@Y1H)4L|Swv78giGX9jZ$GLQf zeQZ8N3}w8(#RU|Ai-%(D5#py3@O=MJN;VN1`6|HGIWH);>q=CI#?QegHP8D5{#IR# zG4q*ahkX(>;_2ncr`5Q7CdZ_7Vo`oBcC)LzGuw|LO~SQ>@>>#7e~9$j(wd zsU%lg$T$WwM zPo%kpMfdwH(pk7{*(1Bg(45_seNh(cq95xMHi8Nq(DU9_tCPVuH-P3DE6 zrO392UDuMY0r~+jW*30}Zd?&Xtu?nP`W^=6mI08aKXx%r-5KP6Hnz(JvtBE}h5)ya zsR@z?%4>6^Q?Q{gQT0zESCV2DjG8ACITxxfr=atuy6!3IfXO<+?53H^t>!y*M`XU@ zymM3h1U4laHuHa4z*gy{)FJvcYvZkwS;^)EveBS2ZUiQo6!H9GXK4+)v3{$a-p-5p zEA?Vncj?QXOzTp#4g!%5H>qfJr|ju{&#;Y0J7opkExHkwPin~u&_GpexBAnm&HqtC zmfC1{z}jzx!v?A2fWBFsHnP2$w%uLur_=5R*KLt&%)=Sn%%A(4)?2UtU~6&RGi$Hp z0Me_+Cu*MQldB?RqlbV*QTJqfsJ$j%d9WCJ(PHh$uJDbubky`nJ{c+hQL1=BU))aY zshBnzU`_+^15QIM)(&sXrW%~qVA9L`pnMgqnV?9pG0z$-ba#EcOp|#GTPscx^ANY~ z*IH3NMRcTE%O9FgJ>uweb%V3&g&!*a%_bCEg5OY)Ru3!A$GMMhzHuccvYJyIC2hA^ zgq_KtxQ%eTsQ5J))ams|yVU%09sIL0>3U3j0#3SAe&5w6bdMGU8xkL5oORmq%rlCg z*z)z--zT_LTasr##=Iyf{q3(Mb-x|(MMe{V5xk1DBd?nT9fVEg)k)%ryJ|_e50I2_ ztFpt78ar-OZLARK*h_e^enmN#5 z<&IkDXtV(=Ig7Ls zsF4br%!WlSY^ZIc`SfMfWR{fK!ZRKOpwwF-#7C*s?%^JD>0OBVcZCwjyW-$F6r*4> z_+)ih)y=89DC~r2*6h%mwYj5~c{u71q&#BOyon9WJ-QY!`vEU)c;;GpHpcpUTUwPg(CJy@ zlIdAr@k8#`FQeaDgs02_u+s7aacZo(sqCVB~$%S ze%+JPKL|-f@up$@w4w_OTrT(>i=ZLN_?$6wVxJU%WFqe=mJSDv$G6WS6V$_E8!oFXVPU|;xN88Iv5er&# z!_Bv(BUxNHX5~Y4R8;9~be{6-lUWwJ`$VYbMl%kj)jcI;!v_jWsqYH?0Y8=AZKLdW zFXyh+-EmM^2xp;+YXFSvw74PujK45#m18oXrfoP50b_H+aDs{cCM?1n5ge(&yZ z+-}klZ0b*fNr&Rg7asOGAQtEISx$t8SkiGol$Ws(E8QSc;ghOlqKHZCKe6`9-M$ zg5;&oW_!!}oJ%*|Gb|ad0+^`X7>wK>OcCze5Os*9o6#Zx5SRjox#W!ko4Pv?UW8yX z`4M@A@k1`CI(H!+;JGT3D8vWYorMg`;dCM(a38^(`O1wJ#A@8yS&P?1%A||?Nna{? z&mCN0zu-zhb~NmcaLUf31Tw$eMtdPHce}O~KwVYu)wwK1K+ecc(RF|s z0OXwM`32#=$>v+I0S%X^%a^Ps+xAv~JnRko@mHc}%Y@UcWX0o5A(TN=T*L|3cFdKh zp6l!te zanMivqqrc9XX-iNF74alV%PYZxHO3_)8J7rv9Qv5WF1Qq1>}{21VP@i zN4fW_C$ic2i+rjAMI2HcTG<=h0jKROR_~eOc4etRoQ-BwrtKQn+!h-C-camO5D0>D z4!umtcBPUN>IQ*LDEQm>OjOS_YJqr%@a`6+KXs_TXlbOaJT3VO^?i2p<%lTPrp7fO zbyH{onoY#*roU_fl*-Lu-nKTOt&5(W>(dlwD!kW1T-u5oVLz?HFXunIpJ(?snKPD5 zJc65fGTsS~(+F1~41bp}Ox=x^6Ltus43Ht1Zr|u{sckN5V1W z6zJ>ysqP*GJv(xPQu?G@OmZ0b(f~|P{Mxqw?7+6585-=@a!r<4k7V7rd9ghBded@z zv@Z=X-3I7xFTln9?nyDp$T}WIurne`!GHZQoWzaYSErXCAe!z3f%7#lCdYX(sWAj15y; zpIhgYYiOfO6MJvcNO@an&t5mdw*>L0&1EW^1siZ2kk(>kKV6mX&dPv?u*u#^KIIz8R$YL0e80Ip35OIz4A5kbUCsLEv&ILU zgthkOn?(Zd#==eP*|ZOw5NR0lsVOVo`pl}gy18kjJR_*OzuheoNL*%g^Flo$9)pUm zU^bJzQi3C4-O-k$BGt%Ud|r5+(JEpE5k1idF@6;(8<72~DZyutK+6CckIe`{P&-7U z5CZuTu!CzHZL}b(ARE3d8JA_*=v$k@%ei1|3b5W9cK8=pmWl8*1}szj8pzb-__-?A)<%~ z^FiX(H69%82spXE&a#OqJRTUb%LkKNL7v5^9^qD7TtYnU_XVzr9f&1N)-0ODEpElw z{_B)QKe@Ly#|sQZd97Y{!GBjr6R={fW}XAgSb6~vEC>k8;Wr=Y>-$s$vAu<0Y?pNd zWp_(8UEGg$`tM;lF~!i7l~c;DPs?V1w6;TR0sh*rKBlnf3{Y}>7io|wJr9^_XZW9_ zS~#MYJu9wiI@!B}9pcmsd2I0mR~jFl6&52M_Q$>2d>TBHcn5N0G+ zOk3?8Q3^xvwN=(V*wMaw8?zi2w)vk4QQYb|>>Yrlt7d?>(EeU*0H7O93WHfPrnpMA^9U_I`O2LdBgMA zo^{7@|5!)}xng%t=nud&q(HUzIPkD) z!h8CsTk6l04r`hS+9chkx~s|WdP8)56DE7hb?=3EF|D`z1YXTOwC3eH*#4DUu&iXi zQ`0{v4jlClkOVUerN?|I8Hhh^8-`m9Re^cjT}jqa{|d{c>EDhmEhIp-CC}q>z@)WW z-_7LuaSX6vS{v^`YT$y8FspvD^}=Z}{nc3V8d{z5T9-2t*S|)K9cCKRLMs zva0gjATNqzlUJUJ4e@-GHd7S$mJG{>7!ro=rGm3-G{?XW*TZgr*OKRi7A-*bgP0xN zF}hD?w^g1!+9b@vJ%h^(%ObS_e+t%N0b(PDJFf?9_D~33#GVYFgmR8T|OfH_+ zYehff!fM{9S~)_BumC*GONYEz^gt><+Oq$jw4pwa;rBQ*5Q2VWo@rcY`DfY|*vCEn z&&i+i@JEqvi5&YE_&ozkaV-Lj6zAgCQDq| z*PZtiEEU`=;;N8g5sBjM$@VLT{U}bz8NNqrpZ_G!0rE~3Yz9Pz{(f=l!SE<6a2UFR zj}MFk;4*KnC& zeSXP4zRG<@$RSM`GyoxX9YClZRM`c%uy_j>0zQmh)@YQ-h4FDfu|V)?Y;1?_mw3s5 z2anIUJAz99T2J!{F~a%W)sQpA^LOXC2`w5!B3D6s^Xh;(fKFt{ z=L>3dGhh3=S)N(VUB;Hk3z5jk8fBcUBun5%hBSn_IE4tdJ2e0ih}d$;!m@(G(72Gd z;~iEg;nRn?rxF)=Pow}w{AR2)rC}<=F7hpqRL?|G+$o8I12o`=$RvO#%JgBO_Y!d8 zW|Q2wkQUY_{V%*{h(iyqBvAp)f$ejwwFNsoz|q!pZ%MF}0cLneI@2-< zlE6LYL#RtJWb`1>qj$2R&&O=TqEy!|f5xSHa~7ZNeu&hjriz}~5r1cGW=`G^EKd#o zxd@t7yn*tT$Je))^`SPn888;#khY$DT#x}B7-;YA&RTZzwZI25m@D5^N;R$tX{Swb z^8RG4{rbWl=F27Oj?etC{@rA#eW-OLGeB0CtpN2?i1MLvmED?N+bqC>6XG+1Y>RsqG-nHQD}_y`AMys9%Mo|)_$ z8^H0>x}r)N$1#nSsC5?8NOVh{HLd+_n<`^C0XPDPq?$UKDqjATcZ2SPA@hg+e^&jFOeg>U zdhvG534qT^HlOA_Jy*&73y{JQ-jsTziiSOsHWKvD^I1rgOp)ljmt1dpiWgfL!yFpH%`fOPtOTPupC~TLPr>5eGO`lf;Z#X2r-^IA51sU`zIUxQEyA;We1M5g$ncURxCD5s@R^S=@iasKQn^y_m{~8eaRGzr9S6FSaYj zb(n^nO=0W%X#h^sp=WLU2l+7a+~k{cI|^J_Y88`(=&+Akki$016XD^sQ5zMZu$qcB zYeg@*rdIxma`g#E?)Ks;U_iyjNcWdS6jnw&v_2DWRJVzV6PcFK zti0e~^KC3RDD2igu;IvV{ZP9>Q#9q2I~t1HO)~b!R9i+;Kj614Y-9EFyDAkH$$ue4 zc_l!clhK%oNuGO2eTr8U#%?vaC=Moi%YTEvu7`x4#41-`U#i-Ak8N`E zh;`xb#08sLO`XyoE7bTw9emXj>?YF$w4Y3%QH#Ov#7|s_`dj!ngSHV65GsAcGGs=6 zF{WN$pVc_rVDJ1OG*iBiYy7iieWCPd2hY$iK;S1?19hk+;m1@jX;Q@Bp)H&sTd+Vg z;ss+ZCE8FZHiVGH(0T8FRd0+!nPxw6A>)!O4V7pkDykX$Q!+QA@A}yXv1K#-dO4Lh zGW6!WlVF1Y2}k?Ie+hxQS*A2tGNIS>*+SK%&q8z9l)lRX3pl>-n0j#hE0-O2px z2GujHmaFd(BO!tV_`-W-ZDa88bE>P|lng9^yjwUKwo;L&!hZ+W_$~2B@457;E^N+D zW#<9i+f+=Dvs2hbxQExHECD#=5pE!3YaX!1q~|ID(iSkr;b}4pOi43Px-hV26Ix?y zv8y<1Gcd@_s%gqnM72^wMzZBKoaE!FLw{Q2(9FP*o?R+SUNZ)TiMEs7c?r1ZqSaxt zG)9%a0=UJ-!DmXvX9eRxvX}WFa8IO$vROa)$WBxN(|5kfHWwUt>p-G$7{K3}dW!|W zss4Baf*g_l!Y$kD?<+3xOmUx^I^<*$SP&UorQn1cGSZgS^vyoFt&2?2NxDO>z4OD$ zL7HClx1c`IJfM8!veLq4h5;I{H*@P&8hSSQen6i5RUig{kI)NB?>VC}Zf*aR4I}$L zqywrgD76XQ5AyPw&TuHUOBCgiKR~~}2c@nxy1@_?6O_DdUsv88aL2t=-<-)DrF!-33bLQY#e_1c~>aT zq#^}iSIco@YN6X^VUuFyVpv)co2=y2lP5AAUUD1A(=o&BK-7<(H2SH{w2j!Pfudvt>6=%nuC^h1XK;AkR8&J#~l z)of@qbp;4 zr)EG6li&xIgekKw`t|;lzvkKjMC_Pxzk1qX`YzIGdw4dQ&W>ebgqBFtu$UU7rIfbg z90b4VP4d<0?ZlD1vc&}g6bc;ljFRS}-_BuZwe~MyVE#^k6F3BR&UUt8+lyt%KK!@I?*sfolq1E{8;xW-3gDva7uLCG{WO>aQPi!CJ4s zaXHD-JvB|8zR?@x6yp4B^WQetM5j{-cAKpic&6EfkxW1|HIf%|fy20a?Ih4T;YiiP z6q&FJ{R8+d87OUiW&Yn4t~B(+n%4z2x2@rRrIvf{&eQq#!OvJnmxRuiTj^|AAgwPl zSoV6GbHBPKbYwq2EC%;Yj=4Nvt}~Fox8(>XA)uA6Z@WWz8M)_jUEl6liB`~2Ou3Yi$2X}~01Eb zAzLZ)Xi>X`6wX_ihZ_EsZ;Fsg`Ccv0Mw?81gP%r<0Tl*5kZIE24#GV(4*CcK4lTEz z+^SM$u}D{aEjE%x&bI?8TeEH6LwBf;iYAGr#T8+@fisayNT)sie&1!U#J0_4?=@TPffI@fdc@Wyc<7%;W+%{A#DAGZ^2hl=Lsr8L8fa&WAU z+8bht`9o!%;@|oB!B0K&shI`jb=shWRh<8n7{Zu>8)xL4j0or(;O!Fx3e(UK;@ z{I9r`$6#&JUU^;G9i+K`vW5{8Ju>*F7zz0Qg<>B&GIQ|0(xv{Ce{+Vl*>QTBb6Z+R zn)z`=Iegg%7P>smFR~0{M(VQ{sBT>Hw!r-~Ka1XaBNZDcVuz^PaJMdKd3mpg8clyf9vyY~KX5I3xu5JMLw5 z^Kyd+`&l&%Wm53yRs3Ss_1%bHyjfmN#_@-<>cea3ybvudnCPQU2`Rpodt1~CnonOj zXb|lv0-|B;ANE#GW^I;*UPst@1e0}T#SSh5?ft9`VB(gq7Q!;8$_2!AcJpJ48uegG zID*Hcrh{aDUe57=gNkzdo%IMr{L6?jyiIk2$E^$j6ZaB$d*(mvgT_}f&N=I-6YGnSWr$H#YAW%0^pZ2)((#cw z!#^^ofU>$63?94kO%Cbzv9k=~5-}8Ra4c_(4iaAns`xNO^D@e;eYWaX5wBnP$5)%l zEo!|YbT;mkN4S@8ut~zBf(>*hh+%&b;BE0oczQ@;(hho{U4cC$iHw={&IJPqM$u+? znINB&+X8>mw-jEdtCf-@nPI^pM{ol{ka`L}C>A3=k2?orp(1^1-tb3W>;pZv zKOn*4a0~{@?VIVVr8yBRrQPHUNFY9pjBI7M7_E|UEB}OSw<1j03pkp4Ej|#X)sPrF z!}>=TFuJmK57pF@mLOe6c3W!1y{hKUh(t`xlohywI;K85$wS&XJjlqEnh3KW?9LG{ zH`o|vG5i-}&WJNKNosDFv=SHj98|})Bi0a3>TZKHe$3IzjSTO1lYO6-!QFsLIaINO zLJ?EKQTLP}CN&`*UpqaP?kXFsqLY=q-~ff|IALCMTWHV){FV~jUzNyvvtv}${K zT=580WPCWnym32ht@e1AUeo-0R+P$fz!$j3`v_H01K`+F<6ss?GY0!h9K3HNfv1v*9zqpl2YOkbx;lP}4uBF?3 zIt#_oZ(04YEH8LsJ%5~1y*9JDd0t}fh-t+*5BnvJG9OyUN3N})8265rK|q6QeCDyJ zj6lv`{BnCXcQxHr8=SmR8*vo-Rl9uzZAJYf(;JEKS_q9SgQJ6PE?8;)D$@h7(T0<_ z|J^LS0x;vlxM#QvH&1=n-5ry*=!OdpfuvjDVmm*@u|ARYTr|&`B61UqHYyXYrE`Ax z7c8s?tG&A$|An&?LdD}d)UxzH$;=7-HSOl?d3G0SC2Awzf|`HfqFZX_G1d3pb-EX& z831=$yaftTQNodlYkG=0*0U*leq_xtj%%7i!8+CJPRi_Oqk-`CRGN8O{DzbT`;ZTaZh6Ie<{k+1F ziOf&t14|a0I-qDNTKDP_STsh~(Z@~Nm(->pB)_uU1|&TO_6ouOl$Y7>W<4A~F<;Xq z;ZaUBHfl1Pqvtc+u&^Jn9-JVqnsER;_)u+uGWtZi7Bv=K$l-2m0oew{6soxM^WZIY zq>Q-%)eh!^8OafI2oHk8t-7`C1%|jkJOQ%#6%4;-6Va(YFlV)GXpH_IUWb<<8<(#| z%^xS$AOU64db7N1T-^~<$+l}s&-EwW=l+>NPNzFv+v-PSS}ojKckW|8cRw)!pNdPt zt!)ck6m1f8HWgmXE-c9+!ZkWxlO?p9OHABsR!3PNwamgcA9RO^yZ*4d{!Ng@N{qfF z!?18R_CVceiP%>3XFD!c`ef>$S(_EmE^%zIJ!9np6gm9O?Y>V??c^H}e=2%+UYG>5 z$Q+3SqRD!a#aj;|(C8U^j=FvsL`knkzXustY^47idFkv)ng#w27kHQ+D8Ei^!V)82 zWJ5q+v3f7_q+@w@;PyGJ?R?-=qFHM}c770aa|9%dtSeZAy@wAk2;y|952O8ZcSq@#FE)!AidVCplIc)CYNkla8=PeMg4al)@BwYVg&jEIp6sMWWv73mq_Mr%IIky*O4sK+n(!~cKi!;K#$~Nm!8)zi!+< zB?E)MI~3&PWJf5<_m3#4Q=W-kFT)>Q{U@$E!UBwJq(8+8oRMbxevp2u{I|>?K}rNg z0`mx#mRJMUXS$`Qm22vJ7!EnnTAZ!+7rN&pe)d&WZf&Xa~`0Ih~kJdetIY1qWE9pap zhF#xDAN?T$sC}46`tDr^Op;9j!grW&7DV^m``;C1n=9*k? z!xb+VTA~xop?)aw-5_yy;};fz!v9~@n~P1FUH)+c>l16{vXRf~R$3WBv3bgJ>j;iR z55Lhrq=b8iV4gDoHCGApXlef=@-(d=X(cc)jY$e2iV?b!u9z^u`|nJR2}FSru`%_V z|K;zWRum*X7TrM&+yW5L<_mk}Z`rf`MD6xSgPXtSmu>H5D5rS zxIZH46zup7_5tN%*>SP>us%+=rs0Ub=9=!xPn22cK6LwkH2r&A()a)WkIyK1^(v*! z&vJRJS6lq{cIBD}6gk-1a=l9FHQ%$Pg&j6gOHt7{c*gy^0IeQ;)2_9 zbSs8X3*{dJ10Gu2o_Kk7=R&r%4?enXFowurP(37qX)Ib`O@|6 zuA>Mp5-ZdmT}6d~J&3r=_c%Zy?WfiliiVvghjOU2a><>j!QaZk?&ge#7jIg6Us~}7 z;;fA6xQn(?+)x*=HbJ`{<Jt{%MBz51RYUIY%b9ZsHEEXB#Jddu~8qQ5R^z3<-_)_H-QVjO2AAtae5 zm8@XYEm}(D!)#3Iq8+%JR7NpS%2EcshSWc42J^~#!7TKP14I_;aFF}zy`qS3^JIS{tVB=Vf;f;m?1s-k zXC&k!@e8BO+ac02-)W8U>zoe``HQ|;>pN*rAr2TpL^ZM&!@fn5s`qj=yCrbFY(p8Dwq7$Lzv5fM)UD3x?iJ6QGJPWR^Tr9D z{279f4dj13?tq{))soG4W7)fKZ>Urp9xkJ}af(9vw)35A_AEk)H%7mnwJt4r07b))T;-^Dnm#mSgMZgOR8 zRj~Ab;e-19)I03A}WGTc&z^{-EiY^R?B=puEBl z@557W?i=jN!Om5Yk(~%r5Ahnjc@MfqC#X~>lef8(2GD~()#<8!=K5PhpIl652BjxWQm>b$ zpqLR#NVMb`bQ>dj&*-4ZzGZ@Q{ zMYTwJkttVXR})am-l=s8I`!aXUeeptDI;*H;!TxrX3^BqVlE==Y8zvr0N1u}p1Sx6 zQL!tbUVb#!TwZgwO^!SOQ$6wRYb^+p(Hgf*V)eD+FMO-W1F$*j+lrWK%^(UOVZYC9 zK%`#Pc=sVx&D|{;!_4U;P}GWc+dk2aqJ_kJo{D=M#9?=buFmYW&$bS*0x-)zeM{g+ zDIGT^iW^8_r>pbVz9|47r-HF)Ivs24Tek=6q)=QBNaVISd=Pl8kvWJd#G9Nv7Ojyv z=n6?*eH3yHo#A@e`5MyU?l2Kp@On=WyxqDOW!8`E3~|lDxhMt?`Hxj2CR%dS1t?cR zUrf4=v`9T(tTyz}VSHbzJC4WV8G7096 z&BeASIDX@16LZzr+I_3DazyY&KmhKF?(}QBNPlJs_UMb$c2oTs;VH(?j1QxWE0oZb zh2HhP9{t0%F=%Z98rW<0z|BvUD02%D*@>r)`z4 zSiP8%bgo6oBy02sp=lJCzHyUMQfVc1e3~&&X5$6#4O*r~6ZOU=&VX7mvHgD3&2u6i z+qYPymoW^#2*XeW z;v0T1S2pTGY~RM7+TqzsCw^Q7tl!?u);fUiFbDg;KoSp#98_%h2ZMG zk-4&--adZxMQl&}=QHzO&X2~fW*)p7`0W+ZZqGyCitoCI?@2v|$z2Kkf7{RCSvr|r zR=go%Ff=PLX4^l13OVu1FCl{#ggn_N|KCTdkY}LXtI>JA7vBYTW=2d=CW77af_9IH znRk6d>D+F;`U&7@P1=Z0IrAF>tMgM9!g!Y~B_}z=?3NNX-V@#~)P2ESWF!3UI9g(; zC8h}v*7|Rx@if573g{Gf%21X8@*nj6vquP@;xzD1-F54d6TI;!qpEMtT4s(xIH#P@ zvXMtD!(;fT+28B`Zx?VO8eILmc32+(W|g%#(0sJoUXg?EV*Sm4X!a5FbKg_)`y}XjNU19!tQB#gDS_M3(;5wyU_59J9KHL{dN0_U9CT~ zK!T(F!htO$bj!`ij3vfsI~ zvb~WS^PNo9pR(8ccUFC!D4RyzBjx}`QCv45?%h)oHdKY7D3gWP{Li60Q_Z`d| z%+jRSswl=ZAYn$cGOr6vq176~}h*dOd}ikc|k&xq1}(u&}!aA%)o_GFMBp-23}{ zr!mdz?z|QfPR$lySqW^GD{|&xTOPj2-z9f-46pmF`q#fPPiy^o8E|4L7fg=+y!L1= z%ge`M^;ZF1K-Flp8g}?figv!9N`0z6e%!r2Dt`V+P@kPC!{sln{zg`y;cQpxd#ur1 zUA;wUO4HNja{-C^hSISo0qUtTzYD)4x)fKxfFI7YHpODQh8C?FY*LI^^EI!8jfoL? zD+@!?6#sm|hx|+WP3%8CFZAq^Iv=T{y_YZi|3(+8txdOt|5F`cnHANEKzWq>+{6?V z$6Zd#o<}Txg#6%{K=ibHPhfEM#Xsb(Jv<9vHEz6y<0Gu&6L59wATQvbI4@6{<_X^Y zXxCJSk6RZ`QJ$Mc!d=h>Uy%uNsFgTua*_I8reG~(J&U5I1U-eM_Wp?vCg7l+`^nqf zLtDNiNsSBtgIvA%U}d^3KkKu0&c{8FSegRSFPU|w=+KDeF zCah2tgk{9YYE(W^kv!D;}6`}6a+vyCr`tfyj5}MwSsbaYmPUXwZH;d8u?_L zB|EAE(SM5!&7P(f*N|53N?$!oazW}<2Zh|})LjlsDX z3Mmv+TszcLz^K&5^;;uxW3cM>iWA)5e&1XmFmLST#yA11#BN2amdc z-7V5f!xl_Ys9#Ch`!Xo$wAv3|QwiR}WSnX~*hfqpA|9<20Zx*8PNSS}jn{$7v)p#z@ z1mAE+X?f>7XZ+oXQ!a4{oPw*@$j{#KBD&7aPfsZAW7ebk-oP8i{!P42=&!DOEo)i+ zUk_oV5DR?BGO3|7ajBn%J`|~0zR|tPv@LBiWXE50sah|92SzBa;v1~pTSd+K$N5ls zJE^a}H7syVxbb{kmh~>Bpg>**H3^+o0pLdNfGb*mMLt9YQop#6QE5It@xnpq*tJ?8 zOmutPzyKn$W%XLLKXwlrt7|Cq%h{DF8!ma3haiCdUPn3pAn&{>R2*PT50 zzyp@Ht#de4WMG!eQ77cRj^TCDlpF~Da_OhNm#}p>d^F;zI17Hw0j0PkPU+sz&$eWl zKS>+iN&zLef%$HS>OV&c4VhV|{vU-$e_*RSIO!fzpRqTN4f_m+Q6TV$T#Q{>S%Udk zHZG3Y>`v0EgCvVDp+E#w8Rbkg$JnB&jD>$d$G22$SnBFN{u zIP^^YMDB?TDtSa3uIQ+jY6u$3U5x7tLGFR8=jVtTmq~yc<5rxF5a5Z1*{DES+P~^h z+KbUDj2DkSb;%Xx@jgahjCZN}P;&U8S^AJI3;XFfMLjsujKF-NExlzfdPw zBhMJNFZ;vj7^crEX<)(-C?0{Nd(iT`JJ(bv+n~~HuRGx@AshbKwcEaNasz&5Q2T|q z+Z_nAjQvwuv~rsRS>oWEO8Jg(pAA{F)5JCJ3*7{07HHxEBB@)yWF1nrD{{XFUEZAf zX#V~QmUg&g2@ipTR{f$8t>btB+XC(Xr@n*!4ep5dIgYf7IjLLsZzI zv9Gnfk3k|yC`#VlLqFN4RC>QAkD9U(7ZMUJzXYwF%Y;(h{#JE5yBoyG@K9f{H~!kA z8EoulB;qfax-Y6(%em?~j_v61W_Qo|L}JSXhQ9BmuDLs=3QudyvBOOr>1~dZ1YEIh zG<6f)D{Shi-J)~lT_>?RsoV3Ja&DKIP0QV-H>F;IV``4C#+9F-Bq-b`+{S)>0(T8l(^)i8)~AB z@_@OXKpQY@Cou&D`<|PxN0NxN4^s~fy!iEact3jlTncq@$_;aXCjZAqh4}1d`_z7Q z+JMNLSHN!^<0aPnQi}$Q6L1JD?F__{DYb4+lzqcPcWJ*I6913=_<4QeI4&?)A2fEAavxT(jbevk}$ z3ECd0rL`A0FnJELFc*XYrDQc1@~UWO>NAe;KJ{iWZx|vhIq=9=hSSbAhuz&;*s@wN zVS#GpUX-%J#UB2x-tum`_g%0+Mr#Xfil=7MS3?NaR6`QS$(XuK9kj|#8Ga9?#IJo| z`3v2uUe~3vy2WoLlhd5P^h;Lr*Y`FUV_?1@B@b|D8gdI^6cHs$DS3OHyi|4@e@(D{ z;ZJViS>m)aI_>7WEssxZPB*VMBa)GOh#HbUiy1$pC}=Vd_~v^I90MNjE4EbR9w;vo zsY_Ye!??eo`kwUdaEya2qC5Rikd5s8*1E93{ND}s=5f|?_%{tsux<4R#eDN!ZOV7N z^9%EB?P9Uz*g5#({;hXCz60TXcBnMLa)kiOh8CU5{h8HJIOeaOt1Pd{N8lP-|Ckgz zPu!>uZAM^`0F>3=<}wG@c(B%95)TU{cZkIA10X2>SV-=AV5V?=(w8o^mZcoqz3{;q zphQzk!)>}MPc4Kv9?r%7(11$@-@Lmf2@fypOiEpPy>oOi`V-K?5?bPTDl|(Vc8S)= zWcD1UCCYGAHYez0@i33rD*P(cYLp_?%MDf;klbT?<0Lx@!olJ24! zm;L0DzGR#?`~%0rcmK+&yQ-OT#|%Dx@QQ%LxWOFO=21&lF~Zm*8wVWR81j3lu9zRt zFH+}$Y@T-@)OV2|#yI5!@*)3P4(VxQJbpm}_=4L#sOYGd>=8wbQtSVG?;MAiBH0n$ zI>L#t%M$)>H+!XSxJP$h>b24jAWAUDEY{aBe$yav$)|NKuiaBsYN1R-2I;cLS{)^A zC&j5aWSwQ|jf;sc-nbLLCy%_6U>6-J-NEp2D0NB+FLSCm+NxMI)|k*Yk@t65)KKZd&JJ3Uy+YuZ{tSHVo0d0k=io)^QK#dRV% zBg>cNv^d>%JOz@P7?OlQgCUET(V%upNQrth%~{z_OF|9SrP2B)wLxhB5rlEO6Msy0 zKD+_J9H+yL;>vo~xxq9Q&#@p34j%pWbK zi6DW|a~%nS_ORH0ZM3O3jDdX0kbZ}3VlHZGkm`FrfnRc=lnzF+=}T z4-6O^P6-(}o*T4(c>DR?# zklN?_153kYRbhzD$N8jcI4(858LponoAr$xJf^mrbyKaqD_}L1tG^7ve(aq4VIM&M zN<(l$7_knT?B;%S!5Y12aGo}`(pMisY3jYJ=jPx%{ysCH4Eee(a^(vjRUb*|eC=qu z8TvY^O$0%AvDu%!#P*$n$g)CucePukt^AYF@#c9!=KR7mPS;DKb?=b9ZyNAlA_2l% z9}yh|Ts?i?xiQN!=25}3m}L)m&^5*n#Pv#I^rPk60XX(-qY>dQuOw$B_?MqR@l}}7 zdE2*j`2a$&GCs4oG_+EXX%6zO#z#k9q4&iU4Wx!y7I&8y!LLn8B_&4v|tIQbb9$iIO-uih{_s}o$mzHD+wZ<$zI z-CF4S!*^O*st zs1o{7#>mBl+09b}<_#Z7YoTO0T>}zI_PBjQMuOd9D#5UESk#SSGgcn2zv`$f=&5On-qX-r>nsUP~)s8I?0 zRI(dTjB4D|va~yZPIQ^I!AJ z3)nVnU+ypnhG!d^-&lFY4 zW#P=*^`8!03DZW!*Y3A2MgKzV!}Bs&p{u{_h`%~5t#jHafTzy3C!|bWfsOGs5XzqL zemg#fl5#{tBoYO)3CmLUm zl0I?3?{7Y4)BiSb_C@54AZKO$9(>+AvOidyzyn_^OL4m=_?4eM68;-HVHzb=XoB1h zKh3J_DK<^w2qj6V;7pP=FucTjlCbJL0lE4J4X{#WX<5Ew-Qtvpp|ESOIO^WwW8>ZS z!r*a79Df|LxV_FUF@bW_#8mo+JAFVzPPw~30DV_2(Y?fm)-^j}+inG8u25#}G=rjI z;N+0emIPx1qhT50o-1|~nu2hvlAfHTxFFXI`V~6R?$XzE#cztKi`(L_&=7^^RL-oW zXb<{YEzI~a(W48JtNYzr>D^y!1$0ZKG(y?4VZgmi?TOjye|O?Ao0E3yjMp27d)AgF zKTao&At(Uow{DH{IBw}}j8ypzXsc{jJJc;8b>bJSGiwu;x@l^XZ%}@KEo@^R8UNy~ z*fq|9DeX8Cl5!K;swLX$x4Smbt#O^1f=wGq3e^PjTKwNDvW15j?Ef4^F-A7}pBzC? z_kPrUZu*!D_`UgC==WVJm_f30akZCy*K9l|7za`#%~mp)e8e;nRF-T&>!-;}_t-~+ zEnzH6)x(EBU3E!SQew||wAlM9AYougRV+x^`1+FG?~nUkU0ja~F|dcv>-SgZu}!x% z>=0%a=`kKS1^2>LsS)*UwH~gb^ycHh{1{pJK~S@ghVTrro%`l8bgp| z-BnSNz<@+rGg+g0H~1L3uJBumpS7Ca5R_;vEa8o?Ex?C+ei@9ZNRoCb_iU`>0JX z_qOogV*L=7=S7o;u0*%GMDBNJu+y}f`({evF-y(vHO&1?MRBRgbC<1Ay)2O@>z8eq zD1F2=ciEC7WipI-sB9RVT;8mf-_AD$1{za3Wd72-`RwKHLKn*E)Fu62>3&xiva6BY zrFy6@KAWhsvq??7j2=CkS6y0SLum!LJ-NR#Z)1}(a9s6(9%oBxRld{_CB9)ja@of9 z($R)z`L+bsK1x?$|CI5~ugp0(4zi>Ol)wproX<49-xz%Q#p8yTtTR0jcNHX<>{o?E z!IIV|AGUb>k`F57l`DM{NU!C((juy5RQqM8oZta@5XX2bUHS^ zW4yOYx3E2YkI#0@e|g|q>&Rm6bnVy1xTh^uSXn4w_Fj)E%jg#J%}U;JRSnGa zCY#mx|JMzJ`O>E7%GC6tVqK76D2K}LILMa^WEVs?LX$2Y+IS1F0F66&jjH+SRA@+Y zb9Bdgn7gZ@{Jw!d@T{ig7%)gkRHv8Ly=`J|vPnL$Jlyb9Z&WP6sE<|c)U?aI(!kN< z$yAe`;Zcy*Z*J$cH`{nDmU1WH5)s-Q5j3h5C8zBVNH!$pQ)pjvk$+(arz9&INDp@s zNFa$VD`(%Toc=P)BgbT&rutpYxxH8DN30sc<6Rx9N|PQ@YZ?n)U5$EEKS2IavX9=E zx(G$V%(4~+sK!MHmRGEFbbGvWVD$6ic$Icq(!XOV^)`ScXNaGR1{MdP5!IUKAQze= z-CixGRy+t#k&MgkhlCelNI19E$4_QAgIP`Y>@$m2K~I?O_=qg%9_f+uqAt?jACo(~ z`=;HMCA=1l%YJuZHb2x7v1?uW(Ixgi%^Ue4wIEDC8n<+^S=eWspmrEQZ(Z%=dT2Z6SFB8qM zAV^hKUS;_C-fouvtYZx_I3eKj|3Ul&g-zbVcL`3fX1G;>w;MXv6AV)Mk( z;in_(rXnjC{X|eW@$>7fAshWwn{>(&mR}j#U!6LS=)Xw?qmF{Z=5`2Em3ivjKFFfd zwE2Qmfq+!?(7<^5DIr2X#74&&`pqYvr+}mu_phP&Ys_;8VVr%if{$ z+gN)p=qN~!R=Spl^>vQ3_*isu+!P>=S68Q^Vc* zaoRgKH_nF!@Gx$+LV{pr^Bm4Ylbs{L#{kTjLz$Kvi4_F^w1WM`hUguiLPC=O4Z$T=-E(35pa1Hw_`hAiIz@R}AS3PxO6_DfNQ6y2%+8R` zvlw1+Ujdp)T&dj;6X4_xfj#BI6RwN;`p5jYzxMe?F4NJP-XpLTn~kZ5%~N!>v25OV zp2}vUpZYQEp@}p8yV(xt#oDqoh)r#Na=hwA`NT*=AF-$M78NGD>)MN?ffO9iT{619 ze|WTYHiP@E{MgkFiWj@(if6`}2Lfz@(-J%vLf5x%kPapFd!eY^ITL-wkAT#V5#WlP zwV{2RB8jMYQ7*y-%{jNj`#)knU{h{d^Tc#&U!g9};CT>>cVumP`a?mjj#fnB9Tcf0 z2e}{(qY^7aJUK)>1ln@`)KwT2c2wpxr15q~>k}z;Gv6FHOlYFuyaPw@BRzrh(%Ej- z-b490?(-GJUaqdL*tM-S`RSC#W?|RCwV|#mkd;A>LBHYov*jn@!eQiEw-u|@my4!W z2rSCvwed<=@5R03vaP;C0pgci&Cq0gSgKE*b~Y_?xugE9C+P_!=`VeP&mMW}u1L+_ zMJj@PHgQNIDqXm-7iJuyKfsig*X#UhTi%eT)0WF~aHz?BOx1S6N}&E!>Q(BS?p?#= z`=QOz`+eQe*Qn|b5kNZVzaA3cm)yNWm6-N-$ed;JQUae%a!r_I`$hrg@UH^4ynu-s zytbMRB~5hTm^_W*@p2$CfF@oV2JJ3t+&GsqqZ~NSQVt$p)mM1#xe$Ueu3Wg%lCih+ z)co@|Z+ck~ieuGCxIscw*+-cPSPiXIPbQu8ivwL=qfKnQ7dQ$#X9> zA9ZI0DBfyOAAK%&vr#;3$cqB%EPj#8-z69GzbUoR$-24MW5rr?e+Ig%a<(*O0q>#= z(riYItxJLg_)QhAEk9^3rsEiG%H;n#=80h?WwJ>-xu&#t4o}0;kmOT@K>mhzR;fdo zmG7BZx~_EHT6bDKXZammV=U9C%jOr^;k=UanD2VB@`whHicB-T5JD_;SnIueKBP3w z(3>E-R)u8x04rPBG|r$ppOO*Mr^;5(#&8TSk}mPa%vtQ_@D^h|GIC8I0k}^bCy8Eg z8+t_N_JFRK3hQW7a@Cb``r03^d|I8N6ga6C^-NOH$GChysv=`=``B6+#3m)5ur96g zl`KUs^5RV<-?8E7 z`FW>G9AjgyFAIuOzuY!&O)LA)ume@&g3@Zj+$wgQk4Qnmq2qT>mFKMH;BCPTa~=9NB~jl02J zx;>PLA|!ezExZu^3yP>V4VXhB51845Uxhih^#1GmEA9OTaemN|waWTVvFHY1B=;j) zvU^G5PVQRmX0CneOb6;%N+xx}$avZT!Oo_T95kDPK=<@-WgiR0jmLz_<+zp+;eBv0 z!!wAtH=~X?D5ml4_2$L9nCKwl2}p#eSp1cz5w%=IJ0D@!HlIfMh_TNUG1S*YrPVop z!#<~(1MnmUe=<934hJm5GsU4v{UAmCxC#?hs#SF!PhXx)gCv*Ij^=)#Z3AdA)83AY z*kddg9}9C|fXD1~nb4KvRDHvCn6Qh`k?BI~2`t(r%sPScLIssJZoY`5-Y6>kKs$~y zJ`C>hBXFTo9<<@MhhB8nMjAapZ%##e_ZYPp(SW}y=x@wwZ*-d1ih|tISJ>bkfQR1g4h)Srg$8I6LL#(xIjODv-w#diC8LQcXv~O`Jh2&dZ$9|C4`6j8QL7jfn z@Y+K?e07E*0@_^b{BJnDlzqPY;r@sc(=CNI(CCf#K6<3TxN>-P2d$~X|n8IsLX%3-;eu@9JCDL zy$yHkm!dF@&r!XNLS0@o}IN;KyR&%P6q{-)HTZQILll#`q4O0g(&Cq5!w>2x?dWf^NQ2YyzD{nrB zQ%<|`@-tU@eq}zEZ~nxC-Uxi^zKDvFh(7A$z+(W$NXD^OUkTntd+*ou*<8z|?a{p- zkaZq~-&hbHONyP*k{nKM_sH7AYoiiTB1-PfHx0*1J|OV!u3rZ5wZw_nj-;fUlfBW@4DQc*I2P}N)-v~C0m7%l z#)0rA8$6l%ePF2xq$l@sbb+&DoyVgktMv!ut(gvyL37OKK7gZl0#88$jM)^FeyAaG zs&gx`+Q-so2h~q(sjhjh8~+!uwqP|$nX6~W1B;7?v8fQA+DV}G>-5shEld6;KfK#L zME72%J>%+u3s9 zJmw?-K2x9LGBF;rF-N`r!gu}M_hPrhkI#}OdwcV3mGc}s5uG)3A*e43SH0kwgLEKk z$#2KdUBs@hL{D#-a^eM#syF&P95$A7@;{Sw3SD@!z1BJkQfKD8R<93dflI4v~A zpvN;Zd1gAwA4n+T?GhK&rF~;a`}V@Z&Gq((e+*YO3<5uCUkZC@D!BXwxb2vyu8EgXoPyP{S>F#^3#QWe0@UgeoO|N+%89Hd;JM$0I^Rh z91ja(plod05l@4!oS^9o@VRDbrbCgJ=fc?OS|A#=sa95CMq}}9|y(M!^LE)t9dEWnEZP2;j^_;~eZ-V_MwyaMY3|hDJl*NkT^DC$uSxJJ! zY}r32C9!Tc)f(RSIs!=fUE71>H%-7-D7$3K%*G55lecuEE9dmL=@N? z-poej7tmX+4hLQ9!>4T1@xlayq4?GwTFJw!?I~fpJVp-8Wsx!FDbgF7&Q_lQ*gOzD z07hqN;(~Q`ee40N0TEeoKSw%Q%?0V&LG{rTTn6=4Rt)?omeEKHJnIHK;V!Hs1cZ{lEqKUAxQZj zem`Z=%QAr{;~}K^?)v#Csa@{RTa`!cW#>IF7d1BE#w(`V+)L5dHpW(!#l+9kqD?XB z5T$m1qcmd!7+?6s9f+cjbvpBPruaE;`OH{ZeJ3~)abpP+HD<8^nHo%a9TlgLs>Gcx zKz^7KHCRNgVxMYWUUD=RU>RzNd51;6_DR|`;^#1tsOWwR)e8GDUuR1mn4p}?Xj$IQ zU3w=j`QGU_UE2Em0b~_#a5bBbe{J{U)!Ttr-tp{pl}FhqMv<^^vwG`PY=u1h;?|eo z(w6>5I^Spt4_79j`iFNRnFY2dXD_#$ubBU5DEPDdH|x!K+IJ$ol?AS`NpnKzO#L(B z&<3NOA91A;CUQluJ|7aex17 zqvk8&%slHkT-QF0srdeoY_zOnmG})0WxVh@okxr|w22h{*R11d*;?<~w5dLbh3G-H zap(KnT=Pnt(Ewq!tTNKnb&XRo$$L}C2*1=97B@<5rca;t? zBO)Y|J#}f>#wcyrHM%%y7a6zyd}aM*;nl}2%}d6ip`Hbpr!2mVo@f1PE#wM5$sd}n54%8%NW!Rpf_FlsnP?4&2h>|9Sq6TdH!otf(=3&Q zMMyC{E8toO7p{wGC0?nemkh0RtCGU3;0v{$n=7K}YZK2Y)J-tOcvDXC3?MI4zbzGk zw}owT^Vr}i7=LKg{6h7Xt)s90emnd!feWL&9Xl6A^(p*te$w=7Al3o(+gJz<$d+&# zFiO930`DbbuQy0;Zl7vhy~NP{i~Ox{c09;%f?(}qE?zdKN~7a&B*Se@yK07C>d}Sm z8*!)^m1cZ4d9SGc9QN*!tKo$6#x(kkaVcN{<@h*h4xj!L|BUUPcExO6L%TR zj*5U&5D*B3jPp({&u z0mRqMHO&a`w{kwsaG_9}hu}VTxgIf0AH}W|PJ(fg$zY-SPuF5XmxB?jGrW((fznr3 zvY4=toi!*!IBeDqSTWP+`-JwIL$7qlbA4Yqlw$h2MafxO78>uKT(@7Qu#r}+woqqw z@h%$vEEjE6JS>0s#G`8P9OxSqGB{#25y+eYkEp-Y0!sOvK6qYd)RQNfl_K#-TlREm z?v{j*zVzR-_E?(lYzi(_Szeb_S#AZ6ZBiSHB@d|B7E8GAMa0e5DRjr`BOvo*uSXv< zI`Yy?{0&GA_h(#xTgskX>5#pvTw~by6$eS|X7DsOhAyD{{teXGh;Y3%QzR7>jG9WO z602)dI2UDLV@!$IySBf@^?brQ^}21L$DR27X%HrRpM>6KPTitc(^|!Y4Qq1#-&zON zN|fop=^76lX`?Xk$j+IY5gV{%heBq+$dL``MB&!=yF3@asN2lXI;M$1XXL?3JsHD} zN|~hp+pF9{JHMtP_0Gh8?1^LxXD=A8+1Xhiz8Z%>mrSPYY45}6@2+50pAY5lgHCkt zG+?O(apsAVS}N5~s(B{W$>Xz5qAvu@h@0;H{uJN4O?^M$ptVEPd`}yPMeh(&PI*mX zeZ4n)_>Q+n$wTwumCj<@x3dt^iuBTYIW9ciE^hNf?6PR}--W3{u!;adqd);-+k5F1 zohj&zU}tJi9>lfjjMj;iQEkn<_SjZ11C?9a1YeAaja@_NGcs$Qa zZmE2exn7C2m7e8EZg3S2OM+jHfRi{fEdlJh4#l-xrX^ioJcR1|rH=iEp*ZdNwHHeH zwh#S6gs+V_6*Kz@Jzv1IZQq~2+LyDXB4QqgXW$QG$-mw@cN(5$SJjQU=qiZLXVU^G;AK4LWBMtZ?+8!s1Envef0EQP z3CQ-Fds5d+5!@Jc$f>HK=;{dZyRi!sgP+%Pc$&B4Mw-xfu!lVUAZP1*-Pji~F@yD4#EN~rDO~VpIp_dGgul6sSP4Bf|;?`EeeuP?NKMgJnA` z0^!f;gzcq#K+ov{ktJcj3}LvT-0Avyh82wKiZyFHxyK+gxJ}-o;Kae${5-0t>l`kW zSxZ4$Y&@56y5n|Ft&-LC-f`O5)0ZOyJ2~B}wOaGDWRI($>s`1~b&AMKegqsGG{90q zXuiZjK*@b07Z>a$zMJ1Ye@dM!lRZiDxUNgEG(q*hZ6<=_I>=#JA*##NTZtHlQPc0! zKf)i73Wt<^-+Ub7x#G+6`%5k2D{%yN2PnhnWMOKv8{b%jsy2mQidM6Tkk+MW zzhR?ULm$KnQxS3Tb8H5_7Kdc|cdo(@g(FOHsUhmp9PLXb+MI99cFGUsf%9~4uzO72J#!0r33!mG~p?zcY5`QZGk z`*16HKG1JgvUE!obQE25&K1d@p1eoijRS!K3P~)DhhoNV%g>BU_-mYxI?eaEW}$>K zdY{g&?nwaYPjqSFYOH1G&$3|?RV0fI9^EF>;Yeoy% z03<-+qtw0ROL+^G3y(XGQEEb6CT%Q}+P5t5#T~@ zApPds=a#h7L-OAJQ}tJ@Vi#NY_^PJVx!0;MXCqD7N2q@HKLi_P6y+S;b^O({9bTXS z^c`r?FDb$*NZ5$GYd43thMzY_ZVhZ1Kp#xloc#j2kUnmlKXYW?`O6pj5g03*4PgLJcsMm!Yh*ObnsPJ#&V8-7eYZ-wDhtov#9prBX6KkhZX5ZLH?r#Phs&3X$ zq}bq15KPm9p$3M@Clf18WH&F>hBOliUg1~!KB7&SGUTq#9}olf(3PPV#MH}7pgb{1 zML#n-PbY#{yvW0SeTDE`hvtj&vTk2ZP+DT#C+6Ub2?$I{aQd=;AznbNK4I(HmC5{jG(wWauLK*T;!GdKm+Fl^ z3axx7XYgl7-u}`e!V2fpsqr0npvPn?>NCnA@vX;?xas2R@4GaHpsq$HFxwm4sST3s z1M9}6L$zNw#0Yz+-soyoEVCD5D%-i-qjg?PRYK>Ex8`E1O z(^!$?f(uHGrIjgDKB=i`qciS^yMUmTO=hW7j=O+mNv39w`vPVzWGc9Cs3<77A+o6; z>hJFT9q%9h<>4XR@V)NqzOM89oD@b`^j+`&K;1kV0#{yc3))(8p*rkptGFVEnFK2) zyKMDjp0|8FE^QmY482^e8dFvHn=4!ru^x{fky>)^8J>r*b2ZpT8u2C`?2mdML5+s; zvJ8ru_4Ms_|3VX!1QjZbD0{fD)=ZWz0y7|kaY?1BWP*Kx-?T;AUAfgZs9Ui5BkEd! z`C$Z*0kge$PZfR`G3SgP03zM(;>RV=G$s5$@DDap?D3qzCDN;?>=aMbehC^3y93}q zPsa^Nx-o&62YH%O1dwymlNX4742H00MjJCA`VgBY?^N!6&G;yfubm?TwJa5PVn2=~ zJ*u!aA1!_!j#{9_VR^5%Xk9E?+HZWB~n_utXq9ZOwd{E+gTV+#P# z-PqATnlViYTY8b`5${Utm#E-R$i3U*R_8`_Dm`Y@BGvRZ&CC2*(IqdhhEjvb)ArQ( z+G*UHn#sL_II}8?k6j`q0C(NIqL=XAr9!=UHQs-IF>UiyEPYTjz!qaf-Q25ywrEz- z`WM?Y+gYh%#%~gT+CqVYKsP5l9S(r|XxqImb}Agf)-xbL+fz6+*A#1OwpuJyL)mXs zBrervn%l_#BFcJ|`I zd6`!1Um;l333cdbAWFw<5uq9hZg){TTRq}6+g@MtJim}i1mlA?HxjUaC5k7-kqJdC z-5IOIWu!Sa5j9QnOv_!os5i42Gwq$QMT#h-g{(kB14P#0%qnlD6Pt$q+F9SVPbh!R zQoN)0^Lr@Ai(Ce=TL(POC((eB7*UvXEN?l7L9*|0J5zVx5X08SuTue61z^BN1)gnj zay+R4TakZNk-A!7X3f`Jn3~1=+3bV60O>l;Cr|DZX0lu1S7)eLqly{mRT?F(RZS?6cZpyvvSHfoo9!!&f!w6c9uTDMnjfs5-BmYmYGd(n50Wt^|`%XRzih1U(+%ns+= zo}3A>w-lAYN$H)`Kbd6c=Y7%tl%GuczgYk$H#A8ra^(wj%R9P-Bn zn&fIz+-$5v{~~eos!*wTpwma6R<0}OCZBJsMPF!Z!`d|mZ351)Byc*Q-7HqA0a_~+ z58&oMlcwZnuv?MN5>aRxwHec{ltrqG)cObC)=ghv(-hZj)IM`B;=Vl#{K4NNJl$7E zJSoMpl&RDDY!@{rDS{6TSiPOL&5Qp+d6e9+Fh^1h><#`{C$422mnCgVgGqq}l!kO! z$Drgv*VehkIWS~HBuf+(7G;0mJ;k_G1ottfy5pZk{RxDtPcS443H^B`H6@ke#wi;O z8n7f;GQ`pSlX*h`xXi+qH=Du@B3NHkt60yeG5rW_%piN{;5At&ENRUYoTk{U*W4NB z-c$ECh)a^7s=m;La~KY^#WD$~vl)oQ4ZDDr#hY4b+ZiMJ;%^mdN~P25`%WfN!O|=@ zF0p-&cd9101CVrY_CH2oCt0`U{q#XvB0D!}f3^*tgM5=rVIN4lO^(L)Js69-OFZvK zgKn6}rzMf<3(B+WR?_HTbF_3uiNKny?E{J}zqKijj}aHmtMESS z2Qb;Nbzf?GwWdX0&-W0Si}l7*Pq!{WIKZ1bZF)!9Zal{{W@S$|fB_fHdFl$LCV6r% zxga2tIvSE+YS#SG#)FJ4-8S%1KuK6TM&i4d{UGv|*0KtE!Arcvn;Nu=H_gZ<_qnaU zoc&WSGghRE1;7?E~uDId)f<5!OU})6VFB zD{bD;!ftff1e&C1(pvL&ZG>)N6DhS$q=^(vkZYcWp5Z=(KngWSR|uc_$d;Dn3!)Pa}MdC1haV65@}qaBUJ&CS9nVSf6f5jNOrEIKk;+I)9#pzl#t zvfQal^s2@lwY#5|J;*+q&`WM6-iE*)xA^X8v3hpJ`4#`}*3J*^yhJ$9`1~q&fbV$X zY1@7vt;LsK>X(|!qs7rb+O`r7LE+yu+ykvW04k`8sqL38R6HoTS?4hMqwc~4+%iUK z2!YQ|M&IMk0tv5&0a5hzw|p+)-=pm=0feai3BHAyZHP6-A%I60gN9#%8+!L5E3FI3 zw&lZaEnuQff+#Y*y1j_?)Qm^Ij&0$7$cU$2Z|%Sy=W^NJjfe3A{g;evw|U{%BtTPzA?EH6 z7=~lb)bNXi5=xHkWQjdOhfQ`*+)}ey?n3C33+|sY4*Qg9dasJY^nU-xel`Jvh$Fah z&osA#bB=BjvG0I{O<-TgD2Pr$R6U{~;W9>lz_TJCS%C3KwfSgcolQD@uQOo;k(N7$ z23!`9nGP5I?SkC>=$Ud|z>_=QwCK;+_@~8fkgVZ@f~+=?Y}ZXM1i&dTjGofNGuA0hN*Z3RlNy6e>ODYhX#+$F= zL*?Po7U2k20Aq{|p9ZXL`=!8eSyn8VUh-$$8T=gAa=dgOhDw&MzSzhhfzPg)KFvD- zD{tNz(e&unaQ4`k+gS0O4SAF?fLs6;&+bWF{cMFK+)_IHGg_mYNW zPcqI0nnuGB&kFwl%?xkt1h&uCn%Q7VZbeyZs)1-6CkR0mNbkGqZ16(ME~NJqboShQ zSd>_1yV?p&=YORr3rYh1<6wkhvfhp8fnOU&P1rI0H2l+6=lGL~-{|?%Ww;p}5`!ozu_LhaLLUn+^yFienaRQ=_i^1LFHbRdyu(}diaGnJ9fxYv9|#(WL46YeTf1n*|PfVek9J zpG3!!Y)CCsZQwxlA}XB71y(a^T>%~UsWnWaxCh4z*c&q&GYOQIW<7lre@Yp#`rit` zGaGmjPU}J$5qAJ7m?`5fo?Bwbw~~v$oq&>7f5HZQ6*4sJmdqDXrtcRREWM~QB%?>V z5DUxY&K{(@=W=9Gh=XD&12SC*ReR4YGWe8&kd&k$TAGw<3ehbSq3FL38oUP!a~BxD zKx#>aEkbp~LC$`)7S=c>x;$^LwuuiW>%{lj(YE5QbFDFr5Uby}=5j4fp60131MiBd zlk3!(eDL`d=9(mze|H{h^=H>xz=^{ zZFtL0okGb5RAh7&(76SNe|Mln(4(ghUnLx~w}wACHt-Dum`#HEb?=OBqF3Y(fP#2I zwZBV_p5MHELRlNDHa)FmIi&6yu%*03@7~U$`-6m$;KYGV z3P-gXM^l4dGT)5khelmTD(5c31CDT40FMb^H_Qm>!>Ew3rciF+yxaq7VN~9vC7F(1 zP3pWBwZuH=P$lot!$q9C>cWqA)&o|uJR5$?iVAdr#!v8|5pRh{q8Lk}(=?B#yMV7Nd6#O&)f5&h}KGj%7L<=>+k z=K??K=U8Gw*e81dT;WYQ7s`Yxr9I-f%b9}{~ksC4rhteXjd^N zqAMV)LhGrEXdQ#Efer}(zhPhodk(-t_r8e#A&KgNjqhW=vrlCmc3Pqw(`e95Sf8rG zw{7u?-Io1V^2Nlr_q*s?am!?@gzCZ(eUyphTQ2~IrN8zNC}4|D8oi=VV5odT3<)yBw$;F%y4HApH>=dj9P_d#o}RkLa2#?@Yp zIq1S!vT*#+x-Rd|o2(Y?>7A^=Stp;WKCA4pJa#?4Ep^@hThRegw119bP3&vyyR25N z4D4wf?wbk3fqGqg-7v@H9dp{N8ND*Qhq!CCQ5gIw+p*m9Y1?sXau}YH)B*35aId zU|FF@trEMc+=2RGe^1g(YA_+#%oJ&&3SB4_$G=B;G+_2O93VZLhWc1hWkg#FHQu_n z6+ztk5LVb<)xb*aU(BDl4RS)qN34EKNQyd@I4FJq%x)HfXwolvCgY`hA`VaEmr!uC zeh*P}e>W1VgSIZ+NFiA5u%JnUgV?6hz=d|`SvA<=+B2vb`t4eGs?6En|~1~Xut6Dztt%mKa4b`<)Q~Ag{@#(@0EPTVux^V zOrgg0J6P^Q&v`SJtqIx<^@@Q5c8~{~WlLMB$5hvGWx!Ekj-y5vAPyl@7m{|{K(_C9 z6L;f~!9y{&l~H;9Bwmm+Hg4hE;zhw;eV@2L_tRXAAjytYoo5J!4jcnxzc0!ylt5>S z-*nyf-<|}>2vI=dUDaQNrNjWi$1IYsxbL{n`j3h z2o{GfXKybi6Zo1`v zqG>9UC&Q?jMN(j)8Dr0C6M!sDJ_aUvi_-#uR77l!nT~;JBnR-GFOaL1=;d)h2UBfs z1_La}l4A~4<)+mZ9wtu#ioAYk-rNG3mA}DTE{mSo@FmVr0%&;A(AZ#W3MHuTTlQc9 z<6;ujNXWwAmazql2VB(_<(nzO?$LX2d-yIp+P_b$+h=!W-Y{^^S%-8_bwam5SEybF zhRK|WfPtU+9cL}7gK3G$aU^bCablhic@+c~NuRNT=Vf@TCp!ash1nqPQd$_>j6uWK z>upQD+Qzp9S9kNxV;FmzPsqz=(Oe*ul6YF-l&s=aEm>t{j2thxiW_`av`4gVZ$YgZ zpx&>_Z|iY2@YKI!VT1LqiOhYvp5~^kO(06r`o!10nR5|h|8FYgWt!+`b^iWaV2f<; zuf?@_`G0C7@BZH`z{w#)PV7E&H!Qx{#4wA17;&rzk0^;Sfa&AKtZS;(AAeZST zfVSphM821DH6N&0|ELuGGz%Z5Ct7;MlUU#j#7WSumGQ|r=u++9-$krZ8LU|J2du^+n|D6Z-da*GcD1nm1VNCB|q z*tK+kEM#yWw)Rg^*O{&^6{7q_OVHxQG}L^MGcCILBGL}x4M4JZP7G@iDMoOX*Tybb z_x<1o(Mofl#y;>UprQN=U+#<19e`7ZbC9L2U2VLY`Az<`KT&e8ndFH_=dJMygm*1vPS&-SF&+*AL59uELeg$V+_JnB39p1fM61bi%|I$4 zb!w19;GoHQ1cBt=#gUO}UP!SOBE){1%lW(M?H@ zs~^mh%nYM8x(V4dUkTrKXrILgHf~PEYUaS#HdZ;}Vi4F9XrAz6q8%e)uu&68c%+@c zR!vHyH+#;8vCl$OSyP>m(j5WR&-Mf6@^V6ZT|8YC`;@q^T^9%@@in;=P;sn1nzKgb z>_NF2Rr^03{v786fR*k;tIy%tJNz6EGeFTw>JtjaUAi|)3BY4$v|IWD|bU2AS+js*VKrwO%a++ithR zs@S-odjXP2rNVpe9;n(o^(n!9yZC9=evL@zoNk0qK1u6OZz?{K7oj?se+=C^7mrXC zb^b*v&l(XXa)-W6lNAFuOe&Cw9euw6{a|doW420sMDi(Vd%rDuY;z9XF-ipj0Y&X4 z)oO%>_z z)M8RW43^(dZVtMyK4AnpXAAQ9>PqN2?V7z^+#>3i9NGHAzgXan>E|T_c5#)s?qwE! z``S8UCVaGAJGan&UwpZta)E5RuclLWENI9%HPEYk=M8KNhi|l$c`iq^EDt1YNV`dud zdpy~LaXM;5>@({VR8dNuj$~j4FroSIu~vzr@AlC83fxgZ`1IK<_-w4Y*_=iRGZIlTvNLZ*dO`&)`?Ff&H{cxL*3jw zTJjt~O+M&kgG-~X?^Huo)9YeX5CPo8t+OTb^n*k9G1xFm7%=&T&MC1^>vlvlU4GLq zy^_3z3;EU^!GJc+syV82>$QvHG7JzZZd?N7A}{wO;Lr~ybtjb(qOnz5FNMcENg;W& zN1?fiXA%dPmh+0>>lJ42qNk!uGzy0~40umbACj%L=#2W;eTR-I5DB>!&$zLl$0 zi~AS)O`s4&yxlSvGmuZ1wn^bP6@Qh{>FBM!oDXX0y_X#)2m1C zx+jm>t%DLB=;w6!kFD-;;KC8kII^WIe*ZU z(nRn7#^)L90vEO;nJsoK&)wP}D{IBY{N?G%atjZ=wCTv+aYP^15hIc=PmUn&BzYrB zZ@2fNjL-Icx|;Nqf*y=Xr$|1h5KZDO0lmJagiYI{cgY$*r(#eIJ0VBDNh%MJqDh6q z42qT6Nd8E?ebiw81u3mOpQN6cHyUc8L;Fz`DBfh9-=n?-6-N2ws|%s;*qzLR<8$Z- z0B|IrEkzFWtd=s@cw@nSo#xDB;9oKd7?(?n8_x37qOca+rcV@2dzb47zL1}Qm-X6#T9=q>fBbw zen9lp>p+`j2v7lZ$q-bO5ru8eA+AHoK9eY;Q zhVJD}UR|2IUuP+bo{cxt?Y+-T%t!1zMgGHM(;mYQer;FvepXd-g$Wz8pdFhP8KN zR)#+lmw@TvJ7nLzrVW)h$s?zV4{p4^WhY$iEm5CSfz2!$g6;q)qap+lQSp_8OpO{F zB(Hw2!Fw&)pP=+diz!8W4AiLeFD0I3C@})-ah`0zJ4%_(YiLI)m05x0)yIa@BD3xw zbv^yJYI9R{HqK}GXCk2s1UFS6pa;l7af*z-i?9~D*cNu%4kOrGg|-|&WvpkR1J|x53?=d?yLT)4Yu7jl#N=@cGiE3k+``V}x&o^FN>IIKd;m)!36H}L*x=&!O$Y z_?4d)4M>bONVuEgw@ActFDP4)Pm#Crl_e}!Ag@u>B|1u%6jSzXye&RK_AsvB8VRig z$YEb0H0;x7`@+_oyV%jZy*gKb5jb_xNfoA4N714i;<5Z?9YDtqKtk9(+JwoByuo!lzsn>zwRc-1@@&zO=asUb9 zpfW9(a}3yT6@$7!wjNtTe`)K20E8lrr`q)GGN?Wzk@;&;Rq6PJgVVvZ*&({H1PaOX zWUt2-54{a>4z=}WoJz>cc;sDPx5R$wbs=t9mza5s>LmkOI%0PaQ7jhI+i-&817zBGi2vJY?Eu=8Gylru8}Bh275TF-6Bz z14)-#Ho~sa(EMyYgC6y|Y3T)2y5J1DP~@zxuysB0=uf5jAPD? zbMkjD|DG!s|40_f7vC?;uBXk+_KwRKXGT^!)DqUw0lx~H<8gKXH?WC#mTjy5cKoA8 zSb&Rz3Zk*#vf8Ay8rBht0BlD0&rDRendehR| zda!xfi4L{`*2HSln6hQ_c^4&VkHO$6c_flM%KsQxb0L;_z7NBNr)5)@Bm{L|AH9E2;kDc;`cS?cBx zOOM0LNb3EpIGEsKIRLoqzWmfAqv;5cV;yg0yta$)G+^tV zeGM$IEEeY$0g{z$|LTmn5iVUQh-N+iojK z$~Vvm4zq&Y@ju)aYmm32k5Q57;vB0jyhje;#pY6_bCo!@`RD(e1uQuck{4?PivUVM zXQyr_IB3(7aI}4CO|1Z6*@Y0N?oj)SoqM}{gbU7+SxCv)#fN$nEYEzj4InK^xKI`BQh^ zW&>E`+uo}OV1el7l8Cx!>5_SlN|>-DJ^N@FztdV+4o<7OlwlFCe7G|!C;NZK1clKU zdT=U-Z7IKJu=WgBfgRX%HF1!n1hor_V)L_)Ed({%huM)Yms<%#iWlbZgHE-S=XP&A zX<5W9#SN~VFTyNXb((Fqu1}XQyYKe&v{)L$QPmOX!dAO=Tvax!5crrOYM_mveP+6W zdmQ+TcHPO7YN5?~kitC;;6C8y+N2wo7vIu*oJw1Aj=QYN+Y5B}mqH%Nsg!e8@g!g& zOymHTm5ZqV|NK;6kqdCcc*p+1PP>&!32P@_cwnh@n&2V%RZX#OCe_N8XP7e9FpyI*Xkmcw!e z-zB^EUZNeL{sAn4-rNVoy@_@PZ;`JJy{Z`_jqOqzp-yTq?HMs@j zJC$C8#|T$ZyRI7(@#`zp05ZR`wz7P!Heg%vTXXoSPWFyq{#Oal(fvC@?GQpyB3NDq zW{b{|cjMdhK!}2bn7<%f;0gcK1lv=^g3R#s)9}1Xi%&me2qT(YV;s^i^W^Lmc2@*} z8)8tj*?a}=dgnN6=?8kUm27*$^1|+Z2o<+nmLivjtCyS2 zjSZGNHl;y)#NZ>$DTf25cj($PsWm0OTjZ1j>?&IN#J4)@M_sCPtmM<(*-M6OP z<$AK^keKh?V3lc}^A3sOOSV#8iLFaJRJ|#`O4e&(;QVWoW!}N8HDB?NZ3Y17H%15F z7Q9Y#V^`P=@idbOv>RQc+GPnW(=QdV*FP+E<|VdQmiwH`e3+jCo(=2_qIV63fpSzv zv`MdRzfX_d`p&O8hO}9(ZF>T9lb(UCa3_o{nJ_H2c~4FtPyX>rpKe{d^{cy=r?6qH z^Dt?;hP~U8FwpV@`9u0jd~2jhdl^ds;Xt$v(8qk$ui~L=SyCTQEM+K1`UWexF5FR@B!b zR{bUO4zLb-jbBmv3D%c8xI@H9&QqkiZ|W%rnDNlB6EcS;WDM!-=x9VVw(M>SRF+Vg zR%u8p)th;&)v z(rCt4i=nE&Ldzmf{xNqG627l4NWz*YY531~YdH9>ME(ti z9XENAGtnE7H3q9c^@`B1_S3F(wf zH|s3OY>B`{1A#YEchvrunMR!d&W;jQ!>sJgIhZKI&Q>qdOPKA;S}pZMiQjzijd=JH zc;|Y#%b|i+BI7yx+&Sj&b04*hUr`|uyp-iFUzY6;>{{WYOwc1&GG_bb+A$#gz0{%Q zX6Q}fR&~uMQTt^oTXjOVMRO5u#lutePM5a`K*JsMCl`(EK?$9U8lv_I*Sx2=arGej zqRRxSoN4>3bLdU-0V7pwXIMl;wfHIRsMaAwUim%>dC09G;nRyooUCEUS$p{=r5|V9 z=|D#6$i)M6N1M(ug9F^E`$b2J1P@!EZ@#8|$sDtY$Sjft5)$6P*7OgQ`gDb~S0gPr zj!5>~Fj^_>VRj|2Qqr!O(4oLB@Kf|tH0;TZ4euiftz6d-jzepSuEWwvk>Q~9(IWZy<6>@c}lJIicI( zBT0$#jIN1c0Y!EDGI_Jq_Ly8E#3Ld${v~ML3x09xcho6wyrJrcKL=C$8Jc#r0ao*= z^cM;!(TJRrpJchz?C_~hwLlT9UX|3mp`OkTHb5%1tMa1OIKatw{ts==HDvZ8I3fbR zmAjj`ad_qqQn|~?uylWlN$-j)Ka!YT8Yi89!0)o2c=bcJKR9RRAI=}cnCFV?oG(FT zA?=d3HEmgl#vj8GuWdE6d=uYg#C*AI?c7vTLz=*KiYAZ_LCx6Z{!!7NNN|~6hgW<> zEUO??a!U5R-4JhaQ~39hcql@z%Ry*7(J4NfZJ%1}PEZO*2sT8pa4O+fhhO1ujF-MV zKmYLM5kGzFV*TWr;;9LIR%(97uofpJ1M<^lAJ0xN7CyKv7AYPTD?I@NQe4}1OGWKdyRKto>sYCD>viy{kFW zr*9^EH8pp{cfCRh*P1b$w*gX@^Z4CBPcOeYpWV-H)-??}`e0lt|4q7a*r{!1k0#T) zIW$9CJXx1-*X@w_Pp3V;V_%W!BSntF<1#7MYtDc^T8nKmpecKf6rB>B{A2S{SUkk< z{Dk~%h>KhL4?RouDEe#)xCW1$Y7F(ef-hbhMB#b9YPFuTR}xva@3>bO4%0a`bGDCS3oI<&jJ>waQ2M)(v6nA z<&xL2VV&!ro~<7*Wn+7eBoPmkGADanu1?s0=S`k0kA#?a@{_>FM}JYiV(PgzZ~_jI z!o2VNO)$jdM9lt0avw0yuc+6?5X(Ii{{srS!Ji@wI+tSB6Dom5BY`yZ-YFg^&!QJq zIVk3MC+G9JGcKUPGz%osbYopm)t+yV1ro3ZX!25 z5~!rY{ZP5K!4X~X#zgE3qg3P9wl~9@ly&rlF1<~TYh^Ee0^J=l_X<=*T(gbC4Lf{# zF8dky&OVDZIFb<{O}hpgfmL><*9V4-4psc5{S*8Rw}D)=?#*lU!~x%J8@ihpt#bz3 zS!;VJtGf5$FjO+&z&k2(BPrX>{rM@a8A_JH)zDNLa9jTHIvq5B6ZGg+V)eOg4p3F| z|6v{6F#)^J|W*@rIB^dSe1leUiW#N2mv z9@gpM&7aq=ThJ77H1|n{W>Uvtn7$IJ6&n#<9p)*>O6*ZZ}8^q73!>0A29t*DAAGojCgUhMV#z zXjk?wQ1XHR57uM}n{Yyi!hI$mq`Yur_x1DFW6$<>{k{>99vpwln|s~%v-;e{Pr^sA zw&V4Zwz1jR+D45>k#C0eHrJktnl{ldPQIyJGNwU{)d6ar9(+D^l6FXT={2{4<$C3;KzbJs6$dYPhY+-=yLZfIO)MYLO5Azzm3et2YKRd zf=+Xm+n!?~5m&dmU~;M4q*e>@$p!c>=e!ki=o4;UWuDXW_r9t|Lf#)+59x<@pK~~W zbhOvI52l;J>7<6vvD;l4K4lL0PVq2kYp6CNQV(gIRe|khh5dy~1GnIbH5%sR_cTm*!^gJllNuCJ^PgU5OOj0A;#+L@3J5I+>g$ z`isWxN>6we`b7j)8ZwAjvcA028*gU6Gykvd!E66F3m}p8W`^y2JykVGUr0f>VTKc~ zZ^qm|9e1q4+;x?kX2lll93}w{<8x&Vzr+=IT_)j*L-V7A`o^+lZg=9TGmF3lFtXLg zUt=wu9UL(}HHrkFj~dJjf3JMX(E!-U#IU?O1Gq$sf4;Z)^e9d^ilW2(B{lH0egx1+ zh;IMOjuZ7h-xTn=++b%)m=8OCXI6`bWCRPriLIZ;#&-_5ru;;EjXZT|ktP4q(!6Ss z5dCEW!MnmU&e&|dWK)}VB%}IyZRX0!?Xf8u-*I!;`0o>>hvWkeiU5*A5PwJjGamVg zvSi{zYEHWG{s%cPWMxJBFiS6mti9>8RTGzJ=%pLv#=uEyK56;{Z`sF4J|Nu6Fn@z% z=Ze(qma1^}Ey~9jPO^0_7!`HY$Flo4gj7G~Q(1nto$!ijAxRU=y!s)7%#@_IimMYQ zL(^MDM@(wv-Q}^!6QmoXAldEXFi=dCkpIew9Y*q_H>#Q4KiGF4OqZ3*t)Q3g- z0ioeW$Lu!8mD0aIJgnIX6gsBX>L<6pP&~;FDS(v_M&9Ua;+IS%!{Bqh>qWHRR|eMz z>$P)NjSM$)Dg@(tvgczX2J}QB#1MW4coP2YYzX}Qn6@VQjpm59q~nFal^3KwG31a+ zJ8Zali*&wPJjm24a)#BUz4o#8R{!TU(lBH+`K0VQTJw>8s`)ZuY(2X+x4pAWnR+x^ z4>5vDU4I@PtvEg#p#y3XAJsakdeYgabjD3&tSG-TrSK%s(vPrn7V_tBTl<^4c`Gm_ z-A#?P&(AA@hS;^2L(kOUZ=klK zJE0l#P3Ql9DQzKDn)=U413eJ@ZvdJ(9+cnON zzTT3Fyl*kTw^dJOozs})AR(SdM`YFp(Dv`IlBlJVWo77)QB+TQ$20tSzmE2tikMlT zg6jCGo7e+4r>_@y0Jy-p=p_@?s43CixQK80T*Ym}{xK05l6-qD1oeYT2ORBB93*76 z#Ac)d7zSvhDOl!b1nWmT^-^t8Py(nMB^Yb~4PlrjA+RRN$hphq~6FQ6-*0YI zxem_qFNi%;(mlA6^Q0!9>xh0tMlV?-BqoUEn?AiqlV`hp@Wr3036dOv8>t!Xo2DzW zh%$vwI-@y`*4~q`1}CSR{pmNlx$PtVG~BRfO{)9`i#?JwAM-1s@~W3#TtnQ7V59?q zlUU`dz-d)uIP?oe@V0iyfll*WLM+#A;&gf@4PO6qWn{bcQjz7d3Gfhiz)d;~-{H=e zw(Kl-yX}f0*%b*&Zo`g}3Gvw;gi{Bf9IR;Bd_?T{%1(5|Jo@#*et`dE-wdOvLFu zdH`jDwgX|zyE{?@#OM2wvD`J@V-o^U!I%~TSf&LG#iBkKgnJ_3`yKJ|O}$!n$gQ_K zExQ{{_#hI`+P8W(_KN7;v8W0}mJuFs7-ZxK^~6_ygDe<*fhbsa2ltkI!m zx1O#zq@339Y)_H!lAs*Np2cUE(?G^Fhd4ddnGzvyX5t%%BB6(%`F+IlL{CGaNY}xA zd&$?x?S;#>GV`S9RL?IC#^xi{T#Sw;rh^rBG&OPv?pq^pzg~vVio0&qEMqqJ9zofF zNnp&s5c%r*Po2#k(_Xz4t*o_?*RM$-brB?@ib|By^SPwDn?lC+D9g{F9R_U!dAw+t+NjkbiByHyi;7YN1Rt>4?uZ3P{C)8!mF!xJSycMt;Vf zr;(*~*FEFT5cNQvYb^b^oX1_PMmG_Dm6XDCJmfKdD>SO}X9vzjSZ`GOCC~6{9BbW^ z<_N!e-@1x$G;k8Gi(Id#X1K_1d*Oc|@jho)6CSn#{h8uQ{ZxdnUV4Qh7}=U~T-HA! z{iDpMD`O)i)l7@)VLNe>gxt2Fq)m*u(^J+)*nQ}B z>O$n?1;N%co%e{VYY_;?T!D&x*Pf@t_%p}KXitpJb1XNd2wUJtfW zG@1ngFZsH!r&jKAI-knfOsSEy-t7|o%Z>OGcv$qjtCI?A zKV5$#b^X+nsx*@OI^AF(hPn1K*Er8Q>%&9c14VZ%xzeta448frq-GZ**7Pj6r7pF0b@r1O z%-hEs(f(8)TEBb;o z-265@G)KG~8a}OkTyR7A)x?x>Q5|~;N3F-kvV0;QxGZUcSR0EPvqQj9U3&-yX2Som zJ83u(ZtSpJMOt`zGrn5@(=Y1e0GD@}V*7-Z-n#ax7XM$UPt*sL8x0mjPr1)6eeWfI zf&_ZS6b&D}-ul|9fR3F!k&-i(Z>$WD{$G~y3+|=e^!ck4%NX3+JhRD17qX3Ss`YmNbd${vtHXTign1Y) z@rr3I3P1t-jA)P?QZGH{Fy&=)zC_$M#NM82(mJF(&sP3;3-}_Gfj(TB((phg4#{q8 z_9pbT9ZnBOCI922Fs=k}CXph$R{?s^*=>ufoxMN6Pm#m5McXr0lD5rOm)qJ*w?_)l zE>|ppE8Du6nTczv+2md3F6MY7duzHW)@$tx(1+<&u;s+Koh2Jh7H+K;!Mg`hEGBMh zWS1mJpb$84ECs|JNuqbu*xF5Kn5m({e|z@GPdfr2jh%&+I;2>N=5EwCR(w$;o~twZ zV=k&=p{)y^Rh{;*HC^G4+{~od=B15JccwMoWM5Z6D{Tl$zWBHT_$m2?oH1c;lyr-c z6UcAiO3C4~$D57&N3vUgE)fdrjFnfYLIM(RT^taHLvwsr-nI8X>DQuN1DyLs@wGc` zLrs3O504~kl#u@u&)FXE>N$(^i@)U?WX8UexJ*C}uV1$D-KQle`ZecghwDL2)+nnD z3q%j&^PI2mW^n!nTw( zMN5fegw~m~<*fd;89bOmG9C>}XF@zeq zg2#vlUM<%}hK@!UC;V639-~}q95Y*9va)&~Wn8uFZ}2eY|8@4|VNGS(zEy3PmL*Cl zDj-u;A*K*DA}UiLRi!14q%1^0kO&c?GJ`+@NpJ)iqXGf~Nhv~9<|%+cB4cDsAw!T5 z0)!ADgph=giI8{b+ug6<*Z01A-}{5_3&}Zq@3r?jYp?ZNzqOBp*J!dzi?L+U&<||} zaX7{w)$aj^Ic?njN4IGb(c}$&hH#oXT7V1kMU3k@wB>GqBNV;u@UqaOoFCS@9OkSl z6=l=UwL2lhmFroO1rXoFav;8dr8bRE@l^Qs9<$+|Z7H8dJi>?Xg6espp2G~Su@j*~ zb&|~;?uO+{scYo+_R1kLe%^gY?x_@1%Z~!OegNcD!}9`McuIVhO>vVrH8SVE$aD(z z5%6E{RhLqdjV${jYLBauNb*;XPqUlWEi5Q9;!^)k0;VsTr|$JaOA1^ud24`2)>ZHk zGn^Vw2!p84-cq?mB2xL-T9=w)j{ih>Ilkg-Kx5}hGhZos=dndIR|Z#f!46GCzS(NB zq;VWgb{skIrJ%?0(2=W?s867==3To4otMrWJrfm_y=oF4+UJb^Lohj6q5Tu%j)Uzd zy?EEoFAFEmQss1c$CZ>^MN&&{6e#{$W=;2(P*KO_GWXuDGY^m@aa?4u`&j9jz`W!a zI&>r_w{TXZeD!P?k>f7zP&DUW+P zu+`4Ly`nF<#!1J)NuM_Ss-(JI*!RpBf)>{IFqCyi~VV__g*uhn?P= zg=XJe30o6yfNKAdxnabC!%E(5{1$JV=H#h*jOmz85&eXfJvQcl_04`nF!;<-=odq} zW~)C=((Y2QHAaD}^25F_`?QZ^ZniN@kllFG#p0|a6J!PMl8DoN8mHI#=WA*R=b@S9 zJDm3Ux4vrJZ;LI&1OokuOstY^H$FGDhxC%!J znKoC@^L)V07Kzc4oV4Gu`_A9!yg@suAIUVWRR>|)Z#5oq4imbWI33mAou!m%)~XgX z!*DFa#47{XGL&yZ;6Xx^x<>q`Zpzad!R^*vv+T9b5U;lsjXIfn*wNKi=<3{nKvVXD zjHx3V7;b>8L^i#NU5t!DMFsHmw4Oq9m>KC7{u;89~3#Y@ojoKt-92EUVTdw3Sm zfeV521gn#>QZ3ll(Ht;135l^v)As4|3Zm~!3E#z*XIu1>EyIzG_yYWL-|Smxr^s7i zabOCGn>}gM`jq_S-V+zK{q7Kl(acawn!8(|j8n1qxKv#!$jQMakuP>xj1Bii0}e?x zGEim)4w_C^p0>Fn=+R3^kHuBn`t}_Jf(h06@IvkNgxTKpgxPks;-)TiHREXa{U}O} zq^;)2vlw{L^*GLOUn6h%$=ajRveK>=;MD=54q55!Zmke~hY32J(^6;v1kdl%K6WnMZxyrzaqJRgfTQ{+oW2cWjbhCu}>-$*_Q{ zeTg%3IokH{rz#c(TbW~#HWZsMNA6i5IMbXzf=oC0izhmdL7>FP zqlzXg*3kiParU&6Z@P`uSJr?MRg#gm45!MFb|BFwYX383AkAg^azh>QzAL+LSAa%n zLu($rZfucXrK$0F>sL?v!sz*qDFo zoYf|Rm9dkuD?6w6BGF59iP+@@lrncFmTI4vxAN(@8m2d0nF~srxj_?G6Ikd3lM%x{ zSF9OO#MkM2T63$c<%1@XowVKJx}e1l?%^4@sbD8@`3ZpW0LiaGvInf!r-K6=2aMVg zFq=F`WyJUdsk@^j@OiIKy1i_Dq12OZlr z6P$MfLM27(;tKX)k7G$t^~W}m&T*TCUW>8H7p)BXUAi-v<{%Xm1R16(mB+na3;E<8 zCsn~3yAC*4z*T_%t=jG~JY(MKtAbZX>#L>Msb-1+7Zp+5W@3bgfA?)PPhYD{WjO?W z{mw;kp0ZiZ*2FgORa4p~bsAM1wds4rDfPhalyGj(CPZF%hwqh%s3*KL+WU!pa76+1 z-D)>{jmv!@1VI&y<6eh@n*CmuY1(z*l*V9@8=~D?D*5KC0OLZz1$2eYuu!wcP4z0% z!`6S8OT()X#w*tCIvNzcf{=gEX)lOA8J2!!y1+%U%gx@C04ZvR#B${ zrTMM8b>e5TrK@3>P)xMp?gTcs#;?z>lg}ZIci>0Qu30&*hFW!^TK_z=+DZ*uj39(k z+&7=-*R^nlqZZ|p8U57G{`TbVXjZO%0c#Zq+bt43N4e1mMcNxSk;I0f*cT|IA>=Kp z0rEVvf6y?ck*rZAGwy`23Dm<$2WgW*3ngh5wB?1E zLG2V*?(-V&6BbPVFr|#BT)`RWyjy9;=RQi%tq08KCl&)zp$vP48*Xd%u_cgYso6l4 z5$&zZxaw+8W2~>NnCq@Ye~u}Sq16PTWDW9<-om+_=9P-uFz>YcK5+Y@@wO1n4iZmm zyC?P!X2cUzXSjIbmEzp19;6fDt0%~xS6tAugOWNf=6P3v;5&W*%GnBr4((>67EIZsqWtoi^`{(y%_S9AQU^<=38W z7)c`IU#w1ol_z(M*T4HvDd-u>$_8gsSh^%A9$T6I-pbaJ`8|;6VupoPFugaQgUmgM z{Hf2zTZsaC-*&tyvY5WDS5LTsu458Z@Dy2M0`;v))IUJqm7Em^pz*N}Nw|5HN&uvE zf)FxeK~%f!D~hB&DFpIt2zeyoDq$vFyASg+pQxNieT21$&4QZSi_&X3k94Kpg5zn1 zL;$k?(%BpBtI$KH*v*C5maODdJSfm7IruKtXW#eAAy!%?UVzNyhIi(Iz*WspM;+;n zoV_-L8u3mx093&oZGI=3yQ@jI4j$?C?a_tEk?UoRqrOuaz=m|*Jq62cYGp^!>u3|N zp(tFtw$lJ3ODRt(b_Sw9lA$+dHF%p|Nj>0;uB1D9#d`lak}T|c-agT1*;I+?z>_Z~ z4yG~tB>E#t?8;^0cHG|MIkdCxtshfrgY$4Sq(@dVb5p~`==8;(lMGf``jkOhBgv*f zV!w8Qs8JMBmM$8T>kk>u&wP0j{bRMX#mm*C7xhJTta*Xg0)skT$)U)a&#AL+KpciV zMBFdWtN8{A+L39asMD_&yeC8ZSo6nW0rpR%?u@jK-#p-b!h7YKxs)n;fX7N=?EM!I zRKYGCtWA>OS9B~&k5-4k>{TC~=QDwj2Tv}~!%xzFu&%a_)Q$@gFcMm#ndL3J>AG0+ zAB-zvvz-AKrw1w74Oe#$=c-o{?)2d|L}L_&!&#s)QFTm3vWfBA=(grjye%&91xwo& zmm(_AdNx#h;SwX5!u9FO%`EZo*6`z4)b=}Vs-KhVFlkLvgcG*lc%Vq_D=R?Q9-{on z^Q2pm18o=6Y@ryjcL(hOi2Nk(w@8u@!}~q%J0)Ht5)0rOsS-&NDr*z&Nj)@+(YyoO z3TDns4LsvkuL`cM^-Nf!kQkEXcqWDe^9-c9)L9l zRfer*tl*`^^asZh(#La!=Ae}W{>93tkPkHih#un;O_1Ma?X%6txZWQ|WvIk}hi#9D64K-F1$dkRpmT#dW zW&HtUoTX(y%pP{2!w~Rm&y7$y%ekg+=tE^&&Y6Ou*i7Gy z-KQlg(AO0>s%GmEMxd3!G)_-M#acqZPR3U=?-0>J(|b1Xl?a;e(#)Rgf5Zr8V|pSP zlVrDg1xk(CG3tKh~%h2cRl0sL|}=Sp-(yB?!ApS|qZDsASkrmOxU`=UM^ z>L@Oj9V#1U%E@kH6*y!`jA|y?9kpLk&S|Wg;M+O=lGdERujGW<>~<{9GKTzZ5W(`d zz-X=jeeRQ!)HWqt)cZnSb8R+`Ueza)8wAHnm_ zIcw8*bG{-~8B-nN-W=e^O`i3{_InUqC)TPek=Zj1uMK0%^!E##;%ZuL8H$Sy%HYuV z*YcdYR?dQWS8?q$*-*c3^7jb;)+fSEl;X&#M<4+tnvLj`B<~0@CdcM@R?LHeVjB$J zV+)XEIxh-4%BkUG09Io2Ih*HU^iCOT!Tl;a$v`xOR4@`;%EW0mACjJgFw30lqcB4V zfiEYCvY&_m%WO0@<+jw;d=)?1RYAFZ@TCCp(Mj?W|Em6rB=B!c%Hh42SijzJj0|Dl zihfzE{-f%|E#~~S4U;#>1(x3%ieB9?$+Vfax4-+D#7g*KH9uq62(t|$&2fhPFg@61 z_yeVO@BleVgq;invbzLOWh%ETIgozc!M8lz1Yx>%h=?lr9rHWj)Cpv}kOcdEtCb_| z^lYHfgUKeAW1-!wF{2Q7Z(wsd*x2(WY`Q@euy5MK`ZBiY_*&YoTzartPc;$t4uReg zwM+8}O(XmS^T;>I68tbS%4_6=-_Da zM8p(ptkaZWyJGX`!hCZtEZ0`t@r0b?Y&$XIUP&kp0cuYTa)0EUx*@A_YSd-u^{lnzj_*bLF7yI-4Ef|uC}fG`Hob6>WoVu`oP~yH5uL%SIqk? znDeGaGxOCwzsmmNY7CSS7S>KUpki;deM}p~TTrCs-<2e0d{^}kEWmhwjQotnDY$JK ze~Xs!+c9tR(fBZp)g|=jOPb$L+B$7jIh@{_55a8hY-8v#GDEui0yAsf8;^`#=9FB{wy~#XRJs0#*MSYUaHALO+Mm5tql;Whi?nNTthat?ClRnjW!nd(yM?2{Xv5N` zD~_`e0x@cE5=Y%u#}w{mNyUY)1~1nfZcX>-kkuzJ2f1`2pdE@KmfZEJjAXOh!c4Y z-L~=f>}?5GaJ7WV3>(RBKrhRJ@LOfqi5z6oII(@U#cCuZc3&PvhwYx;P&Pt$2{aUD z-rGg=Kah5K{XzV??m4o;rpiGF+B%RjxTb}qF0P`c*Vcq!=~rz=V2UNkaCco#x}0<% zEsAHHf+iS(`eWTA%%t9{r&aw{BllU@QiHSP#( z`~xY7~(Y99G}(f zY{a&d;s&tS#w?M_Um(?t6{S|u%!_AcFm1Z)7#Z;D9b`*}A0$RqYcL9%9(z+r@BXppM?wtx+>=-!{R0%Mbn6c z@s!aWvoRLCW*@W9i?}PA{zn46%z=4`uc(4p&#Q!lPq7=p(C#=zR?k&9dKOm~k{6Ac zohvI~E}>-&_}O8!wL4)aLRrPV%=6-mjrKQNOf>ZB+2xRgdBYIJBk*imTN2)sX(f6! zD0e3ul?p;H|daPMCh3at=4exjEF{KIgup`9|X<6HK5lJw$;Ekcmk?I(C zmY4ZcMt?#`cLOszRe--}GMwzVV`fYGUVM$M$Zoq`m;B>rX8!9?S3llR-Cnm%28W|= zFQ9^~Y7gK#Z{p>}8-BFm_G`Df%)MuqZsGqlp_u;MKDg;524K+Z(9@HDF~qZC4vzxv zU>XX6Ogfg|uKf($2dPDLc!!Ogu!z@HFCepGAP6AjhirDTa3&%yHSB=WK6HaSHx+wv zzjK6PuupPmABgm>%AF##=*10#Ow3K1en8y*Xxf>^A|nfOt#F!igO+2GRuNtv^eP*a zcIteM$P_4cIK^d%YS!*UhBcU!EQnX&vn}V?U!KSWBSM(lRAXT_d%S8o-Ps^(2gxrV zW?B`quEF0>Zr@dt;$jp}yQ8pk1ztI_@)%tq?>t@Z9;kiGj8NL}3Jk(b`KE6%0oY0L4z&X1k$|z;JMd1aPS6rIIEEL3W{(}`57pIp0b9JFdDFe&{XqoAmOzQ@o~|rxk`ta|Uwh?#W@7Qa_pU0Y zzGk93qX)0f#rC3GqH`insvH;$HF6k(|DCPNT$TUm=0zt~fJBO<3lLzz7`pcSJ~==@ z#>;^nDU@Wp3r_jqs0SPu>JewZ(P{QWHBf|N^SA6%E+~F%h0>1?9g@+G#~v>b}jvIkW28|6zR?fQbh~p+t0rXmA>lK|~ULq@Odh+XY zWBxJmvGKb?bnRCLyO6Lz1wcJt*G|QP4*;-0`ee%y^z|KI;h@7dM3v3ndBL*W@95kG zuyk+Ps;&VU<%(%1W=Z>wC~ori5wCKHD<9q{`x%PuG~kLIxrdJexG0Ij@g>+C@!v;# zME`J$N^=)+!+D9Own>%MJDl7ntoomaHa2Y3bu5k)net!gS?zDrt$R+YoNC})fl&aI zz^b2X@eHJ`xYYlEV@|Z7>IqSu7i;>?QdZp>7 z<5oYcOr;H^Svh%C!_;!fB{b!t#utgE99h6;6@25S(JACA+*R5h@$^v0rk9I;udTUG zOPkQfT-(vteal&U)E*XKwTV5px5it$wER;Y>}}zrFoDg+!(_4o)>OmAT`lg23Z+<62jow zE1IWQa?ePJcN%Lmk)ohxM;-h6=<=H@ith{@fwMD00NQFJognGoOdI44@g@u}hachs zAoq?afQv4Abrqu~5lA9*cgudwtR5RtjBU5r3-3(9C`6V1m4fmw-SCCt5?YvYFdV|r z<#mhZW%*z06w=@EB>fKuR>a&MfD;H-wpZ=N@BP^28k;wnS09)VX#Zx&d4uQ6g28Ii(00+ znbegh7{W^<`{zz2EX#M|Z%i6!7i&_xkIVRRRXjyGSm zBR7BdCK4Qzkz6v6J5)=}qb{BWKVJ?~M~Rk;Zeq=S!>4AFi@P6=P5-6%i0GFyihIR` z?i-EI`s=LGm2F#$y;thJ|70nF(3^}&bk5Op8Qnu&{fvng-Q3z-(gjfgcu}qgd9VvrFg@|s;dY!b(UUMecy;TRTLZ9DF;M5qp9J*&Rrs6DR zSRHaN6coo3i}}18qYT`}`z~Zbb&MiVnHNmA@4wIwQltHAQcZb(0Jx`5QprsNYN*su zAA$4>owY~2G&)h)yub8eo%n&hh}$7~&@ts!DfOwC2emFvCy>H2VkjN8vDmP$JP0_6CSvmbMKZ!EQY(t9%OUu{$0RwU zvjGmX*YX58%N&1oP%LUcbA@6|u$c$a=#|mEnX+$R?=N$8^J_?R@P#!D@`G??wVB0= zxwdQC0b=MiG&kvny_6@7?U3SocJ9iu}N@;Ga zYn-65#tc<8jd3Y-7buP_kSzaB*sf|H#N2a(o7b}-=)V@%IHa$oWA?Vg*W!4hYqdOMr!$tQ){tU zBPaKH6~drx%ZUN+?gZ6{7$cn4f5p2~z)KQ*aiEXz^F{;u59r@(@MlYSxdhKeOh;Nn z83w3jx;TELEvIeH$`iMf<75UC*l)YPD~>jOBdcHu;%6#rlyXpmKnzAVLb5Pn?Xg9J zyx~q_O!xN03!P~X5;==AR;`FEm=>J7pNwnmX7uYOu*39h9X`PTf%8eNGuD8^N(w7OLNR z0l=mwpZh%E(g^aE*(PaW3SW&5;?GNEMfIRti^;cQ8=$v(V&F4-n8zTQ{DrZRjJkkx zjCemnF?)GF@;P7cxmO#D+ly>(K+4cb^w$VYhScL_;~iFKKzlT9_rBSA!)uLMqhq~K zhTNWR(75U;!U+pl#ddIEL`P<^s?f`6tZpUF&^pcOd(Nm#QE61AF2Ydq5kS~|2s);V z{o&@0xmWxLKQ`>dVm)AAb2rEa#n((7A4FZJ8)+Wy++dWq?b|Ac^!t5Z0`MX4CsQqe zO=**)9S=ANvlT#K0D(Cu#Otx|6YW2fI=(H^{IoJ*6EK$lKBame;d(6aM*xZLvlP!b zuhu3%u^!p2E#8hnz?~i82ZLKV^0GqkbFmp}`5G^yF=oVMdoI`-)c#@&{Ht_W51#kJ zg1iFllPD$Y>7iffoU)c3tXObb&Ww%r3!h?dWY59_=G#?=R2(mv@l~_r2tljmE6CoQZ zi_H@(8`=G%^`IusOASCwPw@cKzm@|sE!ma8UO!ht=j>fGX|XZEG=ul0RR4Ze+~q$* z5Y9xayl}v3G3fy{>cG_}PVA^XnhXxRSF&>8P3)oD0Z{qfw^f9nw-Ug^lnDb`U5H5# zv1)r9FSa|U$GmZJ-l`e3@$~_^Y(C;dp&;`xP$2O(4wBqnzPkJsAN>kQ0m~E1^DM`t z#L$+cQoxgqo{Fd6=I95{!1aTwcW|JhpD2M<6;JrZA}XW54hp@w;j z-0HR8Vp_^EYbIl!Bh`^wwSoerd9sQFr8LPyzTh^wIXFcGFn?IX?>+VH%YR@2xZ8=f zZ1v)lEakCD<4y5>S&(68^mrO4wtM>X&|tJ6jq7AU_7*4{_{VU?F`a{kQB-7337fL& zT#pEwu|CGR+?wA`yVg;ZPg+Z7Z$>Lw!gifx(>1H9Uz++(*iQEdAHdWgJ8q3coGZ_# zNb=9A`R7G$+3!9Id9L1GV&V%`GrMs}AdHK1rzl#q57p^D8`6EJ?kE&D$PCoK12LQs zqc$VW{`x9GH|s201iu)gTKOG*Hc2J<)ppFNnj8b>NY3~Fyn$8L*kfDe9g-!PDlIK$ z@Q*!txe%6yn zsv0@!W5l%1%wvG}_;o2tv6sMTC>H$yiM@M$$&5D>4WeDTLDQ~hS`sty`#1ELyJUjf z2a`*>r*w6F>ukV%i49B34&g2eLq>9M_;yS{?l94(=U4KU1nn90n}8$iPglwWA>E3i zw_bsbe|U6Le|Rr+*NURe(Tv8w9zy3=P3vg#38MDJ)n!guK)B#YX0Dm$Ucvac7jlTJ zGrz048z>XkJ67I8{5l*`exnD7FsjY_*b5)MM0H)uB$AcGRK(&)Ep$+rYOgbYr*{1YYcDH;nejy zu#|?h>Dr*FMAs0N(heqy&I@bVA4G3Y<^iPd`^P$0&W_v{X2Bn31Am^;x#ShmZ^^UsA29YoXw%f&I4s8|tjgIA@FOJ!S66i_IFPja<< zfIND1K7e_+e7i;;7I9kDF$)iR~n_IO0`G`dkoJ*{-dEODoBu){0n0i;$ z1u^a`n_;ty9>JQ`sHLW;ZiGto_bjp@kWUt@8`eW3OZu^j2?rcG&B%c$WQGzx$pq@C z&~8dOdp5e&hv1j%F11dPA1pY?iOwenUD9^8oeyCi#({4#Pf1<+-EprdzoUXjm8x`h z{^}iJof*}#^g+v>>gnaFrFjdV?&wSTJxPN=ok1ol30S&7QIjgaTyv04#`N%}t($Y5 zX4v)l6?Bwi8=<$(%V4buwJlEfATfVzQ8pz0R7`E&Ux2;jTTUg7RGJnAwGmLy=<0d! z(0GTP^ngSjr&)$&C9`YX*qKy$2iKFUob&`p=2;<`LRBGPho*YYPUzGR0!-ybWKkIx z#2Bbk6@-^wjRW5-<1CgXlnbPGrM=b9{0P+_0T~$gETb}?l7&-^bt+>6oOR~7%Arn; z8_OrETQUUbmzYJqiL*OqF*wfWcG!8=M39_Mr3|t2dcwj3R3bc+s|jGKwu_$9rACQy z0*qU+Fm@-HXshvPuw#U8dwk+Slgok%+#NPuj>3A(?sq%rmLoR&a4xD7cUJkzd-g>o-9J_ftkE4e5W&N?PIjTq`peEO2eltKpO~TB z;YVN}((NyfGn)K?^bN07+Q@Dm5-h^EkB{X=S5T#%0sSaLdidQqgL3SvJ|et1m3O8Q zofI~&*g8_Px-EQQ{;O+O?j8!23T?DTJQwXpdb-^W_3a1Efr2*b*^BVaPNP>>2jg~` z8q@RPq2Y%ceC~`d_u_VFu3rEczIucee$emtzv;gqol}N^2tchAjj%{^PnREc?*_Pt z2KL(`p1Tm1p9bvE4Cy4S+ag2y_E=r$%HSD7$>z8nn%htjCw4k90z)X<>=bUP0~;%a z&L-xff3=6&d8E?^t$=tm5}FQ0qSNX-kWK7VOkr2jm+y3>y+o|C|_8FP_?}=iH>*X=`RGUNjwMK z?LF)(8!JN??PZa3_KdjAO^p*#>Xh#}m+hM4XXosc?N;?h$sCpWc))67oNN*9BOKYw z$UQb3dtMgJT@!4PJ#JJ%7Ib6}j4>ga!CjCZZEWPBM)~ZIYje%jkwCP=doy0S3P4&g z#!kZ31#5!KvN)wJq5$CVse7%qEqo{l(TNj5W`g@6Fn5BjaMhX~S@7GkZm= zx4J@SH%H%dy^ej#2^-|u?nV|A*g^M5YlE6f&U^wmuB*MZd;`Z5saRnH>+FhsRL4n^ ze$h^5^^S<{B$!n>S*;HXv?sq|J5X_|74fv@k#MLwPe_RIrZckhyA=yJE?_FUo$J^? zLIzFQ^lqK}DjVgfL(i&z7MYqN=wXYSF7fK1zq9}De^xJSj`J&`;+3hjc|?31tmA2z z{V{5@aR*y57dKMf8>zCFPK68&(|TOxUBc1KfdpH>EpCz!?3Q7K{2eraB|7%li;?BF z5i6*NNg;oh`CO8~La$L|>a@|L#*gRjGotqv32%%Fxnu8}##TFJ zb+NtzZ?EEr_gS{1aFN=zJhl7S?Fu>U0!J6?SnHah7~3q}SM3$pWNvSEXj$-T^iB6D z+V2BPWB4`jIDbpawy$;@f23e{4<_e@zX&s^F4m=UlAE4}pjq>RRo_uZ<&|)&-yGZF z0Si)KM@?@OFBiRO^mr%vtm@Vs{|r9dWKNsEzqUX>q#b1157fvp$PJsL2S1Yg<~z!9 zY40}et}R{QH-2o}0US$UZ3Z(g$;gf0Qy^JZ7|$=iAZ)rd)AH%8qXCJ)jSveuw){1k z^riOAP1O%0%y>Drs zUoux)X4>25m1*HYMK8Js&=%_-JuCnmDGV$;qzUK=nP=*}qC-uW&nB8hcx;YPs;{TX zGvUjhVDeGp%$4o=C0!^9BsH$JONXsaS=RGc*YqGL|mCDLg-Xj6|q&) z&Oo+5Z;iVw9*Uk$<;qU##2j$Ezzcr69HTsw15J@XfK zhw5Y-XLjaPpiSt&=h^w0q97m9pWM#H_Q4BFb6X{ENCu|>&n^Te7NmS%r$%ST;- z9`7tYivw^mQ4MSuc6&N8Y$0Fnv(%kW_yj1w_)O#IK-_dPdMiC^0?@QU%rN}yh%*Q` zO$?nyP3septNpqWz~h&L5Dw`$twy)z2sbCN!jy_9%CoD^G9980tT!Of6!p9u-IO68 zctZrL(Ru?=ghf#0O>3CiM&ioe+=y6~*!#g4w?Cs>6cCF(KaxutZ|=FX0~QM9pr?EA zQT((upq)9~bh6vxj0DrY*z}SOA$r86cxKMZUqCqa!lRD;7&ra^;8f;^f`FIpDF74A zbDFns;B@iYb!5Gj(esKuyssNT5ffcNsgTH?1#5DKSb_!S$6*umU^7B^=roc@Xs0#B zlNt#9lJ)kYDK=wa$}q5?3okF%|Pt84!VfSi_|@sfttR zDGgPF?6Vq%W=zA?w1m8NftIkZo>7}gk2$~>qUS@D_R-J@;To1{%{l~NXA zli0hYRrm+V;?*RRwIy*2Su?3^y!Mfhg}Q=%ad1|*Ask~z&lns+N~rX;Ux|Ybezb|( z%CEv5ZW=`IBq?6nPs9Fzbx5us>}qn}XlT0pbaC~wnFCsVB=(9X;PbI?n& z29Y_=ywL9E$0XVJj^AKqjoRQobRsEJcgDAjbW^#r0W-jJ84s}K^}cr^-p#w_|DDxi z{SR8#Hy-&9r0jqHON`qX{_t<;+5i6TU;b?pYBgzeu@kayti1p0MugA}`j-Dy4>@<~ z&-bzV@;mr4T4+wc^DkZeCxiO8U;oj3`Ve|L-#ZH(hUwar(-(E?|Njf#F2`*OLA>)0J{$3ElkJ>WUB9@83K6 z`_Puqe&4yJ|L3a#T=JeryzAuyjNowZPn6GA=g%GgPsU8VwK06+e=6!^*0FU7!F&Qe z68Vo|O=ekbXqf-!FW9>n_Y@T|7{eBz*p#eg9L}->dq5I@R-E3<6L| z3H_(C!~axWlZbWs@Mz)8_`k>{&p7fpqVIp&#^1-V^t07tuoCma(vnd`%93cok#_)n?-W!Cbpf0y$=9p%!6 zzezZ#H7ox=ZYd|;P5CcY4Cv{f`u%@ga&283khw4a`nPF+Pb1vTga3a%h4o%*&@aCD oLZkU61RMo?*totSZTMn`t$kBYshtw|=8H4GI-e~2<=Vaf1^n?%#{d8T From c9e5b1e8e7f1f1b3e673d393dd7e85d728634685 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 19 Mar 2026 01:22:02 +0800 Subject: [PATCH 045/206] feat: add index segment commit API (#6209) This adds a first-class `IndexSegment` type and a `commit_existing_index_segments` API so a logical index can be committed from multiple physical segments. It is intended as a building block for the multi-segment vector index work without changing the high-level `create_index` flow. Part of https://github.com/lance-format/lance/issues/6180 --- java/lance-jni/Cargo.lock | 1 + python/Cargo.lock | 1 + python/src/indices.rs | 20 ++- rust/lance-index/Cargo.toml | 1 + rust/lance-index/src/lib.rs | 2 + rust/lance-index/src/traits.rs | 8 +- rust/lance-index/src/types.rs | 75 ++++++++++ rust/lance/src/dataset/scanner.rs | 5 +- rust/lance/src/index.rs | 197 +++++++++++++++++++++++--- rust/lance/src/index/create.rs | 8 +- rust/lance/src/index/vector.rs | 12 +- rust/lance/src/index/vector/ivf.rs | 16 +-- rust/lance/src/index/vector/ivf/v2.rs | 13 +- 13 files changed, 295 insertions(+), 64 deletions(-) create mode 100644 rust/lance-index/src/types.rs diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 9beaccc254a..e9e3c42fead 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -3633,6 +3633,7 @@ dependencies = [ "bitpacking", "bitvec", "bytes", + "chrono", "crossbeam-queue", "datafusion", "datafusion-common", diff --git a/python/Cargo.lock b/python/Cargo.lock index 38579810684..9e584cc79d4 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -4192,6 +4192,7 @@ dependencies = [ "bitpacking", "bitvec", "bytes", + "chrono", "crossbeam-queue", "datafusion", "datafusion-common", diff --git a/python/src/indices.rs b/python/src/indices.rs index 7428bf0680d..805c84ec6b9 100644 --- a/python/src/indices.rs +++ b/python/src/indices.rs @@ -37,7 +37,7 @@ use crate::{ dataset::Dataset, error::PythonErrorExt, file::object_store_from_uri_or_path_no_options, rt, }; use lance::index::vector::ivf::write_ivf_pq_file_from_existing_index; -use lance_index::{DatasetIndexExt, IndexDescription}; +use lance_index::{DatasetIndexExt, IndexDescription, IndexSegment, IndexType}; use uuid::Uuid; #[pyclass(name = "IndexConfig", module = "lance.indices", get_all)] @@ -416,9 +416,21 @@ async fn do_load_shuffled_vectors( .infer_error()?; let mut ds = dataset.ds.as_ref().clone(); - ds.commit_existing_index(index_name, column, index_id) - .await - .infer_error()?; + ds.commit_existing_index_segments( + index_name, + column, + vec![IndexSegment::new( + index_id, + ds.fragments().iter().map(|f| f.id as u32), + Arc::new( + prost_types::Any::from_msg(&lance_table::format::pb::VectorIndexDetails::default()) + .unwrap(), + ), + IndexType::IvfPq.version(), + )], + ) + .await + .infer_error()?; Ok(()) } diff --git a/rust/lance-index/Cargo.toml b/rust/lance-index/Cargo.toml index 515c58cd53a..7a6190688f7 100644 --- a/rust/lance-index/Cargo.toml +++ b/rust/lance-index/Cargo.toml @@ -68,6 +68,7 @@ tracing.workspace = true tempfile.workspace = true crossbeam-queue.workspace = true bytes.workspace = true +chrono.workspace = true uuid.workspace = true twox-hash = "2.0" async-channel = "2.3.1" diff --git a/rust/lance-index/src/lib.rs b/rust/lance-index/src/lib.rs index 62ae68414a6..5f32a73675c 100644 --- a/rust/lance-index/src/lib.rs +++ b/rust/lance-index/src/lib.rs @@ -29,9 +29,11 @@ pub mod progress; pub mod registry; pub mod scalar; pub mod traits; +pub mod types; pub mod vector; pub use crate::traits::*; +pub use crate::types::IndexSegment; pub const INDEX_FILE_NAME: &str = "index.idx"; /// The name of the auxiliary index file. diff --git a/rust/lance-index/src/traits.rs b/rust/lance-index/src/traits.rs index cb211854df2..5ad13861ddc 100644 --- a/rust/lance-index/src/traits.rs +++ b/rust/lance-index/src/traits.rs @@ -7,9 +7,8 @@ use async_trait::async_trait; use datafusion::execution::SendableRecordBatchStream; use lance_core::{Error, Result}; -use crate::{IndexParams, IndexType, optimize::OptimizeOptions}; +use crate::{IndexParams, IndexType, optimize::OptimizeOptions, types::IndexSegment}; use lance_table::format::IndexMetadata; -use uuid::Uuid; /// A set of criteria used to filter potential indices to use for a query #[derive(Debug, Default)] @@ -275,11 +274,12 @@ pub trait DatasetIndexExt { /// If the index does not exist, return Error. async fn index_statistics(&self, index_name: &str) -> Result; - async fn commit_existing_index( + /// Commit one or more existing physical index segments as a logical index. + async fn commit_existing_index_segments( &mut self, index_name: &str, column: &str, - index_id: Uuid, + segments: Vec, ) -> Result<()>; async fn read_index_partition( diff --git a/rust/lance-index/src/types.rs b/rust/lance-index/src/types.rs new file mode 100644 index 00000000000..6a991be9932 --- /dev/null +++ b/rust/lance-index/src/types.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +use roaring::RoaringBitmap; +use uuid::Uuid; + +/// A single physical segment of a logical index. +/// +/// Each segment is stored independently and will become one manifest entry when committed. +/// The logical index identity (name / target column / dataset version) is provided separately +/// by the commit API. +#[derive(Debug, Clone, PartialEq)] +pub struct IndexSegment { + /// Unique ID of the physical segment. + uuid: Uuid, + /// The fragments covered by this segment. + fragment_bitmap: RoaringBitmap, + /// Metadata specific to the index type. + index_details: Arc, + /// The on-disk index version for this segment. + index_version: i32, +} + +impl IndexSegment { + /// Create a fully described segment with the given UUID, fragment coverage, and index + /// metadata. + pub fn new( + uuid: Uuid, + fragment_bitmap: I, + index_details: Arc, + index_version: i32, + ) -> Self + where + I: IntoIterator, + { + Self { + uuid, + fragment_bitmap: fragment_bitmap.into_iter().collect(), + index_details, + index_version, + } + } + + /// Return the UUID of this segment. + pub fn uuid(&self) -> Uuid { + self.uuid + } + + /// Return the fragment coverage of this segment. + pub fn fragment_bitmap(&self) -> &RoaringBitmap { + &self.fragment_bitmap + } + + /// Return the serialized index details for this segment. + pub fn index_details(&self) -> &Arc { + &self.index_details + } + + /// Return the on-disk index version for this segment. + pub fn index_version(&self) -> i32 { + self.index_version + } + + /// Consume the segment and return its component parts. + pub fn into_parts(self) -> (Uuid, RoaringBitmap, Arc, i32) { + ( + self.uuid, + self.fragment_bitmap, + self.index_details, + self.index_version, + ) + } +} diff --git a/rust/lance/src/dataset/scanner.rs b/rust/lance/src/dataset/scanner.rs index 6cadcb1f7a7..325bb87af66 100644 --- a/rust/lance/src/dataset/scanner.rs +++ b/rust/lance/src/dataset/scanner.rs @@ -2698,8 +2698,7 @@ impl Scanner { let row_addrs = RowAddrTreeMap::from_iter(u64s); let row_addr_mask = RowAddrMask::from_allowed(row_addrs); let index_result = IndexExprResult::Exact(row_addr_mask); - let fragments_covered = - RoaringBitmap::from_iter(self.dataset.fragments().iter().map(|f| f.id as u32)); + let fragments_covered = self.dataset.fragment_bitmap.as_ref().clone(); let batch = index_result.serialize_to_arrow(&fragments_covered)?; let stream = futures::stream::once(async move { Ok(batch) }); let stream = Box::pin(RecordBatchStreamAdapter::new( @@ -4172,7 +4171,7 @@ impl Scanner { if let Some(fragments) = &self.fragments { RoaringBitmap::from_iter(fragments.iter().map(|f| f.id as u32)) } else { - RoaringBitmap::from_iter(self.dataset.fragments().iter().map(|f| f.id as u32)) + self.dataset.fragment_bitmap.as_ref().clone() } } diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index 9ae326ad7e6..fdc91760a2e 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -42,7 +42,7 @@ use lance_index::vector::flat::index::{FlatBinQuantizer, FlatIndex, FlatQuantize use lance_index::vector::hnsw::HNSW; use lance_index::vector::pq::ProductQuantizer; use lance_index::vector::sq::ScalarQuantizer; -use lance_index::{DatasetIndexExt, INDEX_METADATA_SCHEMA_KEY, IndexDescription}; +use lance_index::{DatasetIndexExt, INDEX_METADATA_SCHEMA_KEY, IndexDescription, IndexSegment}; use lance_index::{INDEX_FILE_NAME, Index, IndexType, pb, vector::VectorIndex}; use lance_index::{ IndexCriteria, is_system_index, @@ -774,38 +774,58 @@ impl DatasetIndexExt for Dataset { } } - async fn commit_existing_index( + async fn commit_existing_index_segments( &mut self, index_name: &str, column: &str, - index_id: Uuid, + segments: Vec, ) -> Result<()> { + if segments.is_empty() { + return Err(Error::invalid_input( + "CreateIndex: at least one index segment is required".to_string(), + )); + } + let Some(field) = self.schema().field(column) else { return Err(Error::index(format!( "CreateIndex: column '{column}' does not exist" ))); }; - // TODO: We will need some way to determine the index details here. Perhaps - // we can load the index itself and get the details that way. - - let new_idx = IndexMetadata { - uuid: index_id, - name: index_name.to_string(), - fields: vec![field.id], - dataset_version: self.manifest.version, - fragment_bitmap: Some(self.get_fragments().iter().map(|f| f.id() as u32).collect()), - index_details: None, - index_version: 0, - created_at: Some(chrono::Utc::now()), - base_id: None, // New indices don't have base_id (they're not from shallow clone) - files: None, // File info will be populated when index is created - }; + let mut seen_segment_ids = HashSet::with_capacity(segments.len()); + for segment in &segments { + if !seen_segment_ids.insert(segment.uuid()) { + return Err(Error::invalid_input(format!( + "CreateIndex: duplicate segment uuid {} for index '{}'", + segment.uuid(), + index_name + ))); + } + } + + let new_indices = segments + .into_iter() + .map(|segment| { + let (uuid, fragment_bitmap, index_details, index_version) = segment.into_parts(); + IndexMetadata { + uuid, + name: index_name.to_string(), + fields: vec![field.id], + dataset_version: self.manifest.version, + fragment_bitmap: Some(fragment_bitmap), + index_details: Some(index_details), + index_version, + created_at: Some(chrono::Utc::now()), + base_id: None, + files: None, // File info will be populated when index is created + } + }) + .collect(); let transaction = Transaction::new( self.manifest.version, Operation::CreateIndex { - new_indices: vec![new_idx], + new_indices, removed_indices: vec![], }, None, @@ -5157,6 +5177,145 @@ mod tests { ); } + #[tokio::test] + async fn test_commit_existing_index_segments_commits_multiple_segments() { + use lance_datagen::{BatchCount, RowCount, array}; + + let test_dir = tempfile::tempdir().unwrap(); + let test_uri = test_dir.path().to_str().unwrap(); + + let reader = lance_datagen::gen_batch() + .col("id", array::step::()) + .col( + "vector", + array::rand_vec::(8.into()), + ) + .into_reader_rows(RowCount::from(20), BatchCount::from(2)); + + let mut dataset = Dataset::write( + reader, + test_uri, + Some(WriteParams { + max_rows_per_file: 10, + max_rows_per_group: 10, + ..Default::default() + }), + ) + .await + .unwrap(); + + let seg0 = IndexSegment::new( + Uuid::new_v4(), + std::iter::once(0_u32), + Arc::new(vector_index_details()), + IndexType::Vector.version(), + ); + let seg1 = IndexSegment::new( + Uuid::new_v4(), + std::iter::once(1_u32), + Arc::new(vector_index_details()), + IndexType::Vector.version(), + ); + + dataset + .commit_existing_index_segments( + "vector_idx", + "vector", + vec![seg0.clone(), seg1.clone()], + ) + .await + .unwrap(); + + let committed = dataset.load_indices_by_name("vector_idx").await.unwrap(); + assert_eq!(committed.len(), 2); + let committed_uuids = committed.iter().map(|idx| idx.uuid).collect::>(); + assert_eq!( + committed_uuids, + HashSet::from([seg0.uuid(), seg1.uuid()]), + "all committed segment uuids should be preserved" + ); + assert_eq!( + committed + .iter() + .map(|idx| idx + .fragment_bitmap + .as_ref() + .unwrap() + .iter() + .collect::>()) + .collect::>(), + HashSet::from([vec![0], vec![1]]), + "each committed segment should preserve its fragment coverage" + ); + } + + #[tokio::test] + async fn test_commit_existing_index_segments_rejects_duplicate_segment_ids() { + use lance_datagen::{BatchCount, RowCount, array}; + + let test_dir = tempfile::tempdir().unwrap(); + let test_uri = test_dir.path().to_str().unwrap(); + + let reader = lance_datagen::gen_batch() + .col("id", array::step::()) + .col( + "vector", + array::rand_vec::(8.into()), + ) + .into_reader_rows(RowCount::from(10), BatchCount::from(1)); + + let mut dataset = Dataset::write(reader, test_uri, None).await.unwrap(); + + let base = IndexSegment::new( + Uuid::new_v4(), + std::iter::once(0_u32), + Arc::new(vector_index_details()), + IndexType::Vector.version(), + ); + + let err = dataset + .commit_existing_index_segments( + "vector_idx", + "vector", + vec![ + base.clone(), + IndexSegment::new( + base.uuid(), + std::iter::once(1_u32), + Arc::new(vector_index_details()), + IndexType::Vector.version(), + ), + ], + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("duplicate segment uuid")); + } + + #[tokio::test] + async fn test_commit_existing_index_segments_rejects_empty_segments() { + use lance_datagen::{BatchCount, RowCount, array}; + + let test_dir = tempfile::tempdir().unwrap(); + let test_uri = test_dir.path().to_str().unwrap(); + + let reader = lance_datagen::gen_batch() + .col("id", array::step::()) + .col( + "vector", + array::rand_vec::(8.into()), + ) + .into_reader_rows(RowCount::from(10), BatchCount::from(1)); + + let mut dataset = Dataset::write(reader, test_uri, None).await.unwrap(); + + let err = dataset + .commit_existing_index_segments("vector_idx", "vector", vec![]) + .await + .unwrap_err(); + assert!(err.to_string().contains("at least one index segment")); + } + #[tokio::test] async fn test_resolve_index_column_error_cases() { use lance_datagen::{BatchCount, RowCount, array}; diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index 562f2ed7df4..2678cb22471 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -427,13 +427,7 @@ impl<'a> CreateIndexBuilder<'a> { fragment_bitmap: if train { match &self.fragments { Some(fragment_ids) => Some(fragment_ids.iter().collect()), - None => Some( - self.dataset - .get_fragments() - .iter() - .map(|f| f.id() as u32) - .collect(), - ), + None => Some(self.dataset.fragment_bitmap.as_ref().clone()), } } else { // Empty bitmap for untrained indices diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index ba0611c462d..0d0ad64993a 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -1585,17 +1585,7 @@ pub async fn initialize_vector_index( )) })?; - let fragment_bitmap = if target_dataset.get_fragments().is_empty() { - Some(roaring::RoaringBitmap::new()) - } else { - Some( - target_dataset - .get_fragments() - .iter() - .map(|f| f.id() as u32) - .collect(), - ) - }; + let fragment_bitmap = Some(target_dataset.fragment_bitmap.as_ref().clone()); let new_idx = IndexMetadata { uuid: new_uuid, diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index ecbcde56b35..621773c60c0 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -2656,13 +2656,7 @@ mod tests { dataset_version: dataset.version().version, fields: vec![field.id], name: INDEX_NAME.to_string(), - fragment_bitmap: Some( - dataset - .get_fragments() - .iter() - .map(|f| f.id() as u32) - .collect(), - ), + fragment_bitmap: Some(dataset.fragment_bitmap.as_ref().clone()), index_details: Some(Arc::new(vector_index_details())), index_version: VECTOR_INDEX_VERSION as i32, created_at: Some(chrono::Utc::now()), @@ -2761,13 +2755,7 @@ mod tests { dataset_version: dataset_mut.version().version, fields: vec![field.id], name: format!("{}_remapped", INDEX_NAME), - fragment_bitmap: Some( - dataset_mut - .get_fragments() - .iter() - .map(|f| f.id() as u32) - .collect(), - ), + fragment_bitmap: Some(dataset_mut.fragment_bitmap.as_ref().clone()), index_details: Some(Arc::new(vector_index_details())), index_version: VECTOR_INDEX_VERSION as i32, created_at: Some(chrono::Utc::now()), diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index b214c4704e3..0bdc0389648 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -653,7 +653,7 @@ mod tests { use lance_index::vector::{ pq::storage::ProductQuantizationMetadata, storage::STORAGE_METADATA_KEY, }; - use lance_index::{DatasetIndexExt, IndexType}; + use lance_index::{DatasetIndexExt, IndexSegment, IndexType}; use lance_index::{INDEX_AUXILIARY_FILE_NAME, metrics::NoOpMetricsCollector}; use lance_index::{optimize::OptimizeOptions, scalar::IndexReader}; use lance_index::{scalar::IndexWriter, vector::hnsw::builder::HnswBuildParams}; @@ -1467,7 +1467,16 @@ mod tests { .unwrap(); dataset - .commit_existing_index(index_name, "vector", shared_uuid) + .commit_existing_index_segments( + index_name, + "vector", + vec![IndexSegment::new( + shared_uuid, + dataset.fragment_bitmap.as_ref().clone(), + Arc::new(crate::index::vector_index_details()), + IndexType::IvfPq.version(), + )], + ) .await .unwrap(); } From 1a8b23d883c426638b26fda01f2dd1ec2710c7da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C4=83zvan=20Burl=C4=83cioiu?= Date: Wed, 18 Mar 2026 22:19:17 +0200 Subject: [PATCH 046/206] feat: add abfss:// scheme support for Azure ADLS Gen2 (#6192) --- rust/lance-io/src/object_store.rs | 12 ++ rust/lance-io/src/object_store/providers.rs | 6 +- .../src/object_store/providers/azure.rs | 118 +++++++++++++++++- rust/lance-namespace-impls/src/credentials.rs | 16 ++- rust/lance-table/src/io/commit.rs | 4 +- rust/lance/src/dataset/write.rs | 17 +++ 6 files changed, 162 insertions(+), 11 deletions(-) diff --git a/rust/lance-io/src/object_store.rs b/rust/lance-io/src/object_store.rs index 4327671b99b..02128143705 100644 --- a/rust/lance-io/src/object_store.rs +++ b/rust/lance-io/src/object_store.rs @@ -1065,6 +1065,13 @@ mod tests { .unwrap(); assert_eq!(store.scheme, "gs"); assert_eq!(path.to_string(), "foo.lance"); + + let (store, path) = + ObjectStore::from_uri("abfss://filesystem@account.dfs.core.windows.net/foo.lance") + .await + .unwrap(); + assert_eq!(store.scheme, "abfss"); + assert_eq!(path.to_string(), "foo.lance"); } async fn test_block_size_used_test_helper( @@ -1107,6 +1114,11 @@ mod tests { (String::from("account_name"), String::from("account")), (String::from("container_name"), String::from("container")) ])))] + #[case("abfss://filesystem@account.dfs.core.windows.net/foo.lance", + Some(HashMap::from([ + (String::from("account_name"), String::from("account")), + (String::from("container_name"), String::from("filesystem")) + ])))] #[tokio::test] async fn test_block_size_used_cloud( #[case] uri: &str, diff --git a/rust/lance-io/src/object_store/providers.rs b/rust/lance-io/src/object_store/providers.rs index 90964ba2586..3583b27b288 100644 --- a/rust/lance-io/src/object_store/providers.rs +++ b/rust/lance-io/src/object_store/providers.rs @@ -309,7 +309,11 @@ impl Default for ObjectStoreRegistry { providers.insert("s3+ddb".into(), aws); } #[cfg(feature = "azure")] - providers.insert("az".into(), Arc::new(azure::AzureBlobStoreProvider)); + { + let azure = Arc::new(azure::AzureBlobStoreProvider); + providers.insert("az".into(), azure.clone()); + providers.insert("abfss".into(), azure); + } #[cfg(feature = "gcp")] providers.insert("gs".into(), Arc::new(gcp::GcsStoreProvider)); #[cfg(feature = "oss")] diff --git a/rust/lance-io/src/object_store/providers/azure.rs b/rust/lance-io/src/object_store/providers/azure.rs index 2bd0bf2878d..5db13b50c20 100644 --- a/rust/lance-io/src/object_store/providers/azure.rs +++ b/rust/lance-io/src/object_store/providers/azure.rs @@ -88,6 +88,14 @@ impl AzureBlobStoreProvider { #[async_trait::async_trait] impl ObjectStoreProvider for AzureBlobStoreProvider { async fn new_store(&self, base_path: Url, params: &ObjectStoreParams) -> Result { + let scheme = base_path.scheme().to_string(); + if scheme != "az" && scheme != "abfss" { + return Err(Error::invalid_input(format!( + "Unsupported Azure scheme '{}', expected 'az' or 'abfss'", + scheme + ))); + } + let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE); let mut storage_options = StorageOptions(params.storage_options().cloned().unwrap_or_default()); @@ -110,7 +118,7 @@ impl ObjectStoreProvider for AzureBlobStoreProvider { Ok(ObjectStore { inner, - scheme: String::from("az"), + scheme, block_size, max_iop_size: *DEFAULT_MAX_IOP_SIZE, use_constant_size_upload_parts: false, @@ -131,8 +139,10 @@ impl ObjectStoreProvider for AzureBlobStoreProvider { let authority = url.authority(); let (container, account) = match authority.find("@") { Some(at_index) => { - // The URI looks like 'az://container@account.dfs.core.windows.net/path-part/file', - // or possibly 'az://container@account/path-part/file'. + // The URI has an: + // - az:// schema type and is similar to 'az://container@account.dfs.core.windows.net/path-part/file + // or possibly 'az://container@account/path-part/file' (the short version). + // - abfss:// schema type and is similar to 'abfss://filesystem@account.dfs.core.windows.net/path-part/file'. let container = &authority[..at_index]; let account = &authority[at_index + 1..]; ( @@ -299,6 +309,21 @@ mod tests { ); } + #[test] + fn test_calculate_object_store_prefix_from_url_short_account() { + let provider = AzureBlobStoreProvider; + let options = HashMap::from_iter([("account_name".to_string(), "bob".to_string())]); + assert_eq!( + "az$container@account", + provider + .calculate_object_store_prefix( + &Url::parse("az://container@account/path").unwrap(), + Some(&options) + ) + .unwrap() + ); + } + #[test] fn test_fail_to_calculate_object_store_prefix_from_url() { let provider = AzureBlobStoreProvider; @@ -313,4 +338,91 @@ mod tests { .to_string(); assert_eq!(expected, &result[..expected.len()]); } + + // --- abfss:// tests --- + + #[test] + fn test_abfss_extract_path() { + let provider = AzureBlobStoreProvider; + let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path/to/dataset.lance") + .unwrap(); + let path = provider.extract_path(&url).unwrap(); + assert_eq!( + path, + object_store::path::Path::from("path/to/dataset.lance") + ); + } + + #[test] + fn test_calculate_abfss_prefix() { + let provider = AzureBlobStoreProvider; + let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path/to/data").unwrap(); + let prefix = provider.calculate_object_store_prefix(&url, None).unwrap(); + assert_eq!(prefix, "abfss$myfs@myaccount"); + } + + #[test] + fn test_calculate_abfss_prefix_ignores_storage_options() { + let provider = AzureBlobStoreProvider; + let options = + HashMap::from_iter([("account_name".to_string(), "other_account".to_string())]); + let url = Url::parse("abfss://myfs@myaccount.dfs.core.windows.net/path").unwrap(); + let prefix = provider + .calculate_object_store_prefix(&url, Some(&options)) + .unwrap(); + assert_eq!(prefix, "abfss$myfs@myaccount"); + } + + #[tokio::test] + async fn test_abfss_default_uses_microsoft_builder() { + use crate::object_store::StorageOptionsAccessor; + let provider = AzureBlobStoreProvider; + let url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap(); + let params = ObjectStoreParams { + storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options( + HashMap::from([ + ("account_name".to_string(), "testaccount".to_string()), + ("account_key".to_string(), "dGVzdA==".to_string()), + ]), + ))), + ..Default::default() + }; + + let store = provider.new_store(url, ¶ms).await.unwrap(); + assert_eq!(store.scheme, "abfss"); + assert!(!store.is_local()); + assert!(store.is_cloud()); + let inner_desc = store.inner.to_string(); + assert!( + inner_desc.contains("MicrosoftAzure"), + "abfss:// without use_opendal should use MicrosoftAzureBuilder, got: {}", + inner_desc + ); + } + + #[tokio::test] + async fn test_unsupported_scheme_rejected() { + use crate::object_store::StorageOptionsAccessor; + let provider = AzureBlobStoreProvider; + let url = Url::parse("wasbs://container@myaccount.blob.core.windows.net/path").unwrap(); + let params = ObjectStoreParams { + storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options( + HashMap::from([ + ("account_name".to_string(), "myaccount".to_string()), + ("account_key".to_string(), "dGVzdA==".to_string()), + ]), + ))), + ..Default::default() + }; + + let err = provider + .new_store(url, ¶ms) + .await + .expect_err("expected error for unsupported scheme"); + assert!( + err.to_string().contains("Unsupported Azure scheme"), + "unexpected error: {}", + err + ); + } } diff --git a/rust/lance-namespace-impls/src/credentials.rs b/rust/lance-namespace-impls/src/credentials.rs index eeb83efd6b7..e25fd92581e 100644 --- a/rust/lance-namespace-impls/src/credentials.rs +++ b/rust/lance-namespace-impls/src/credentials.rs @@ -353,7 +353,7 @@ pub fn detect_provider_from_uri(uri: &str) -> &'static str { match url.scheme() { "s3" => "aws", "gs" => "gcp", - "az" => "azure", + "az" | "abfss" => "azure", _ => "unknown", } } @@ -556,8 +556,16 @@ mod tests { assert_eq!(detect_provider_from_uri("gs://bucket/path"), "gcp"); assert_eq!(detect_provider_from_uri("GS://bucket/path"), "gcp"); - // Azure (supported scheme: az://) + // Azure (supported schemes: az:// and abfss://) assert_eq!(detect_provider_from_uri("az://container/path"), "azure"); + assert_eq!( + detect_provider_from_uri("az://container@account.blob.core.windows.net/path"), + "azure" + ); + assert_eq!( + detect_provider_from_uri("abfss://container@account.dfs.core.windows.net/path"), + "azure" + ); // Unknown (unsupported schemes) assert_eq!(detect_provider_from_uri("/local/path"), "unknown"); @@ -565,10 +573,6 @@ mod tests { assert_eq!(detect_provider_from_uri("memory://test"), "unknown"); // Hadoop-style schemes not supported by lance-io assert_eq!(detect_provider_from_uri("s3a://bucket/path"), "unknown"); - assert_eq!( - detect_provider_from_uri("abfss://container@account.dfs.core.windows.net/path"), - "unknown" - ); assert_eq!( detect_provider_from_uri("wasbs://container@account.blob.core.windows.net/path"), "unknown" diff --git a/rust/lance-table/src/io/commit.rs b/rust/lance-table/src/io/commit.rs index acc7eaaec14..42b88a8813e 100644 --- a/rust/lance-table/src/io/commit.rs +++ b/rust/lance-table/src/io/commit.rs @@ -681,7 +681,9 @@ pub async fn commit_handler_from_url( match url.scheme() { "file" | "file-object-store" => Ok(local_handler), - "s3" | "gs" | "az" | "memory" | "oss" | "cos" => Ok(Arc::new(ConditionalPutCommitHandler)), + "s3" | "gs" | "az" | "abfss" | "memory" | "oss" | "cos" => { + Ok(Arc::new(ConditionalPutCommitHandler)) + } #[cfg(not(feature = "dynamodb"))] "s3+ddb" => Err(Error::invalid_input_source( "`s3+ddb://` scheme requires `dynamodb` feature to be enabled".into(), diff --git a/rust/lance/src/dataset/write.rs b/rust/lance/src/dataset/write.rs index aac88289299..2ed137bd23c 100644 --- a/rust/lance/src/dataset/write.rs +++ b/rust/lance/src/dataset/write.rs @@ -1845,6 +1845,11 @@ mod tests { let test_cases = vec![ ("s3://multi-path-test/test1/subBucket2", "test1/subBucket2"), ("gs://my-bucket/path/to/data", "path/to/data"), + ("az://container/path/to/data", "path/to/data"), + ( + "abfss://filesystem@account.dfs.core.windows.net/path/to/data", + "path/to/data", + ), ("file:///tmp/test/bucket", "tmp/test/bucket"), ]; @@ -1873,6 +1878,18 @@ mod tests { path: "s3://bucket2/path2".to_string(), is_dataset_root: true, }, + BasePath { + id: 3, + name: Some("azure-az-base".to_string()), + path: "az://container/path1".to_string(), + is_dataset_root: true, + }, + BasePath { + id: 4, + name: Some("azure-abfss-base".to_string()), + path: "abfss://filesystem@account.dfs.core.windows.net/path1".to_string(), + is_dataset_root: true, + }, ]), target_bases: Some(vec![1]), // Use ID 1 which corresponds to bucket1 ..Default::default() From f404d0e4148f366fa8fdbb53271768d4783ce9a0 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Wed, 18 Mar 2026 13:22:12 -0700 Subject: [PATCH 047/206] docs: document vector index RAM (training) & storage requirements (#6108) --- docs/src/guide/performance.md | 92 +++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/docs/src/guide/performance.md b/docs/src/guide/performance.md index 21484b83cda..11ca2e23b72 100644 --- a/docs/src/guide/performance.md +++ b/docs/src/guide/performance.md @@ -239,3 +239,95 @@ currently extremely slow and the btree index is much faster for large range quer When a bitmap index is not fully loaded into the index cache, the search time will be controlled by the number of bitmaps that need to be loaded from disk and the speed of storage. The parts_loaded metric in the execution metrics can tell you how many bitmaps were loaded from disk to satisfy a query. + +### Vector Index + +Vector indexes (IVF_PQ, IVF_HNSW_SQ, etc.) are built in multiple phases, each with different memory requirements. + +#### IVF Training + +The IVF (Inverted File) phase clusters vectors into partitions using KMeans. To train the KMeans model, a sample of the +dataset is loaded into memory. The size of this sample is determined by: + +``` +training_data = num_partitions * sample_rate * dimension * sizeof(data_type) +``` + +The default `sample_rate` is 256. For example, with 1024 partitions, 768-dimensional float32 vectors, and the default +sample rate: + +``` +1024 * 256 * 768 * 4 bytes = 768 MiB +``` + +In addition to the training data, each KMeans iteration allocates membership and distance vectors proportional to the +number of training vectors (8 bytes per vector). The centroids themselves require `num_partitions * dimension * +sizeof(data_type)` bytes. In practice, the training data dominates and these additional allocations are small in +comparison. + +If the dataset has fewer rows than `num_partitions * sample_rate`, the entire dataset is used for training instead. + +#### Quantizer Training + +After IVF training, a quantizer (e.g. PQ, SQ) is trained to compress vectors. This phase may sample some of the +dataset, but the sample size is tied to properties of the quantizer and the vector dimension rather than the size of the +dataset. As a result, quantizer training typically requires very little RAM compared to the IVF phase. + +#### Shuffling + +The final phase scans the entire vector column, transforms each vector (assigning it to an IVF partition and quantizing +it), and writes the results into per-partition files on disk. This is a streaming operation — data is not accumulated in +memory. + +The input scan uses a 2 GiB I/O readahead buffer by default (configurable via `LANCE_DEFAULT_IO_BUFFER_SIZE`) and reads +batches of 8,192 rows. Incoming batches are transformed in parallel, with `num_cpus - 2` batches in flight at a time +(configurable via `LANCE_CPU_THREADS`). Each batch is sorted by partition ID and the slices are written directly to the +corresponding partition file. The in-flight memory during this phase is roughly: + +``` +io_readahead_buffer + num_cpu_threads * batch_size * (raw_vector_size + transformed_vector_size) +``` + +Each partition has an open file writer with roughly 8 MiB of accumulation buffer. In practice there shouldn't be that +much data accumulated in a single partition anyways. Instead, the max accumulation will be roughly the final size of +the partitions which comes out to `num_rows * (num_sub_vectors + 8) bytes`. For example, 100M rows with a 1536-dimensional +vector will have 96 sub-vectors and so the max accumulation will be ~10GB. The additional 8 bytes per row is for the row ID. + +#### Storage Requirements + +The on-disk size of a vector index consists of the IVF centroids and the quantized vectors. + +The centroids require: + +``` +num_partitions * dimension * sizeof(data_type) +``` + +This is typically small. For example, 10K partitions with 768-dimensional float32 vectors is only 30 MiB. + +The quantized vectors make up the bulk of the index. Each row stores a quantized code plus an 8-byte row ID. The +exact size depends on the quantizer: + +**PQ (Product Quantization):** Each sub-vector is quantized to a single byte, so each row requires +`num_sub_vectors + 8` bytes. For example, 100M rows with 96 sub-vectors: + +``` +100M * (96 + 8) = ~9.7 GiB +``` + +**SQ (Scalar Quantization):** Each dimension is independently quantized to a single byte, so each row requires +`dimension + 8` bytes. SQ preserves more information than PQ but requires more storage. For example, 100M rows with +768-dimensional vectors: + +``` +100M * (768 + 8) = ~72.3 GiB +``` + +**RQ (RaBitQ):** Vectors are quantized to binary codes with a configurable number of bits per +dimension. Each row also stores per-row scale and offset factors (4 bytes each) used for distance correction. Each +row requires `dimension * num_bits / 8 + 16` bytes (8 bytes for the row ID plus 8 bytes for the factors). For +example, 100M rows with 768 dimensions and 1 bit per dimension: + +``` +100M * (768 * 1 / 8 + 16) = ~10.8 GiB +``` From 416f5a625d0a625aef5a4b582d7a7122076dd4ff Mon Sep 17 00:00:00 2001 From: ForwardXu Date: Thu, 19 Mar 2026 12:38:24 +0800 Subject: [PATCH 048/206] feat: support atomic multi-table transactions via namespace manifest (#6173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add a new `table_version_storage_enabled` flag to `DirectoryNamespaceBuilder` that stores table versions in the `__manifest` table (as `table_version` object type) instead of relying solely on Lance's native version management. This enables atomic multi-table version creation via `BatchCreateTableVersions`, multi-table deletion via `BatchDeleteTableVersions`, and a unified atomic `BatchCommitTables` operation that supports mixed operations (declare, create version, delete versions, deregister) across multiple tables in a single transaction. ## Key Changes ### manifest.rs - Add `TableVersion` variant to `ObjectType` enum - Make `str_object_id` and `insert_into_manifest_with_metadata` public - Add `batch_insert_into_manifest()`: atomically inserts multiple entries using a single MergeInsert with `WhenMatched::Fail` semantics, so if any version already exists the entire batch is rejected - Add `query_table_versions()`: lists all versions for a given table from the manifest, returning version number and full metadata JSON - Add `query_table_version()`: queries a single version entry by table ID and version number, returning its metadata JSON - Add `delete_table_versions()`: removes version entries by table ID and version ranges from the manifest - Add `batch_delete_table_versions_by_object_ids()`: atomically deletes version entries across multiple tables in a single `DeleteBuilder` operation ### dir.rs - Add `table_version_storage_enabled` field with builder method and validation (requires `manifest_enabled=true`) - `CreateTableVersion`: when enabled, also records the version entry in `__manifest` via `insert_into_manifest_with_metadata` - `BatchCreateTableVersions`: two-phase commit — first copies staging manifests to final locations, then atomically inserts all version entries into `__manifest` in a single batch operation - `ListTableVersions`: when enabled, queries versions from `__manifest` and reconstructs `TableVersion` from stored metadata JSON - `DescribeTableVersion`: when enabled and version is specified, queries directly from `__manifest` instead of opening the dataset - `BatchDeleteTableVersions`: supports two modes: - **Single-table (legacy)**: uses `request.id` + `request.ranges` for one table - **Multi-table (transactional)**: uses `request.entries` to atomically delete versions across multiple tables (metadata deletion is atomic; physical file cleanup is best-effort) - `BatchCommitTables`: new unified operation that atomically commits mixed table operations (`declare_table`, `create_table_version`, `delete_table_versions`, `deregister_table`) across multiple tables using discriminated union types from the generated `lance-namespace` API spec - Add comprehensive tests: batch create atomic, conflict detection, manifest requirement validation, single version recording, and more ### namespace.rs - Add `batch_create_table_versions` trait method with default not-supported implementation - Add `batch_commit_tables` trait method with default not-supported implementation - Update `batch_delete_table_versions` doc to document both single-table (legacy) and multi-table (transactional) modes ### rest.rs / rest_adapter.rs - Add REST client/server endpoints: - `POST /v1/table/version/batch_create` — `batch_create_table_versions` - `POST /v1/table/:id/version/batch_delete` — `batch_delete_table_versions` - `POST /v1/table/batch_commit` — `batch_commit_tables` (unified atomic multi-table commit) ## Depends On - lance-namespace PR: adds `BatchCommitTables` API spec and generated client code with discriminated union types (`CommitTableOperation`, `CommitTableResult`) ## Testing - 122 unit tests pass (including new tests for batch create, conflict detection, multi-table delete, and manifest validation) - All doc-tests pass --- Cargo.lock | 29 +- Cargo.toml | 2 +- python/Cargo.lock | 121 +--- rust/lance-namespace-impls/src/dir.rs | 401 ++++++++++- .../lance-namespace-impls/src/dir/manifest.rs | 636 ++++++++++++++++-- rust/lance-namespace-impls/src/rest.rs | 17 +- .../lance-namespace-impls/src/rest_adapter.rs | 24 + rust/lance-namespace/Cargo.toml | 1 + rust/lance-namespace/src/namespace.rs | 3 +- 9 files changed, 1026 insertions(+), 208 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0a31acdcf2..97c13ed1657 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3002,7 +3002,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3241,7 +3241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4220,7 +4220,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -4553,7 +4553,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4648,7 +4648,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5263,6 +5263,7 @@ dependencies = [ "bytes", "lance-core", "lance-namespace-reqwest-client", + "serde", "snafu", "tempfile", "tokio", @@ -5333,9 +5334,9 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad4c947349acd6e37e984eba0254588bd894e6128434338b9e6904e56fb4633" +checksum = "ee2e48de899e2931afb67fcddd0a08e439bf5d8b6ea2a2ed9cb8f4df669bd5cc" dependencies = [ "reqwest", "serde", @@ -6101,7 +6102,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7157,7 +7158,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.37", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -7194,9 +7195,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7774,7 +7775,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8774,7 +8775,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9748,7 +9749,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 82c36edea52..597f8cb1fbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,7 +66,7 @@ lance-linalg = { version = "=4.0.0-beta.12", path = "./rust/lance-linalg" } lance-namespace = { version = "=4.0.0-beta.12", path = "./rust/lance-namespace" } lance-namespace-impls = { version = "=4.0.0-beta.12", path = "./rust/lance-namespace-impls" } lance-namespace-datafusion = { version = "=4.0.0-beta.12", path = "./rust/lance-namespace-datafusion" } -lance-namespace-reqwest-client = "0.5.2" +lance-namespace-reqwest-client = "0.6.1" lance-table = { version = "=4.0.0-beta.12", path = "./rust/lance-table" } lance-test-macros = { version = "=4.0.0-beta.12", path = "./rust/lance-test-macros" } lance-testing = { version = "=4.0.0-beta.12", path = "./rust/lance-testing" } diff --git a/python/Cargo.lock b/python/Cargo.lock index 9e584cc79d4..c73e287b07c 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2506,7 +2506,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2681,7 +2681,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3456,7 +3456,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -3816,7 +3816,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4313,6 +4313,7 @@ dependencies = [ "bytes", "lance-core", "lance-namespace-reqwest-client", + "serde", "snafu", ] @@ -4349,9 +4350,9 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad4c947349acd6e37e984eba0254588bd894e6128434338b9e6904e56fb4633" +checksum = "ee2e48de899e2931afb67fcddd0a08e439bf5d8b6ea2a2ed9cb8f4df669bd5cc" dependencies = [ "reqwest", "serde", @@ -4989,7 +4990,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5882,7 +5883,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.37", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5919,9 +5920,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6391,7 +6392,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7265,7 +7266,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8081,7 +8082,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8155,7 +8156,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -8164,16 +8165,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "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", + "windows-targets", ] [[package]] @@ -8191,31 +8183,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "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", + "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", ] [[package]] @@ -8224,96 +8199,48 @@ 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" diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index 73906198961..e2cac3504aa 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -14,7 +14,7 @@ use async_trait::async_trait; use bytes::Bytes; use futures::TryStreamExt; use lance::dataset::builder::DatasetBuilder; -use lance::dataset::{Dataset, WriteParams}; +use lance::dataset::{Dataset, WriteMode, WriteParams}; use lance::session::Session; use lance_io::object_store::{ObjectStore, ObjectStoreParams, ObjectStoreRegistry}; use lance_table::io::commit::ManifestNamingScheme; @@ -101,6 +101,9 @@ pub struct DirectoryNamespaceBuilder { dir_listing_enabled: bool, inline_optimization_enabled: bool, table_version_tracking_enabled: bool, + /// When true, table versions are stored in the `__manifest` table instead of + /// relying on Lance's native version management. + table_version_storage_enabled: bool, credential_vendor_properties: HashMap, context_provider: Option>, commit_retries: Option, @@ -121,6 +124,10 @@ impl std::fmt::Debug for DirectoryNamespaceBuilder { "table_version_tracking_enabled", &self.table_version_tracking_enabled, ) + .field( + "table_version_storage_enabled", + &self.table_version_storage_enabled, + ) .field( "context_provider", &self.context_provider.as_ref().map(|_| "Some(...)"), @@ -144,6 +151,7 @@ impl DirectoryNamespaceBuilder { dir_listing_enabled: true, // Default to enabled for backwards compatibility inline_optimization_enabled: true, table_version_tracking_enabled: false, // Default to disabled + table_version_storage_enabled: false, // Default to disabled credential_vendor_properties: HashMap::new(), context_provider: None, commit_retries: None, @@ -190,6 +198,19 @@ impl DirectoryNamespaceBuilder { self } + /// Enable or disable table version management through the `__manifest` table. + /// + /// When enabled, table versions are tracked as `table_version` entries in the + /// `__manifest` Lance table. This enables: + /// - Centralized version tracking instead of per-table `_versions/` directories + /// + /// Requires `manifest_enabled` to be true. + /// When disabled (default), version storage uses per-table storage operations. + pub fn table_version_storage_enabled(mut self, enabled: bool) -> Self { + self.table_version_storage_enabled = enabled; + self + } + /// Create a DirectoryNamespaceBuilder from properties HashMap. /// /// This method parses a properties map into builder configuration. @@ -304,6 +325,12 @@ impl DirectoryNamespaceBuilder { .and_then(|v| v.parse::().ok()) .unwrap_or(false); + // Extract table_version_storage_enabled (default: false) + let table_version_storage_enabled = properties + .get("table_version_storage_enabled") + .and_then(|v| v.parse::().ok()) + .unwrap_or(false); + // Extract credential vendor properties (properties prefixed with "credential_vendor.") // The prefix is stripped to get short property names // The build() method will check if enabled=true before creating the vendor @@ -327,6 +354,7 @@ impl DirectoryNamespaceBuilder { dir_listing_enabled, inline_optimization_enabled, table_version_tracking_enabled, + table_version_storage_enabled, credential_vendor_properties, context_provider: None, commit_retries, @@ -455,6 +483,13 @@ impl DirectoryNamespaceBuilder { /// - Connection to the storage backend fails /// - Storage options are invalid pub async fn build(self) -> Result { + // Validate: table_version_storage_enabled requires manifest_enabled + if self.table_version_storage_enabled && !self.manifest_enabled { + return Err(Error::invalid_input( + "table_version_storage_enabled requires manifest_enabled=true", + )); + } + let (object_store, base_path) = Self::initialize_object_store(&self.root, &self.storage_options, &self.session).await?; @@ -468,6 +503,7 @@ impl DirectoryNamespaceBuilder { self.dir_listing_enabled, self.inline_optimization_enabled, self.commit_retries, + self.table_version_storage_enabled, ) .await { @@ -504,6 +540,7 @@ impl DirectoryNamespaceBuilder { manifest_ns, dir_listing_enabled: self.dir_listing_enabled, table_version_tracking_enabled: self.table_version_tracking_enabled, + table_version_storage_enabled: self.table_version_storage_enabled, credential_vendor, context_provider: self.context_provider, }) @@ -577,6 +614,8 @@ pub struct DirectoryNamespace { /// When true, `describe_table` returns `managed_versioning: true` to indicate /// commits should go through namespace table version APIs. table_version_tracking_enabled: bool, + /// When true, table versions are stored in the `__manifest` table. + table_version_storage_enabled: bool, /// Credential vendor created once during initialization. /// Used to vend temporary credentials for table access. credential_vendor: Option>, @@ -598,6 +637,13 @@ impl std::fmt::Display for DirectoryNamespace { } } +/// Describes the version ranges to delete for a single table. +/// Used by `batch_delete_table_versions` and `delete_physical_version_files`. +struct TableDeleteEntry { + table_id: Option>, + ranges: Vec<(i64, i64)>, +} + impl DirectoryNamespace { /// Apply pagination to a list of table names /// @@ -911,6 +957,60 @@ impl DirectoryNamespace { Ok(migrated_count) } + + /// Delete physical manifest files for the given table version ranges (best-effort). + /// + /// This helper is used by `batch_delete_table_versions` in both the manifest-enabled + /// and non-manifest paths. It resolves each table's storage location, computes the + /// version file paths, and attempts to delete them. Errors are logged (best-effort) + /// when `best_effort` is true, or returned immediately when false. + /// + /// Returns the number of files successfully deleted. + async fn delete_physical_version_files( + &self, + table_entries: &[TableDeleteEntry], + best_effort: bool, + ) -> Result { + let mut deleted_count = 0i64; + for te in table_entries { + let table_uri = self.resolve_table_location(&te.table_id).await?; + let table_path = Self::uri_to_object_store_path(&table_uri); + let table_path_str = table_path.as_ref(); + let versions_dir_path = Path::from(format!("{}_versions", table_path_str)); + + for (start, end) in &te.ranges { + for version in *start..=*end { + let version_path = + versions_dir_path.child(format!("{}.manifest", version as u64)); + match self.object_store.inner.delete(&version_path).await { + Ok(_) => { + deleted_count += 1; + } + Err(object_store::Error::NotFound { .. }) => {} + Err(e) => { + if best_effort { + log::warn!( + "Failed to delete manifest file for version {} of table {:?}: {:?}", + version, + te.table_id, + e + ); + } else { + return Err(Error::namespace_source( + format!( + "Failed to delete version {} for table at '{}': {}", + version, table_uri, e + ) + .into(), + )); + } + } + } + } + } + } + Ok(deleted_count) + } } #[async_trait] @@ -1337,7 +1437,7 @@ impl LanceNamespace for DirectoryNamespace { }); let write_params = WriteParams { - mode: lance::dataset::WriteMode::Create, + mode: WriteMode::Create, store_params, ..Default::default() }; @@ -1507,6 +1607,18 @@ impl LanceNamespace for DirectoryNamespace { &self, request: ListTableVersionsRequest, ) -> Result { + // When table_version_storage_enabled, query from __manifest + if self.table_version_storage_enabled + && let Some(ref manifest_ns) = self.manifest_ns + { + let table_id = request.id.clone().unwrap_or_default(); + let want_descending = request.descending == Some(true); + return manifest_ns + .list_table_versions(&table_id, want_descending, request.limit) + .await; + } + + // Fallback when table_version_storage is not enabled: list from _versions/ directory let table_uri = self.resolve_table_location(&request.id).await?; let table_path = Self::uri_to_object_store_path(&table_uri); @@ -1677,6 +1789,41 @@ impl LanceNamespace for DirectoryNamespace { ); } + // If table_version_storage_enabled is enabled, also record in __manifest (best-effort) + if self.table_version_storage_enabled + && let Some(ref manifest_ns) = self.manifest_ns + { + let table_id_str = + manifest::ManifestNamespace::str_object_id(&request.id.clone().unwrap_or_default()); + let object_id = + manifest::ManifestNamespace::build_version_object_id(&table_id_str, version as i64); + let metadata_json = serde_json::json!({ + "manifest_path": final_path.to_string(), + "manifest_size": manifest_size, + "e_tag": put_result.e_tag, + "naming_scheme": request.naming_scheme.as_deref().unwrap_or("V2"), + }) + .to_string(); + + if let Err(e) = manifest_ns + .insert_into_manifest_with_metadata( + vec![manifest::ManifestEntry { + object_id, + object_type: manifest::ObjectType::TableVersion, + location: None, + metadata: Some(metadata_json), + }], + None, + ) + .await + { + log::warn!( + "Failed to record table version in __manifest (best-effort): {:?}", + e + ); + } + } + Ok(CreateTableVersionResponse { transaction_id: None, version: Some(Box::new(TableVersion { @@ -1694,6 +1841,16 @@ impl LanceNamespace for DirectoryNamespace { &self, request: DescribeTableVersionRequest, ) -> Result { + // When table_version_storage_enabled and a specific version is requested, + // query from __manifest to avoid opening the entire dataset + if self.table_version_storage_enabled + && let (Some(manifest_ns), Some(version)) = (&self.manifest_ns, request.version) + { + let table_id = request.id.clone().unwrap_or_default(); + return manifest_ns.describe_table_version(&table_id, version).await; + } + + // Fallback when table_version_storage is not enabled: open the dataset to describe the version let table_uri = self.resolve_table_location(&request.id).await?; // Use DatasetBuilder with storage options to support S3 with custom endpoints @@ -1752,44 +1909,80 @@ impl LanceNamespace for DirectoryNamespace { &self, request: BatchDeleteTableVersionsRequest, ) -> Result { - let table_uri = self.resolve_table_location(&request.id).await?; - - let table_path = Self::uri_to_object_store_path(&table_uri); - let table_path_str = table_path.as_ref(); - let versions_dir_path = Path::from(format!("{}_versions", table_path_str)); - - let mut deleted_count = 0i64; + // Single-table mode: use `id` (from path parameter) + `ranges` to delete + // versions from one table. + let ranges: Vec<(i64, i64)> = request + .ranges + .iter() + .map(|r| { + let start = r.start_version; + let end = if r.end_version > 0 { + r.end_version + } else { + start + }; + (start, end) + }) + .collect(); + let table_entries = vec![TableDeleteEntry { + table_id: request.id.clone(), + ranges, + }]; - for range in &request.ranges { - let start = range.start_version as u64; - let end = if range.end_version > 0 { - range.end_version as u64 - } else { - start - }; + let mut total_deleted_count = 0i64; - for version in start..=end { - let version_path = versions_dir_path.child(format!("{}.manifest", version)); - match self.object_store.inner.delete(&version_path).await { - Ok(_) => { - deleted_count += 1; - } - Err(object_store::Error::NotFound { .. }) => {} - Err(e) => { - return Err(Error::namespace_source( - format!( - "Failed to delete version {} for table at '{}': {}", - version, table_uri, e - ) - .into(), - )); + if self.table_version_storage_enabled + && let Some(ref manifest_ns) = self.manifest_ns + { + // Phase 1 (atomic commit point): Delete version records from __manifest + // for ALL tables in a single atomic operation. This is the authoritative + // source of truth — once __manifest entries are removed, the versions + // are logically deleted across all tables atomically. + + // Collect all (table_id_str, ranges) for batch deletion + let mut all_object_ids: Vec = Vec::new(); + for te in &table_entries { + let table_id_str = manifest::ManifestNamespace::str_object_id( + &te.table_id.clone().unwrap_or_default(), + ); + for (start, end) in &te.ranges { + for version in *start..=*end { + let object_id = manifest::ManifestNamespace::build_version_object_id( + &table_id_str, + version, + ); + all_object_ids.push(object_id); } } } + + if !all_object_ids.is_empty() { + total_deleted_count = manifest_ns + .batch_delete_table_versions_by_object_ids(&all_object_ids) + .await?; + } + + // Phase 2: Delete physical manifest files (best-effort). + // Even if some file deletions fail, the versions are already removed from + // __manifest, so they won't be visible to readers. Leftover files are + // orphaned but harmless and can be cleaned up later. + let _ = self + .delete_physical_version_files(&table_entries, true) + .await; + + return Ok(BatchDeleteTableVersionsResponse { + deleted_count: Some(total_deleted_count), + transaction_id: None, + }); } + // Fallback when table_version_storage is not enabled: delete physical files directly (no __manifest) + total_deleted_count = self + .delete_physical_version_files(&table_entries, false) + .await?; + Ok(BatchDeleteTableVersionsResponse { - deleted_count: Some(deleted_count), + deleted_count: Some(total_deleted_count), transaction_id: None, }) } @@ -4868,4 +5061,148 @@ mod tests { ); } } + + /// Tests for multi-table transaction support via table_version_storage_enabled. + mod multi_table_transactions { + use super::*; + use futures::TryStreamExt; + use lance::dataset::builder::DatasetBuilder; + use lance_namespace::models::CreateTableVersionRequest; + + /// Helper to create a namespace with table_version_storage_enabled enabled + async fn create_managed_namespace(temp_path: &str) -> Arc { + Arc::new( + DirectoryNamespaceBuilder::new(temp_path) + .table_version_tracking_enabled(true) + .table_version_storage_enabled(true) + .manifest_enabled(true) + .build() + .await + .unwrap(), + ) + } + + /// Helper to create a table and get its staging manifest path + async fn create_table_and_get_staging( + namespace: Arc, + table_name: &str, + ) -> (Vec, object_store::path::Path) { + let schema = create_test_schema(); + let ipc_data = create_test_ipc_data(&schema); + let mut create_req = CreateTableRequest::new(); + create_req.id = Some(vec![table_name.to_string()]); + namespace + .create_table(create_req, bytes::Bytes::from(ipc_data)) + .await + .unwrap(); + + let table_id = vec![table_name.to_string()]; + let dataset = DatasetBuilder::from_namespace(namespace.clone(), table_id.clone()) + .await + .unwrap() + .load() + .await + .unwrap(); + + // Find existing manifest and create a staging copy + let versions_path = dataset.versions_dir(); + let manifest_metas: Vec<_> = dataset + .object_store() + .inner + .list(Some(&versions_path)) + .try_collect() + .await + .unwrap(); + + let manifest_meta = manifest_metas + .iter() + .find(|m| { + m.location + .filename() + .map(|f| f.ends_with(".manifest")) + .unwrap_or(false) + }) + .expect("No manifest file found"); + + let manifest_data = dataset + .object_store() + .inner + .get(&manifest_meta.location) + .await + .unwrap() + .bytes() + .await + .unwrap(); + + let staging_path = dataset + .versions_dir() + .child(format!("staging_{}", table_name)); + dataset + .object_store() + .inner + .put(&staging_path, manifest_data.into()) + .await + .unwrap(); + + (table_id, staging_path) + } + + #[tokio::test] + async fn test_table_version_storage_enabled_requires_manifest() { + // table_version_storage_enabled=true requires manifest_enabled=true + let temp_dir = TempStdDir::default(); + let temp_path = temp_dir.to_str().unwrap(); + + let result = DirectoryNamespaceBuilder::new(temp_path) + .table_version_storage_enabled(true) + .manifest_enabled(false) + .build() + .await; + + assert!( + result.is_err(), + "Should fail when table_version_storage_enabled=true but manifest_enabled=false" + ); + } + + #[tokio::test] + #[cfg(not(windows))] + async fn test_create_table_version_records_in_manifest() { + // When table_version_storage_enabled is enabled, single create_table_version + // should also record the version in __manifest + let temp_dir = TempStrDir::default(); + let temp_path: &str = &temp_dir; + + let namespace = create_managed_namespace(temp_path).await; + let ns: Arc = namespace.clone(); + + let (table_id, staging_path) = + create_table_and_get_staging(ns.clone(), "table_managed").await; + + // Create version 2 + let mut create_req = CreateTableVersionRequest::new(2, staging_path.to_string()); + create_req.id = Some(table_id.clone()); + create_req.naming_scheme = Some("V2".to_string()); + let response = namespace.create_table_version(create_req).await.unwrap(); + + assert!(response.version.is_some()); + let version = response.version.unwrap(); + assert_eq!(version.version, 2); + + // Verify the version is recorded in __manifest by querying it + let manifest_ns = namespace.manifest_ns.as_ref().unwrap(); + let table_id_str = manifest::ManifestNamespace::str_object_id(&table_id); + let versions = manifest_ns + .query_table_versions(&table_id_str, false, None) + .await + .unwrap(); + + assert!( + !versions.is_empty(), + "Version should be recorded in __manifest" + ); + let (ver, _path) = &versions[0]; + assert_eq!(*ver, 2, "Recorded version should be 2"); + } + } } diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index 16947c58290..902c5462acc 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -6,6 +6,7 @@ //! This module provides a namespace implementation that uses a manifest table //! to track tables and nested namespaces. +use arrow::array::builder::{ListBuilder, StringBuilder}; use arrow::array::{Array, RecordBatch, RecordBatchIterator, StringArray}; use arrow::datatypes::{DataType, Field, Schema as ArrowSchema}; use arrow_ipc::reader::StreamReader; @@ -13,7 +14,10 @@ use async_trait::async_trait; use bytes::Bytes; use futures::{FutureExt, stream::StreamExt}; use lance::dataset::optimize::{CompactionOptions, compact_files}; -use lance::dataset::{ReadParams, WriteParams, builder::DatasetBuilder}; +use lance::dataset::{ + DeleteBuilder, MergeInsertBuilder, ReadParams, WhenMatched, WhenNotMatched, WriteParams, + builder::DatasetBuilder, +}; use lance::session::Session; use lance::{Dataset, dataset::scanner::Scanner}; use lance_core::Error as LanceError; @@ -30,10 +34,11 @@ use lance_namespace::models::{ CreateNamespaceRequest, CreateNamespaceResponse, CreateTableRequest, CreateTableResponse, DeclareTableRequest, DeclareTableResponse, DeregisterTableRequest, DeregisterTableResponse, DescribeNamespaceRequest, DescribeNamespaceResponse, DescribeTableRequest, - DescribeTableResponse, DropNamespaceRequest, DropNamespaceResponse, DropTableRequest, - DropTableResponse, ListNamespacesRequest, ListNamespacesResponse, ListTablesRequest, - ListTablesResponse, NamespaceExistsRequest, RegisterTableRequest, RegisterTableResponse, - TableExistsRequest, + DescribeTableResponse, DescribeTableVersionResponse, DropNamespaceRequest, + DropNamespaceResponse, DropTableRequest, DropTableResponse, ListNamespacesRequest, + ListNamespacesResponse, ListTableVersionsResponse, ListTablesRequest, ListTablesResponse, + NamespaceExistsRequest, RegisterTableRequest, RegisterTableResponse, TableExistsRequest, + TableVersion, }; use lance_namespace::schema::arrow_schema_to_json; use object_store::path::Path; @@ -62,6 +67,7 @@ const BASE_OBJECTS_INDEX_NAME: &str = "base_objects_label_list"; pub enum ObjectType { Namespace, Table, + TableVersion, } impl ObjectType { @@ -69,6 +75,7 @@ impl ObjectType { match self { Self::Namespace => "namespace", Self::Table => "table", + Self::TableVersion => "table_version", } } @@ -76,6 +83,7 @@ impl ObjectType { match s { "namespace" => Ok(Self::Namespace), "table" => Ok(Self::Table), + "table_version" => Ok(Self::TableVersion), _ => Err(Error::io(format!("Invalid object type: {}", s))), } } @@ -89,6 +97,22 @@ pub struct TableInfo { pub location: String, } +/// An entry to be inserted into the manifest table. +/// +/// This struct makes the meaning of each field explicit, replacing the +/// previous tuple-based API `(String, ObjectType, Option, Option)`. +#[derive(Debug, Clone)] +pub struct ManifestEntry { + /// The unique object identifier (e.g., table name or version object_id) + pub object_id: String, + /// The type of the object (Namespace, Table, or TableVersion) + pub object_type: ObjectType, + /// The storage location (e.g., directory name for tables) + pub location: Option, + /// Additional metadata serialized as JSON + pub metadata: Option, +} + /// Information about a namespace stored in the manifest #[derive(Debug, Clone)] pub struct NamespaceInfo { @@ -334,10 +358,15 @@ impl ManifestNamespace { dir_listing_enabled: bool, inline_optimization_enabled: bool, commit_retries: Option, + table_version_storage_enabled: bool, ) -> Result { - let manifest_dataset = - Self::ensure_manifest_table_up_to_date(&root, &storage_options, session.clone()) - .await?; + let manifest_dataset = Self::ensure_manifest_table_up_to_date( + &root, + &storage_options, + session.clone(), + table_version_storage_enabled, + ) + .await?; Ok(Self { root, @@ -379,30 +408,58 @@ impl ManifestNamespace { } } - /// Split an object ID (table_id as vec of strings) into namespace and table name - fn split_object_id(table_id: &[String]) -> (Vec, String) { - if table_id.len() == 1 { - (vec![], table_id[0].clone()) + /// Split an object ID (vec of strings) into namespace and table name + pub fn split_object_id(object_id: &[String]) -> (Vec, String) { + if object_id.len() == 1 { + (vec![], object_id[0].clone()) } else { ( - table_id[..table_id.len() - 1].to_vec(), - table_id[table_id.len() - 1].clone(), + object_id[..object_id.len() - 1].to_vec(), + object_id[object_id.len() - 1].clone(), ) } } - /// Convert a table ID (vec of strings) to an object_id string - fn str_object_id(table_id: &[String]) -> String { - table_id.join(DELIMITER) + /// Convert an ID (vec of strings) to an object_id string + pub fn str_object_id(object_id: &[String]) -> String { + object_id.join(DELIMITER) + } + + /// Format a version number as a zero-padded lexicographically sortable string. + /// + /// Versions are stored as 20-digit zero-padded integers (e.g., `00000000000000000001` + /// for version 1) so that string-based range queries and sorting work correctly. + pub fn format_table_version(version: i64) -> String { + format!("{:020}", version) + } + + /// Build the object_id for a table version entry. + /// + /// Format: `{table_object_id}${zero_padded_version}` + pub fn build_version_object_id(table_object_id: &str, version: i64) -> String { + format!( + "{}{}{}", + table_object_id, + DELIMITER, + Self::format_table_version(version) + ) + } + + /// Parse a version number from the version suffix of a table version object_id. + /// + /// The object_id is formatted as `{table_id}${zero_padded_version}`. + pub fn parse_version_from_object_id(object_id: &str) -> Option { + let (_namespace, name) = Self::parse_object_id(object_id); + name.parse::().ok() } - /// Generate a new directory name in format: _ + /// Generate a new directory name in format: `_` /// The hash is used to (1) optimize object store throughput, /// (2) have high enough entropy in a short period of time to prevent issues like /// failed table creation, delete and create new table of the same name, etc. /// The object_id is added after the hash to ensure /// dir name uniqueness and make debugging easier. - fn generate_dir_name(object_id: &str) -> String { + pub fn generate_dir_name(object_id: &str) -> String { // Generate a random number for uniqueness let random_num: u64 = rand::random(); @@ -782,24 +839,39 @@ impl ManifestNamespace { object_type: ObjectType, location: Option, ) -> Result<()> { - self.insert_into_manifest_with_metadata(object_id, object_type, location, None, None) - .await + self.insert_into_manifest_with_metadata( + vec![ManifestEntry { + object_id, + object_type, + location, + metadata: None, + }], + None, + ) + .await } - /// Insert an entry into the manifest table with metadata and base_objects - async fn insert_into_manifest_with_metadata( + /// Insert one or more entries into the manifest table with metadata and base_objects. + /// + /// This is the unified entry point for both single and batch inserts. + /// Uses a single MergeInsert operation to insert all entries at once. + /// If any entry already exists (matching object_id), the entire batch fails. + pub async fn insert_into_manifest_with_metadata( &self, - object_id: String, - object_type: ObjectType, - location: Option, - metadata: Option, + entries: Vec, base_objects: Option>, ) -> Result<()> { - use arrow::array::builder::{ListBuilder, StringBuilder}; + if entries.is_empty() { + return Ok(()); + } let schema = Self::manifest_schema(); - // Create base_objects array from the provided list + let mut object_ids = Vec::with_capacity(entries.len()); + let mut object_types = Vec::with_capacity(entries.len()); + let mut locations: Vec> = Vec::with_capacity(entries.len()); + let mut metadatas: Vec> = Vec::with_capacity(entries.len()); + let string_builder = StringBuilder::new(); let mut list_builder = ListBuilder::new(string_builder).with_field(Arc::new(Field::new( "object_id", @@ -807,42 +879,52 @@ impl ManifestNamespace { true, ))); - match base_objects { - Some(objects) => { - for obj in objects { - list_builder.values().append_value(obj); + for (i, entry) in entries.iter().enumerate() { + object_ids.push(entry.object_id.as_str()); + object_types.push(entry.object_type.as_str()); + locations.push(entry.location.clone()); + metadatas.push(entry.metadata.clone()); + + // Only the first entry gets the base_objects (for single-entry inserts + // with base_objects like view creation); batch entries use null. + if i == 0 { + match &base_objects { + Some(objects) => { + for obj in objects { + list_builder.values().append_value(obj); + } + list_builder.append(true); + } + None => { + list_builder.append_null(); + } } - list_builder.append(true); - } - None => { + } else { list_builder.append_null(); } } let base_objects_array = list_builder.finish(); - // Create arrays with optional values - let location_array = match location { - Some(loc) => Arc::new(StringArray::from(vec![Some(loc)])), - None => Arc::new(StringArray::from(vec![None::])), - }; + let location_array: Arc = Arc::new(StringArray::from( + locations.iter().map(|l| l.as_deref()).collect::>(), + )); - let metadata_array = match metadata { - Some(meta) => Arc::new(StringArray::from(vec![Some(meta)])), - None => Arc::new(StringArray::from(vec![None::])), - }; + let metadata_array: Arc = Arc::new(StringArray::from( + metadatas.iter().map(|m| m.as_deref()).collect::>(), + )); let batch = RecordBatch::try_new( schema.clone(), vec![ - Arc::new(StringArray::from(vec![object_id.as_str()])), - Arc::new(StringArray::from(vec![object_type.as_str()])), + Arc::new(StringArray::from(object_ids)), + Arc::new(StringArray::from(object_types.to_vec())), location_array, metadata_array, Arc::new(base_objects_array), ], ) - .map_err(|e| Error::io(format!("Failed to create manifest entry: {}", e)))?; + .map_err(|e| Error::io(format!("Failed to create manifest entries: {}", e)))?; let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); @@ -852,16 +934,16 @@ impl ManifestNamespace { drop(dataset_guard); // Drop read guard before merge insert let mut merge_builder = - lance::dataset::MergeInsertBuilder::try_new(dataset_arc, vec!["object_id".to_string()]) - .map_err(|e| { + MergeInsertBuilder::try_new(dataset_arc, vec!["object_id".to_string()]).map_err( + |e| { Error::io_source(box_error(std::io::Error::other(format!( "Failed to create merge builder: {}", e )))) - })?; - - merge_builder.when_matched(lance::dataset::WhenMatched::Fail); - merge_builder.when_not_matched(lance::dataset::WhenNotMatched::InsertAll); + }, + )?; + merge_builder.when_matched(WhenMatched::Fail); + merge_builder.when_not_matched(WhenNotMatched::InsertAll); // Use conflict_retries to handle cross-process races on manifest mutations. // When two processes concurrently insert the same object_id, the second one // hits a commit conflict. With conflict_retries > 0, the retry re-evaluates @@ -888,7 +970,7 @@ impl ManifestNamespace { .execute_reader(Box::new(reader)) .await .map_err(|e| { - convert_lance_commit_error(&e, "Failed to execute merge", Some(&object_id)) + convert_lance_commit_error(&e, "Failed to execute merge insert into manifest", None) })?; let new_dataset = Arc::try_unwrap(new_dataset_arc).unwrap_or_else(|arc| (*arc).clone()); @@ -914,7 +996,7 @@ impl ManifestNamespace { let dataset = Arc::new(dataset_guard.clone()); drop(dataset_guard); // Drop read guard before delete - let new_dataset = lance::dataset::DeleteBuilder::new(dataset, &predicate) + let new_dataset = DeleteBuilder::new(dataset, &predicate) .execute() .await .map_err(|e| convert_lance_commit_error(&e, "Failed to delete", None))?; @@ -937,6 +1019,415 @@ impl ManifestNamespace { Ok(()) } + /// Query the manifest for all versions of a table, sorted by version. + /// + /// Returns a list of (version, metadata_json_string) tuples where metadata_json_string + /// contains the full metadata JSON stored in the manifest (manifest_path, manifest_size, + /// e_tag, naming_scheme). + /// + /// **Known limitation**: All matching rows are loaded into memory, sorted in Rust, + /// and then truncated. For tables with a very large number of versions this may be + /// expensive. Pushing sort/limit into the scan is not yet supported by Lance. + pub async fn query_table_versions( + &self, + object_id: &str, + descending: bool, + limit: Option, + ) -> Result> { + let escaped_id = object_id.replace('\'', "''"); + // table_version object_ids are formatted as "{object_id}${zero_padded_version}" + let filter = format!( + "object_type = 'table_version' AND starts_with(object_id, '{}{}')", + escaped_id, DELIMITER + ); + let mut scanner = self.manifest_scanner().await?; + scanner.filter(&filter).map_err(|e| { + Error::io_source(box_error(std::io::Error::other(format!( + "Failed to filter: {}", + e + )))) + })?; + scanner.project(&["object_id", "metadata"]).map_err(|e| { + Error::io_source(box_error(std::io::Error::other(format!( + "Failed to project: {}", + e + )))) + })?; + let batches = Self::execute_scanner(scanner).await?; + + let mut versions: Vec<(i64, String)> = Vec::new(); + for batch in batches { + if batch.num_rows() == 0 { + continue; + } + let object_id_array = Self::get_string_column(&batch, "object_id")?; + let metadata_array = Self::get_string_column(&batch, "metadata")?; + for i in 0..batch.num_rows() { + let oid = object_id_array.value(i); + // Parse version from object_id + if let Some(version) = Self::parse_version_from_object_id(oid) { + let metadata_str = metadata_array.value(i).to_string(); + versions.push((version, metadata_str)); + } + } + } + + if descending { + versions.sort_by(|a, b| b.0.cmp(&a.0)); + } else { + versions.sort_by(|a, b| a.0.cmp(&b.0)); + } + + if let Some(limit) = limit { + versions.truncate(limit as usize); + } + + Ok(versions) + } + + /// Query the manifest for a specific version of a table. + /// + /// Returns the full metadata JSON string if found, which contains + /// manifest_path, manifest_size, e_tag, and naming_scheme. + /// + pub async fn query_table_version( + &self, + object_id: &str, + version: i64, + ) -> Result> { + let version_object_id = Self::build_version_object_id(object_id, version); + self.query_table_version_by_object_id(&version_object_id) + .await + } + + /// Query a specific table version by its exact object_id. + async fn query_table_version_by_object_id( + &self, + version_object_id: &str, + ) -> Result> { + let escaped_id = version_object_id.replace('\'', "''"); + let filter = format!( + "object_id = '{}' AND object_type = 'table_version'", + escaped_id + ); + let mut scanner = self.manifest_scanner().await?; + scanner.filter(&filter).map_err(|e| { + Error::io_source(box_error(std::io::Error::other(format!( + "Failed to filter: {}", + e + )))) + })?; + scanner.project(&["metadata"]).map_err(|e| { + Error::io_source(box_error(std::io::Error::other(format!( + "Failed to project: {}", + e + )))) + })?; + let batches = Self::execute_scanner(scanner).await?; + + for batch in batches { + if batch.num_rows() == 0 { + continue; + } + let metadata_array = Self::get_string_column(&batch, "metadata")?; + return Ok(Some(metadata_array.value(0).to_string())); + } + + Ok(None) + } + + /// Delete table version entries from the manifest for a given table and version ranges. + /// + /// Each range is (start_version, end_version) inclusive. Deletes all matching + /// `object_type = 'table_version'` entries whose object_id matches + /// `{object_id}${zero_padded_version}`. + /// + /// Builds a single filter expression covering all version ranges and executes + /// one bulk delete operation instead of deleting versions one at a time. + pub async fn delete_table_versions( + &self, + object_id: &str, + ranges: &[(i64, i64)], + ) -> Result { + if ranges.is_empty() { + return Ok(0); + } + + // Collect all object_ids to delete (both new zero-padded and legacy formats) + let mut object_id_conditions: Vec = Vec::new(); + for (start, end) in ranges { + for version in *start..=*end { + let oid = Self::build_version_object_id(object_id, version); + let escaped = oid.replace('\'', "''"); + object_id_conditions.push(format!("'{}'", escaped)); + } + } + + if object_id_conditions.is_empty() { + return Ok(0); + } + + // First, count how many entries exist so we can report the deleted count + let in_list = object_id_conditions.join(", "); + let filter = format!( + "object_type = 'table_version' AND object_id IN ({})", + in_list + ); + + let mut scanner = self.manifest_scanner().await?; + scanner.filter(&filter).map_err(|e| { + Error::io_source(box_error(std::io::Error::other(format!( + "Failed to filter: {}", + e + )))) + })?; + scanner.project(&["object_id"]).map_err(|e| { + Error::io_source(box_error(std::io::Error::other(format!( + "Failed to project: {}", + e + )))) + })?; + let batches = Self::execute_scanner(scanner).await?; + let deleted_count: i64 = batches.iter().map(|b| b.num_rows() as i64).sum(); + + if deleted_count == 0 { + return Ok(0); + } + + // Execute a single bulk delete with the combined filter + let dataset_guard = self.manifest_dataset.get().await?; + let dataset = Arc::new(dataset_guard.clone()); + drop(dataset_guard); + + let new_dataset = DeleteBuilder::new(dataset, &filter) + .execute() + .await + .map_err(|e| { + convert_lance_commit_error(&e, "Failed to batch delete table versions", None) + })?; + + self.manifest_dataset + .set_latest( + Arc::try_unwrap(new_dataset.new_dataset).unwrap_or_else(|arc| (*arc).clone()), + ) + .await; + + if let Err(e) = self.run_inline_optimization().await { + log::warn!( + "Unexpected failure when running inline optimization: {:?}", + e + ); + } + + Ok(deleted_count) + } + + /// Atomically delete table version entries from the manifest by their object_ids. + /// + /// This method supports multi-table transactional deletion: all specified + /// object_ids (which may span multiple tables) are deleted in a single atomic + /// `DeleteBuilder` operation. Either all entries are removed or none are. + /// + /// Object IDs are formatted as `{table_id}${version}`. + pub async fn batch_delete_table_versions_by_object_ids( + &self, + object_ids: &[String], + ) -> Result { + if object_ids.is_empty() { + return Ok(0); + } + + let in_list: String = object_ids + .iter() + .map(|oid| { + let escaped = oid.replace('\'', "''"); + format!("'{}'", escaped) + }) + .collect::>() + .join(", "); + + let filter = format!( + "object_type = 'table_version' AND object_id IN ({})", + in_list + ); + + // Count how many entries exist so we can report the deleted count + let mut scanner = self.manifest_scanner().await?; + scanner.filter(&filter).map_err(|e| { + Error::io_source(box_error(std::io::Error::other(format!( + "Failed to filter: {}", + e + )))) + })?; + scanner.project(&["object_id"]).map_err(|e| { + Error::io_source(box_error(std::io::Error::other(format!( + "Failed to project: {}", + e + )))) + })?; + let batches = Self::execute_scanner(scanner).await?; + let deleted_count: i64 = batches.iter().map(|b| b.num_rows() as i64).sum(); + + if deleted_count == 0 { + return Ok(0); + } + + // Execute a single atomic bulk delete covering all tables + let dataset_guard = self.manifest_dataset.get().await?; + let dataset = Arc::new(dataset_guard.clone()); + drop(dataset_guard); + + let new_dataset = DeleteBuilder::new(dataset, &filter) + .execute() + .await + .map_err(|e| { + convert_lance_commit_error( + &e, + "Failed to batch delete table versions across multiple tables", + None, + ) + })?; + + self.manifest_dataset + .set_latest( + Arc::try_unwrap(new_dataset.new_dataset).unwrap_or_else(|arc| (*arc).clone()), + ) + .await; + + if let Err(e) = self.run_inline_optimization().await { + log::warn!( + "Unexpected failure when running inline optimization: {:?}", + e + ); + } + + Ok(deleted_count) + } + + /// Set a property flag in the __manifest table's metadata key-value map. + /// + /// This uses `dataset.update_metadata()` to persist the flag in the + /// __manifest dataset's table metadata, rather than inserting a row. + /// If the property already exists with the same value, this is a no-op. + pub async fn set_property(&self, name: &str, value: &str) -> Result<()> { + let dataset_guard = self.manifest_dataset.get().await?; + if dataset_guard.metadata().get(name) == Some(&value.to_string()) { + return Ok(()); + } + drop(dataset_guard); + + let mut dataset_guard = self.manifest_dataset.get_mut().await?; + dataset_guard + .update_metadata([(name, value)]) + .await + .map_err(|e| { + Error::io_source(box_error(std::io::Error::other(format!( + "Failed to set property '{}' in __manifest metadata: {}", + name, e + )))) + })?; + Ok(()) + } + + /// Check if a property flag exists in the __manifest table's metadata key-value map. + pub async fn has_property(&self, name: &str) -> Result { + let dataset_guard = self.manifest_dataset.get().await?; + Ok(dataset_guard.metadata().contains_key(name)) + } + + /// Parse metadata JSON into a `TableVersion`. + /// + /// Returns `None` if metadata is invalid or missing required fields. + fn parse_table_version(version: i64, metadata_str: &str) -> Option { + let meta: serde_json::Value = match serde_json::from_str(metadata_str) { + Ok(v) => v, + Err(e) => { + log::warn!( + "Skipping version {} due to invalid metadata JSON: {}", + version, + e + ); + return None; + } + }; + let manifest_path = match meta.get("manifest_path").and_then(|v| v.as_str()) { + Some(p) => p.to_string(), + None => { + log::warn!( + "Skipping version {} due to missing 'manifest_path' in metadata — \ + this may indicate data corruption", + version + ); + return None; + } + }; + let manifest_size = meta.get("manifest_size").and_then(|v| v.as_i64()); + let e_tag = meta + .get("e_tag") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(TableVersion { + version, + manifest_path, + manifest_size, + e_tag, + timestamp_millis: None, + metadata: None, + }) + } + + /// List table versions from the __manifest table. + /// + /// Queries the manifest for all versions of the given table and returns + /// them as a `ListTableVersionsResponse`. + pub async fn list_table_versions( + &self, + table_id: &[String], + descending: bool, + limit: Option, + ) -> Result { + let object_id = Self::str_object_id(table_id); + let manifest_versions = self + .query_table_versions(&object_id, descending, limit) + .await?; + + let table_versions: Vec = manifest_versions + .into_iter() + .filter_map(|(version, metadata_str)| Self::parse_table_version(version, &metadata_str)) + .collect(); + + Ok(ListTableVersionsResponse { + versions: table_versions, + page_token: None, + }) + } + + /// Describe a specific table version from the __manifest table. + /// + /// Queries the manifest for a specific version and returns it as a + /// `DescribeTableVersionResponse`. Returns an error if the version is not found. + pub async fn describe_table_version( + &self, + table_id: &[String], + version: i64, + ) -> Result { + let object_id = Self::str_object_id(table_id); + if let Some(metadata_str) = self.query_table_version(&object_id, version).await? + && let Some(tv) = Self::parse_table_version(version, &metadata_str) + { + return Ok(DescribeTableVersionResponse { + version: Box::new(tv), + }); + } + Err(Error::namespace_source( + format!( + "Version {} not found in manifest for table {:?}", + version, table_id + ) + .into(), + )) + } + /// Register a table in the manifest without creating the physical table (internal helper for migration) pub async fn register_table(&self, name: &str, location: String) -> Result<()> { let object_id = Self::build_object_id(&[], name); @@ -1033,10 +1524,12 @@ impl ManifestNamespace { /// 1. Try to load an existing manifest table /// 2. If it exists, check and migrate the schema if needed (e.g., add primary key metadata) /// 3. If it doesn't exist, create a new manifest table with the current schema + /// 4. Persist feature flags (e.g., table_version_storage_enabled) if requested async fn ensure_manifest_table_up_to_date( root: &str, storage_options: &Option>, session: Option>, + table_version_storage_enabled: bool, ) -> Result { let manifest_path = format!("{}/{}", root, MANIFEST_TABLE_NAME); log::debug!("Attempting to load manifest from {}", manifest_path); @@ -1090,6 +1583,27 @@ impl ManifestNamespace { })?; } + // Persist table_version_storage_enabled flag in __manifest so that once + // enabled, it becomes a permanent property of this namespace. + if table_version_storage_enabled { + let needs_flag = dataset + .metadata() + .get("table_version_storage_enabled") + .map(|v| v != "true") + .unwrap_or(true); + + if needs_flag + && let Err(e) = dataset + .update_metadata([("table_version_storage_enabled", "true")]) + .await + { + log::warn!( + "Failed to persist table_version_storage_enabled flag in __manifest: {:?}", + e + ); + } + } + Ok(DatasetConsistencyWrapper::new(dataset)) } else { log::info!("Creating new manifest table at {}", manifest_path); @@ -1632,10 +2146,12 @@ impl LanceNamespace for ManifestNamespace { }); self.insert_into_manifest_with_metadata( - object_id, - ObjectType::Namespace, - None, - metadata, + vec![ManifestEntry { + object_id, + object_type: ObjectType::Namespace, + location: None, + metadata, + }], None, ) .await?; diff --git a/rust/lance-namespace-impls/src/rest.rs b/rust/lance-namespace-impls/src/rest.rs index 21451d89c39..42fcbe540c4 100644 --- a/rust/lance-namespace-impls/src/rest.rs +++ b/rust/lance-namespace-impls/src/rest.rs @@ -18,8 +18,9 @@ use lance_namespace::models::{ AlterTableAddColumnsRequest, AlterTableAddColumnsResponse, AlterTableAlterColumnsRequest, AlterTableAlterColumnsResponse, AlterTableDropColumnsRequest, AlterTableDropColumnsResponse, AlterTransactionRequest, AlterTransactionResponse, AnalyzeTableQueryPlanRequest, - CountTableRowsRequest, CreateNamespaceRequest, CreateNamespaceResponse, - CreateTableIndexRequest, CreateTableIndexResponse, CreateTableRequest, CreateTableResponse, + BatchDeleteTableVersionsRequest, BatchDeleteTableVersionsResponse, CountTableRowsRequest, + CreateNamespaceRequest, CreateNamespaceResponse, CreateTableIndexRequest, + CreateTableIndexResponse, CreateTableRequest, CreateTableResponse, CreateTableScalarIndexResponse, CreateTableTagRequest, CreateTableTagResponse, CreateTableVersionRequest, CreateTableVersionResponse, DeclareTableRequest, DeclareTableResponse, DeleteFromTableRequest, DeleteFromTableResponse, DeleteTableTagRequest, @@ -1170,6 +1171,18 @@ impl LanceNamespace for RestNamespace { .await } + async fn batch_delete_table_versions( + &self, + request: BatchDeleteTableVersionsRequest, + ) -> Result { + let id = object_id_str(&request.id, &self.delimiter)?; + let encoded_id = urlencode(&id); + let path = format!("/v1/table/{}/version/delete", encoded_id); + let query = [("delimiter", self.delimiter.as_str())]; + self.post_json(&path, &query, &request, "batch_delete_table_versions", &id) + .await + } + async fn update_table_schema_metadata( &self, request: UpdateTableSchemaMetadataRequest, diff --git a/rust/lance-namespace-impls/src/rest_adapter.rs b/rust/lance-namespace-impls/src/rest_adapter.rs index e2d30299151..ee696ee1a32 100644 --- a/rust/lance-namespace-impls/src/rest_adapter.rs +++ b/rust/lance-namespace-impls/src/rest_adapter.rs @@ -81,6 +81,10 @@ impl RestAdapter { "/v1/table/:id/version/describe", post(describe_table_version), ) + .route( + "/v1/table/:id/version/delete", + post(batch_delete_table_versions), + ) .route("/v1/table/:id/stats", get(get_table_stats)) // Table data operations .route("/v1/table/:id/create", post(create_table)) @@ -783,6 +787,26 @@ async fn describe_table_version( } } +async fn batch_delete_table_versions( + State(backend): State>, + headers: HeaderMap, + Path(id): Path, + Query(params): Query, + Json(body): Json, +) -> Response { + let request = BatchDeleteTableVersionsRequest { + id: Some(parse_id(&id, params.delimiter.as_deref())), + identity: extract_identity(&headers), + ranges: body.ranges, + ..Default::default() + }; + + match backend.batch_delete_table_versions(request).await { + Ok(response) => (StatusCode::OK, Json(response)).into_response(), + Err(e) => error_to_response(e), + } +} + async fn get_table_stats( State(backend): State>, headers: HeaderMap, diff --git a/rust/lance-namespace/Cargo.toml b/rust/lance-namespace/Cargo.toml index f0aa59c91b8..1bb1358d486 100644 --- a/rust/lance-namespace/Cargo.toml +++ b/rust/lance-namespace/Cargo.toml @@ -16,6 +16,7 @@ async-trait.workspace = true bytes.workspace = true arrow.workspace = true lance-core.workspace = true +serde.workspace = true snafu.workspace = true lance-namespace-reqwest-client.workspace = true diff --git a/rust/lance-namespace/src/namespace.rs b/rust/lance-namespace/src/namespace.rs index 25776e26e76..610dd03af9e 100644 --- a/rust/lance-namespace/src/namespace.rs +++ b/rust/lance-namespace/src/namespace.rs @@ -339,8 +339,7 @@ pub trait LanceNamespace: Send + Sync + std::fmt::Debug { /// Batch delete table versions. /// - /// Deletes multiple version records from a table. This operation supports - /// deleting ranges of versions for efficient bulk cleanup. + /// Deletes version records for a single table using `request.id` + `request.ranges`. /// /// # Arguments /// From 60cae4c8f0b1a333be23309be1cc6ca949e95365 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Thu, 19 Mar 2026 05:23:14 +0000 Subject: [PATCH 049/206] chore: release beta version 4.0.0-beta.13 --- .bumpversion.toml | 2 +- Cargo.lock | 146 +++++++++++------------ Cargo.toml | 40 +++---- java/lance-jni/Cargo.lock | 97 +++++++-------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 244 ++++++++++++++++++++++++-------------- python/Cargo.toml | 2 +- 8 files changed, 299 insertions(+), 236 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index a51e7b34c8a..0adce97afe1 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.0.0-beta.12" +current_version = "4.0.0-beta.13" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 97c13ed1657..d76001c035e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1441,9 +1441,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d13a61f2963b88eef9c1be03df65d42f6996dfeac1054870d950fcf66686f83" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" dependencies = [ "bon-macros", "rustversion", @@ -1451,9 +1451,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d314cc62af2b6b0c65780555abb4d02a03dd3b799cd42419044f0c38d99738c0" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ "darling 0.23.0", "ident_case", @@ -1536,9 +1536,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -3002,7 +3002,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3241,7 +3241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3451,7 +3451,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "lance-datagen", @@ -4220,7 +4220,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -4420,36 +4420,32 @@ dependencies = [ [[package]] name = "include-flate" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01b7cb6ca682a621e7cda1c358c9724b53a7b4409be9be1dd443b7f3a26f998" +checksum = "8a05fb00d9abc625268e0573a519506b264a7d6965de09bac13201bfb44e723d" dependencies = [ "include-flate-codegen", "include-flate-compress", - "libflate", - "zstd", ] [[package]] name = "include-flate-codegen" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f49bf5274aebe468d6e6eba14a977eaf1efa481dc173f361020de70c1c48050" +checksum = "92c3c319a7527668538a8530c541e74e881e94c4f41e1425622d0a41c16468af" dependencies = [ "include-flate-compress", - "libflate", - "proc-macro-error", + "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.117", - "zstd", ] [[package]] name = "include-flate-compress" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae6a40e716bcd5931f5dbb79cd921512a4f647e2e9413fded3171fca3824dbc" +checksum = "ed0bd9ea81b94169d61c5a397e9faef02153d3711fc62d3270bcde3ac85380d9" dependencies = [ "libflate", "zstd", @@ -4553,7 +4549,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4648,7 +4644,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4743,7 +4739,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "all_asserts", "approx", @@ -4838,7 +4834,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "arrow-buffer", @@ -4874,7 +4870,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrayref", "paste", @@ -4883,7 +4879,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "arrow-buffer", @@ -4923,7 +4919,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -4957,7 +4953,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -4977,7 +4973,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-arith", "arrow-array", @@ -5024,7 +5020,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "all_asserts", "arrow", @@ -5050,7 +5046,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-arith", "arrow-array", @@ -5093,7 +5089,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "datafusion", "geo-traits", @@ -5107,7 +5103,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "approx", "arrow", @@ -5187,7 +5183,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-arith", @@ -5235,7 +5231,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "approx", "arrow-array", @@ -5256,7 +5252,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "async-trait", @@ -5271,7 +5267,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -5289,7 +5285,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-ipc", @@ -5347,7 +5343,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -5393,7 +5389,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "proc-macro2", "quote", @@ -5402,7 +5398,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "arrow-schema", @@ -5413,7 +5409,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "clap", "lance-core", @@ -6102,7 +6098,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6198,9 +6194,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -6208,9 +6204,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -6879,9 +6875,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -6989,27 +6985,25 @@ dependencies = [ ] [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "proc-macro-error-attr2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ - "proc-macro-error-attr", "proc-macro2", "quote", - "syn 1.0.109", - "version_check", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "proc-macro-error2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ + "proc-macro-error-attr2", "proc-macro2", "quote", - "version_check", + "syn 2.0.117", ] [[package]] @@ -7158,7 +7152,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.37", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -7195,9 +7189,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7775,7 +7769,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8775,7 +8769,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8940,9 +8934,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -9070,18 +9064,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap", "toml_datetime", @@ -9091,9 +9085,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ "winnow", ] @@ -9749,7 +9743,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -10061,9 +10055,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 597f8cb1fbf..051b2649341 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.0.0-beta.12", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.0.0-beta.12", path = "./rust/lance-arrow" } -lance-core = { version = "=4.0.0-beta.12", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.0.0-beta.12", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.0.0-beta.12", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.0.0-beta.12", path = "./rust/lance-encoding" } -lance-file = { version = "=4.0.0-beta.12", path = "./rust/lance-file" } -lance-geo = { version = "=4.0.0-beta.12", path = "./rust/lance-geo" } -lance-index = { version = "=4.0.0-beta.12", path = "./rust/lance-index" } -lance-io = { version = "=4.0.0-beta.12", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.0.0-beta.12", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.0.0-beta.12", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.0.0-beta.12", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.0.0-beta.12", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=4.0.0-beta.13", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=4.0.0-beta.13", path = "./rust/lance-arrow" } +lance-core = { version = "=4.0.0-beta.13", path = "./rust/lance-core" } +lance-datafusion = { version = "=4.0.0-beta.13", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=4.0.0-beta.13", path = "./rust/lance-datagen" } +lance-encoding = { version = "=4.0.0-beta.13", path = "./rust/lance-encoding" } +lance-file = { version = "=4.0.0-beta.13", path = "./rust/lance-file" } +lance-geo = { version = "=4.0.0-beta.13", path = "./rust/lance-geo" } +lance-index = { version = "=4.0.0-beta.13", path = "./rust/lance-index" } +lance-io = { version = "=4.0.0-beta.13", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=4.0.0-beta.13", path = "./rust/lance-linalg" } +lance-namespace = { version = "=4.0.0-beta.13", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=4.0.0-beta.13", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=4.0.0-beta.13", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=4.0.0-beta.12", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.0.0-beta.12", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.0.0-beta.12", path = "./rust/lance-testing" } +lance-table = { version = "=4.0.0-beta.13", path = "./rust/lance-table" } +lance-test-macros = { version = "=4.0.0-beta.13", path = "./rust/lance-test-macros" } +lance-testing = { version = "=4.0.0-beta.13", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.0.0-beta.12", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=4.0.0-beta.13", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.0.0-beta.12", path = "./rust/compression/fsst" } +fsst = { version = "=4.0.0-beta.13", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index e9e3c42fead..7eddd067d08 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -286,7 +286,7 @@ dependencies = [ "arrow-schema", "arrow-select", "flatbuffers", - "lz4_flex 0.12.0", + "lz4_flex 0.12.1", "zstd", ] @@ -996,9 +996,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d13a61f2963b88eef9c1be03df65d42f6996dfeac1054870d950fcf66686f83" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" dependencies = [ "bon-macros", "rustversion", @@ -1006,9 +1006,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d314cc62af2b6b0c65780555abb4d02a03dd3b799cd42419044f0c38d99738c0" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ "darling", "ident_case", @@ -1085,9 +1085,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3353,7 +3353,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-arith", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "arrow-buffer", @@ -3439,7 +3439,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrayref", "paste", @@ -3448,7 +3448,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "arrow-buffer", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -3518,7 +3518,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -3536,7 +3536,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-arith", "arrow-array", @@ -3573,7 +3573,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-arith", "arrow-array", @@ -3605,7 +3605,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "datafusion", "geo-traits", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-arith", @@ -3687,7 +3687,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-arith", @@ -3728,7 +3728,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-schema", @@ -3762,7 +3762,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "arrow-buffer", @@ -3778,19 +3778,20 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "async-trait", "bytes", "lance-core", "lance-namespace-reqwest-client", + "serde", "snafu", ] [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-ipc", @@ -3821,9 +3822,9 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad4c947349acd6e37e984eba0254588bd894e6128434338b9e6904e56fb4633" +checksum = "ee2e48de899e2931afb67fcddd0a08e439bf5d8b6ea2a2ed9cb8f4df669bd5cc" dependencies = [ "reqwest", "serde", @@ -3834,7 +3835,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -4050,15 +4051,15 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" [[package]] name = "lz4_flex" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e" +checksum = "98c23545df7ecf1b16c303910a69b079e8e251d60f7dd2cc9b4177f2afaf1746" dependencies = [ "twox-hash", ] @@ -4332,9 +4333,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -4342,9 +4343,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4560,7 +4561,7 @@ dependencies = [ "futures", "half", "hashbrown 0.16.1", - "lz4_flex 0.12.0", + "lz4_flex 0.12.1", "num-bigint", "num-integer", "num-traits", @@ -4785,9 +4786,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -6036,7 +6037,7 @@ dependencies = [ "levenshtein_automata", "log", "lru", - "lz4_flex 0.11.5", + "lz4_flex 0.11.6", "measure_time", "memmap2", "once_cell", @@ -6297,9 +6298,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -6374,18 +6375,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap", "toml_datetime", @@ -6395,9 +6396,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ "winnow", ] @@ -7209,9 +7210,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index e839028d5f0..4431d028eff 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 50cf798f184..2afdbbfdefa 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.0.0-beta.12 + 4.0.0-beta.13 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index c73e287b07c..abc746d2d58 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -350,7 +350,7 @@ dependencies = [ "arrow-schema", "arrow-select", "flatbuffers", - "lz4_flex 0.12.0", + "lz4_flex 0.12.1", "zstd", ] @@ -1152,9 +1152,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d13a61f2963b88eef9c1be03df65d42f6996dfeac1054870d950fcf66686f83" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" dependencies = [ "bon-macros", "rustversion", @@ -1162,9 +1162,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d314cc62af2b6b0c65780555abb4d02a03dd3b799cd42419044f0c38d99738c0" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ "darling 0.23.0", "ident_case", @@ -1250,9 +1250,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -2506,7 +2506,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2681,7 +2681,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2824,7 +2824,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3456,7 +3456,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -3654,36 +3654,32 @@ dependencies = [ [[package]] name = "include-flate" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01b7cb6ca682a621e7cda1c358c9724b53a7b4409be9be1dd443b7f3a26f998" +checksum = "8a05fb00d9abc625268e0573a519506b264a7d6965de09bac13201bfb44e723d" dependencies = [ "include-flate-codegen", "include-flate-compress", - "libflate", - "zstd", ] [[package]] name = "include-flate-codegen" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f49bf5274aebe468d6e6eba14a977eaf1efa481dc173f361020de70c1c48050" +checksum = "92c3c319a7527668538a8530c541e74e881e94c4f41e1425622d0a41c16468af" dependencies = [ "include-flate-compress", - "libflate", - "proc-macro-error", + "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.117", - "zstd", ] [[package]] name = "include-flate-compress" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae6a40e716bcd5931f5dbb79cd921512a4f647e2e9413fded3171fca3824dbc" +checksum = "ed0bd9ea81b94169d61c5a397e9faef02153d3711fc62d3270bcde3ac85380d9" dependencies = [ "libflate", "zstd", @@ -3816,7 +3812,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3911,7 +3907,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-arith", @@ -3978,7 +3974,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "arrow-buffer", @@ -3998,7 +3994,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrayref", "paste", @@ -4007,7 +4003,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "arrow-buffer", @@ -4044,7 +4040,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -4077,7 +4073,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -4095,7 +4091,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-arith", "arrow-array", @@ -4132,7 +4128,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-arith", "arrow-array", @@ -4164,7 +4160,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "datafusion", "geo-traits", @@ -4178,7 +4174,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-arith", @@ -4249,7 +4245,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-arith", @@ -4290,7 +4286,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow-array", "arrow-buffer", @@ -4306,7 +4302,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "async-trait", @@ -4319,7 +4315,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-ipc", @@ -4363,7 +4359,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -4779,15 +4775,15 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" [[package]] name = "lz4_flex" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e" +checksum = "98c23545df7ecf1b16c303910a69b079e8e251d60f7dd2cc9b4177f2afaf1746" dependencies = [ "twox-hash", ] @@ -4990,7 +4986,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5076,9 +5072,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -5086,9 +5082,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -5313,7 +5309,7 @@ dependencies = [ "futures", "half", "hashbrown 0.16.1", - "lz4_flex 0.12.0", + "lz4_flex 0.12.1", "num-bigint", "num-integer", "num-traits", @@ -5583,9 +5579,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -5634,27 +5630,25 @@ dependencies = [ ] [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "proc-macro-error-attr2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ - "proc-macro-error-attr", "proc-macro2", "quote", - "syn 1.0.109", - "version_check", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "proc-macro-error2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ + "proc-macro-error-attr2", "proc-macro2", "quote", - "version_check", + "syn 2.0.117", ] [[package]] @@ -5729,7 +5723,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" dependencies = [ "arrow", "arrow-array", @@ -5883,7 +5877,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.37", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -5920,9 +5914,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6392,7 +6386,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7112,7 +7106,7 @@ dependencies = [ "levenshtein_automata", "log", "lru", - "lz4_flex 0.11.5", + "lz4_flex 0.11.6", "measure_time", "memmap2", "once_cell", @@ -7266,7 +7260,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7390,9 +7384,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -7477,18 +7471,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap", "toml_datetime", @@ -7498,9 +7492,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ "winnow", ] @@ -8082,7 +8076,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8156,7 +8150,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]] @@ -8165,7 +8159,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]] @@ -8183,14 +8186,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]] @@ -8199,53 +8219,101 @@ 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" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] diff --git a/python/Cargo.toml b/python/Cargo.toml index 0d5a3c23e80..0827df30ed5 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.0.0-beta.12" +version = "4.0.0-beta.13" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 674ccce5f32acd855779fff0991a86fa0d27b749 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 20 Mar 2026 04:43:53 +0800 Subject: [PATCH 050/206] docs: remove legacy preview index note (#6218) This removes the outdated note that pointed users to the legacy `lancedb/pylance` preview index from our main installation docs. The transition period is over, so keeping that note in the README and quickstart now adds confusion to the current install path. --- README.md | 3 --- docs/src/quickstart/index.md | 2 -- 2 files changed, 5 deletions(-) diff --git a/README.md b/README.md index 6eeea984113..edef967fb0f 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,6 @@ To install a preview release: pip install --pre --extra-index-url https://pypi.fury.io/lance-format/pylance ``` -> [!NOTE] -> For versions prior to 1.0.0-beta.4, you can find them at https://pypi.fury.io/lancedb/pylance - > [!TIP] > Preview releases are released more often than full releases and contain the > latest features and bug fixes. They receive the same level of testing as full releases. diff --git a/docs/src/quickstart/index.md b/docs/src/quickstart/index.md index 6f2c9a8d147..00daa6a4ee4 100644 --- a/docs/src/quickstart/index.md +++ b/docs/src/quickstart/index.md @@ -25,8 +25,6 @@ pip install --pre --extra-index-url https://pypi.fury.io/lance-format/pylance > Note: Preview releases receive the same level of testing as regular releases. -> Note: For versions prior to 1.0.0-beta.4, you can find them at https://pypi.fury.io/lancedb/pylance - ## Set Up Your Environment First, let's import the necessary libraries: From 15e16cd9ce61cad3688eaa758e9d5400977b9f89 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Thu, 19 Mar 2026 21:05:26 +0000 Subject: [PATCH 051/206] chore: bump main to 4.1.0-beta.0 Unreleased version after creating v4.0.0-rc.1 --- .bumpversion.toml | 2 +- Cargo.lock | 58 +++++++++++++++++++-------------------- Cargo.toml | 40 +++++++++++++-------------- java/lance-jni/Cargo.lock | 50 ++++++++++++++++----------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 50 ++++++++++++++++----------------- python/Cargo.toml | 2 +- 8 files changed, 103 insertions(+), 103 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 0adce97afe1..28f95e5cdef 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.0.0-beta.13" +current_version = "4.1.0-beta.0" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index d76001c035e..d190505f6ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -660,9 +660,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "zeroize", @@ -670,9 +670,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -3451,7 +3451,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "lance-datagen", @@ -4739,7 +4739,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "all_asserts", "approx", @@ -4834,7 +4834,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -4870,7 +4870,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrayref", "paste", @@ -4879,7 +4879,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -4919,7 +4919,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -4953,7 +4953,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -5020,7 +5020,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "all_asserts", "arrow", @@ -5046,7 +5046,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -5089,7 +5089,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "datafusion", "geo-traits", @@ -5103,7 +5103,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "approx", "arrow", @@ -5183,7 +5183,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -5231,7 +5231,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "approx", "arrow-array", @@ -5252,7 +5252,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "async-trait", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -5285,7 +5285,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-ipc", @@ -5343,7 +5343,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -5389,7 +5389,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "proc-macro2", "quote", @@ -5398,7 +5398,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "arrow-schema", @@ -5409,7 +5409,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "clap", "lance-core", @@ -10272,18 +10272,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 051b2649341..6b5f1c88fa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.0.0-beta.13", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.0.0-beta.13", path = "./rust/lance-arrow" } -lance-core = { version = "=4.0.0-beta.13", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.0.0-beta.13", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.0.0-beta.13", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.0.0-beta.13", path = "./rust/lance-encoding" } -lance-file = { version = "=4.0.0-beta.13", path = "./rust/lance-file" } -lance-geo = { version = "=4.0.0-beta.13", path = "./rust/lance-geo" } -lance-index = { version = "=4.0.0-beta.13", path = "./rust/lance-index" } -lance-io = { version = "=4.0.0-beta.13", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.0.0-beta.13", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.0.0-beta.13", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.0.0-beta.13", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.0.0-beta.13", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=4.1.0-beta.0", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=4.1.0-beta.0", path = "./rust/lance-arrow" } +lance-core = { version = "=4.1.0-beta.0", path = "./rust/lance-core" } +lance-datafusion = { version = "=4.1.0-beta.0", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=4.1.0-beta.0", path = "./rust/lance-datagen" } +lance-encoding = { version = "=4.1.0-beta.0", path = "./rust/lance-encoding" } +lance-file = { version = "=4.1.0-beta.0", path = "./rust/lance-file" } +lance-geo = { version = "=4.1.0-beta.0", path = "./rust/lance-geo" } +lance-index = { version = "=4.1.0-beta.0", path = "./rust/lance-index" } +lance-io = { version = "=4.1.0-beta.0", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=4.1.0-beta.0", path = "./rust/lance-linalg" } +lance-namespace = { version = "=4.1.0-beta.0", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=4.1.0-beta.0", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=4.1.0-beta.0", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=4.0.0-beta.13", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.0.0-beta.13", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.0.0-beta.13", path = "./rust/lance-testing" } +lance-table = { version = "=4.1.0-beta.0", path = "./rust/lance-table" } +lance-test-macros = { version = "=4.1.0-beta.0", path = "./rust/lance-test-macros" } +lance-testing = { version = "=4.1.0-beta.0", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.0.0-beta.13", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=4.1.0-beta.0", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.0.0-beta.13", path = "./rust/compression/fsst" } +fsst = { version = "=4.1.0-beta.0", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 7eddd067d08..c67bcf496c1 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -513,9 +513,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "zeroize", @@ -523,9 +523,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3353,7 +3353,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -3439,7 +3439,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrayref", "paste", @@ -3448,7 +3448,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -3518,7 +3518,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -3536,7 +3536,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -3573,7 +3573,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -3605,7 +3605,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "datafusion", "geo-traits", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -3687,7 +3687,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -3728,7 +3728,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-schema", @@ -3762,7 +3762,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -3778,7 +3778,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "async-trait", @@ -3791,7 +3791,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-ipc", @@ -3835,7 +3835,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -7382,18 +7382,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 4431d028eff..99883d00d8e 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 2afdbbfdefa..3d62ad35aa3 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.0.0-beta.13 + 4.1.0-beta.0 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index abc746d2d58..32f0565a1c4 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -610,9 +610,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "zeroize", @@ -620,9 +620,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -2824,7 +2824,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3907,7 +3907,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -3974,7 +3974,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -3994,7 +3994,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrayref", "paste", @@ -4003,7 +4003,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -4040,7 +4040,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -4073,7 +4073,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -4128,7 +4128,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "datafusion", "geo-traits", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -4245,7 +4245,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -4286,7 +4286,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -4302,7 +4302,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "async-trait", @@ -4315,7 +4315,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-ipc", @@ -4359,7 +4359,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -5723,7 +5723,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -8499,18 +8499,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/python/Cargo.toml b/python/Cargo.toml index 0827df30ed5..51f1f6646cb 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.0.0-beta.13" +version = "4.1.0-beta.0" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 42581d40b7c27d25bdb1b3d60c237bf52ebef538 Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Fri, 20 Mar 2026 16:49:35 +0800 Subject: [PATCH 052/206] perf: new layout for positions and new algo for phrase query (#6203) This stores all positions of single posting list in a flat array, and apply compression to the positions: - for positions of single document, storing delta positions - divide positions of single posting list into blocks (128 by default) and apply bitpack to each block - store the remaining positions in varint This also stores the remaining doc ids / frequencies in varint as well. These are breaking changes. on 1M random dataset: - build is 9.35x faster (34.269s -> 3.664s) - index is 8.87x smaller (955,002,412 bytes -> 107,603,319 bytes) - phrase query avg latency is 3.41x faster (21.631ms -> 6.334ms) and p95 latency is 3.17x faster (63.735ms -> 20.100ms) the improvement can be more significant on larger dataset --------- Signed-off-by: BubbleCal --- Cargo.lock | 2 + .../python/tests/compat/compat_decorator.py | 67 +- .../tests/compat/test_scalar_indices.py | 21 +- python/python/tests/compat/venv_manager.py | 11 +- python/python/tests/compat/venv_runner.py | 32 +- python/python/tests/test_scalar_index.py | 6 +- rust/lance-index/Cargo.toml | 1 + rust/lance-index/benches/inverted.rs | 108 +- rust/lance-index/src/scalar.rs | 1 + rust/lance-index/src/scalar/inverted.rs | 18 +- .../src/scalar/inverted/builder.rs | 694 +++++-- .../src/scalar/inverted/encoding.rs | 596 +++++- rust/lance-index/src/scalar/inverted/index.rs | 1803 ++++++++++++++--- rust/lance-index/src/scalar/inverted/iter.rs | 118 +- rust/lance-index/src/scalar/inverted/wand.rs | 367 +++- rust/lance-index/src/scalar/lance_format.rs | 6 +- rust/lance/Cargo.toml | 1 + .../src/dataset/mem_wal/memtable/flush.rs | 4 +- 18 files changed, 3325 insertions(+), 531 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d190505f6ce..906d615db25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4812,6 +4812,7 @@ dependencies = [ "prost", "prost-types", "rand 0.9.2", + "rand_distr 0.5.1", "roaring", "rstest", "semver", @@ -5150,6 +5151,7 @@ dependencies = [ "lance-linalg", "lance-table", "lance-testing", + "libc", "libm", "lindera", "lindera-tantivy", diff --git a/python/python/tests/compat/compat_decorator.py b/python/python/tests/compat/compat_decorator.py index cb9aa10c573..0ab35672410 100644 --- a/python/python/tests/compat/compat_decorator.py +++ b/python/python/tests/compat/compat_decorator.py @@ -10,11 +10,13 @@ import inspect import json +import os import subprocess import sys import urllib.request +from contextlib import contextmanager from functools import lru_cache -from typing import List +from typing import Dict, List, Optional import pytest from packaging.version import Version @@ -147,6 +149,42 @@ def check_read(self): def check_write(self): pass + def skip_read_after_current_write(self, version: str) -> bool: + """Return True to skip the old-version read after current-version writes.""" + return False + + def skip_downgrade(self, version: str) -> bool: + """Return True to skip the current-write -> old-read downgrade test.""" + return False + + def current_env(self, method_name: str) -> Dict[str, str]: + """Return environment overrides for methods executed in the current runtime.""" + return {} + + def compat_env(self, version: str, method_name: str) -> Dict[str, str]: + """Return environment overrides for methods executed in a compat venv.""" + return {} + + +@contextmanager +def _temporary_env(overrides: Optional[Dict[str, str]]): + if not overrides: + yield + return + + sentinel = object() + old_values = {key: os.environ.get(key, sentinel) for key in overrides} + try: + for key, value in overrides.items(): + os.environ[key] = value + yield + finally: + for key, value in old_values.items(): + if value is sentinel: + os.environ.pop(key, None) + else: + os.environ[key] = value + def compat_test(min_version: str = "0.16.0"): """Decorator to generate upgrade/downgrade compatibility tests. @@ -285,12 +323,18 @@ def test_func({sig_params}): """Test that old Lance version can read data written by current version.""" from pathlib import Path obj = cls(tmp_path / "data.lance", {init_params}) + obj.compat_version = version + if obj.skip_downgrade(version): + pytest.skip( + "downgrade compatibility is intentionally unsupported for this test" + ) # Current version: create data - obj.create() + with _temporary_env(obj.current_env("create")): + obj.create() # Old version: verify can read venv = venv_factory.get_venv(version) - venv.execute_method(obj, "check_read") - venv.execute_method(obj, "check_write") + venv.execute_method(obj, "check_read", obj.compat_env(version, "check_read")) + venv.execute_method(obj, "check_write", obj.compat_env(version, "check_write")) ''' else: # upgrade_downgrade func_body = f''' @@ -298,18 +342,21 @@ def test_func({sig_params}): """Test round-trip compatibility: old -> current -> old.""" from pathlib import Path obj = cls(tmp_path / "data.lance", {init_params}) + obj.compat_version = version venv = venv_factory.get_venv(version) # Old version: create data - venv.execute_method(obj, "create") + venv.execute_method(obj, "create", obj.compat_env(version, "create")) # Current version: read and write - obj.check_read() - obj.check_write() + with _temporary_env(obj.current_env("check_read")): + obj.check_read() + with _temporary_env(obj.current_env("check_write")): + obj.check_write() # Old version: verify can still read - venv.execute_method(obj, "check_read") - venv.execute_method(obj, "check_write") + venv.execute_method(obj, "check_read", obj.compat_env(version, "check_read")) + venv.execute_method(obj, "check_write", obj.compat_env(version, "check_write")) ''' # Execute to create the function - namespace = {"cls": cls} + namespace = {"cls": cls, "_temporary_env": _temporary_env, "pytest": pytest} exec(func_body, namespace) return namespace["test_func"] diff --git a/python/python/tests/compat/test_scalar_indices.py b/python/python/tests/compat/test_scalar_indices.py index 5d42a837bdc..08222108805 100644 --- a/python/python/tests/compat/test_scalar_indices.py +++ b/python/python/tests/compat/test_scalar_indices.py @@ -289,15 +289,16 @@ def create(self): } ) dataset = lance.write_dataset(data, self.path, max_rows_per_file=100) - dataset.create_scalar_index("text", "INVERTED") + dataset.create_scalar_index("text", "INVERTED", with_position=True) def check_read(self): """Verify FTS index can be queried.""" ds = lance.dataset(self.path) - # Search for documents containing "words" and "7" - # Note: Actual FTS query syntax may vary - table = ds.to_table(filter="text LIKE '%words 7 %'") - assert table.num_rows > 0 + match_table = ds.to_table( + full_text_query={"query": "words 7", "columns": ["text"]} + ) + assert match_table.num_rows > 0 + assert 7 in match_table.column("idx").to_pylist() def check_write(self): """Verify can insert data with FTS index.""" @@ -313,3 +314,13 @@ def check_write(self): ) ds.insert(data) ds.optimize.compact_files() + + def skip_downgrade(self, version: str) -> bool: + return version.startswith("0.") + + def current_env(self, method_name: str) -> dict[str, str]: + if method_name == "create": + return {"LANCE_FTS_FORMAT_VERSION": "1"} + if method_name == "check_write": + return {"LANCE_FTS_FORMAT_VERSION": "2"} + return {} diff --git a/python/python/tests/compat/venv_manager.py b/python/python/tests/compat/venv_manager.py index 996422aea34..a5e52bbc3fd 100644 --- a/python/python/tests/compat/venv_manager.py +++ b/python/python/tests/compat/venv_manager.py @@ -162,7 +162,12 @@ def _receive_message(self) -> Any: return pickle.loads(data) - def execute_method(self, obj: Any, method_name: str) -> Any: + def execute_method( + self, + obj: Any, + method_name: str, + env_overrides: Optional[dict[str, str]] = None, + ) -> Any: """ Execute a method on a pickled object in the virtual environment. @@ -192,8 +197,8 @@ def execute_method(self, obj: Any, method_name: str) -> Any: # Ensure subprocess is running self._ensure_subprocess() try: - # Send request: (obj, method_name) - self._send_message((obj, method_name)) + # Send request: (obj, method_name, env_overrides) + self._send_message((obj, method_name, env_overrides or {})) # Receive response response = self._receive_message() diff --git a/python/python/tests/compat/venv_runner.py b/python/python/tests/compat/venv_runner.py index ce7b3c3de77..8ad7f06ba21 100644 --- a/python/python/tests/compat/venv_runner.py +++ b/python/python/tests/compat/venv_runner.py @@ -22,6 +22,7 @@ import sys import time import traceback +from contextlib import contextmanager # Enable detailed timing output with DEBUG=1 DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes") @@ -55,17 +56,41 @@ def write_message(stream, obj): stream.buffer.flush() +@contextmanager +def temporary_env(overrides): + if not overrides: + yield + return + + sentinel = object() + old_values = {key: os.environ.get(key, sentinel) for key in overrides} + try: + for key, value in overrides.items(): + os.environ[key] = value + yield + finally: + for key, value in old_values.items(): + if value is sentinel: + os.environ.pop(key, None) + else: + os.environ[key] = value + + def main(): """Main loop that processes method calls until EOF.""" while True: try: - # Read request (obj, method_name) + # Read request (obj, method_name, env_overrides) request = read_message(sys.stdin) if request is None: # EOF - parent closed connection break - obj, method_name = request + if len(request) == 2: + obj, method_name = request + env_overrides = {} + else: + obj, method_name, env_overrides = request # Execute method with timing start_time = time.time() @@ -77,7 +102,8 @@ def main(): ) method = getattr(obj, method_name) - result = method() + with temporary_env(env_overrides): + result = method() if DEBUG: exec_time = time.time() - start_time diff --git a/python/python/tests/test_scalar_index.py b/python/python/tests/test_scalar_index.py index 24b32e54d81..884079ccec6 100644 --- a/python/python/tests/test_scalar_index.py +++ b/python/python/tests/test_scalar_index.py @@ -4261,7 +4261,9 @@ def test_json_inverted_match_query(tmp_path): assert results.num_rows == 1 -def test_describe_indices(tmp_path): +@pytest.mark.parametrize("fts_format_version", ["1", "2"]) +def test_describe_indices(tmp_path, monkeypatch, fts_format_version): + monkeypatch.setenv("LANCE_FTS_FORMAT_VERSION", fts_format_version) data = pa.table( { "id": range(100), @@ -4291,7 +4293,7 @@ def test_describe_indices(tmp_path): assert indices[0].segments[0].uuid is not None assert indices[0].segments[0].fragment_ids == {0} assert indices[0].segments[0].dataset_version_at_last_update == 1 - assert indices[0].segments[0].index_version == 1 + assert indices[0].segments[0].index_version == int(fts_format_version) assert indices[0].segments[0].created_at is not None assert isinstance(indices[0].segments[0].created_at, datetime) assert indices[0].segments[0].size_bytes is not None diff --git a/rust/lance-index/Cargo.toml b/rust/lance-index/Cargo.toml index 7a6190688f7..c79760f0f07 100644 --- a/rust/lance-index/Cargo.toml +++ b/rust/lance-index/Cargo.toml @@ -82,6 +82,7 @@ approx.workspace = true criterion.workspace = true env_logger = "0.11.6" geo-traits.workspace = true +libc.workspace = true lance-datagen.workspace = true lance-testing.workspace = true test-log.workspace = true diff --git a/rust/lance-index/benches/inverted.rs b/rust/lance-index/benches/inverted.rs index d43e3c001af..02746339bf2 100644 --- a/rust/lance-index/benches/inverted.rs +++ b/rust/lance-index/benches/inverted.rs @@ -34,15 +34,22 @@ fn bench_inverted(c: &mut Criterion) { let rt = tokio::runtime::Builder::new_multi_thread().build().unwrap(); - let tempdir = tempfile::tempdir().unwrap(); - let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap(); - let store = rt.block_on(async { - Arc::new(LanceIndexStore::new( - Arc::new(ObjectStore::local()), - index_dir, - Arc::new(LanceCache::no_cache()), - )) - }); + let make_store = |path: &std::path::Path| { + let index_dir = Path::from_filesystem_path(path).unwrap(); + rt.block_on(async { + Arc::new(LanceIndexStore::new( + Arc::new(ObjectStore::local()), + index_dir, + Arc::new(LanceCache::no_cache()), + )) + }) + }; + let indexing_tempdir = tempfile::tempdir().unwrap(); + let indexing_store = make_store(indexing_tempdir.path()); + let indexing_with_positions_tempdir = tempfile::tempdir().unwrap(); + let indexing_with_positions_store = make_store(indexing_with_positions_tempdir.path()); + let phrase_search_tempdir = tempfile::tempdir().unwrap(); + let phrase_search_store = make_store(phrase_search_tempdir.path()); let row_id_col = Arc::new(UInt64Array::from( (0..TOTAL).map(|i| i as u64).collect_vec(), @@ -90,13 +97,54 @@ fn bench_inverted(c: &mut Criterion) { let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default().with_position(false)); black_box({ - builder.update(stream, store.as_ref()).await.unwrap(); + builder + .update(stream, indexing_store.as_ref()) + .await + .unwrap(); builder }); }) }); + + c.bench_function( + format!("invert_indexing_with_positions({TOTAL})").as_str(), + |b| { + b.to_async(&rt).iter(|| async { + let stream = RecordBatchStreamAdapter::new( + batch.schema(), + stream::iter(vec![Ok(batch.clone())]), + ); + let stream = Box::pin(stream); + let mut builder = + InvertedIndexBuilder::new(InvertedIndexParams::default().with_position(true)); + black_box({ + builder + .update(stream, indexing_with_positions_store.as_ref()) + .await + .unwrap(); + builder + }); + }) + }, + ); + + rt.block_on(async { + let stream = + RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch.clone())])); + let stream = Box::pin(stream); + let mut builder = + InvertedIndexBuilder::new(InvertedIndexParams::default().with_position(true)); + builder + .update(stream, phrase_search_store.as_ref()) + .await + .unwrap(); + }); let invert_index = rt - .block_on(InvertedIndex::load(store, None, &LanceCache::no_cache())) + .block_on(InvertedIndex::load( + phrase_search_store, + None, + &LanceCache::no_cache(), + )) .unwrap(); let params = FtsSearchParams::new().with_limit(Some(10)); @@ -147,6 +195,44 @@ fn bench_inverted(c: &mut Criterion) { } }) }); + + let phrase_params = FtsSearchParams::new() + .with_limit(Some(10)) + .with_phrase_slop(Some(0)); + let phrase_pairs = sample_words + .windows(2) + .map(|pair| { + Arc::new(Tokens::new( + pair.iter().map(|s| s.to_string()).collect(), + DocType::Text, + )) + }) + .collect_vec(); + let mut phrase_query_idx = 0usize; + + c.bench_function(format!("invert_phrase_search({TOTAL})").as_str(), |b| { + b.to_async(&rt).iter(|| { + let query = phrase_pairs[phrase_query_idx % phrase_pairs.len()].clone(); + phrase_query_idx = phrase_query_idx.wrapping_add(1); + let invert_index = invert_index.clone(); + let params = phrase_params.clone(); + let no_filter = no_filter.clone(); + async move { + black_box( + invert_index + .bm25_search( + query, + params.clone().into(), + Operator::And, + no_filter.clone(), + Arc::new(NoOpMetricsCollector), + ) + .await + .unwrap(), + ); + } + }) + }); } #[cfg(target_os = "linux")] diff --git a/rust/lance-index/src/scalar.rs b/rust/lance-index/src/scalar.rs index a8abdee7a40..531c650c700 100644 --- a/rust/lance-index/src/scalar.rs +++ b/rust/lance-index/src/scalar.rs @@ -209,6 +209,7 @@ pub trait IndexReader: Send + Sync { #[async_trait] pub trait IndexStore: std::fmt::Debug + Send + Sync + DeepSizeOf { fn as_any(&self) -> &dyn Any; + fn clone_arc(&self) -> Arc; /// Suggested I/O parallelism for the store fn io_parallelism(&self) -> usize; diff --git a/rust/lance-index/src/scalar/inverted.rs b/rust/lance-index/src/scalar/inverted.rs index ba02546b8a9..8435e1154e1 100644 --- a/rust/lance-index/src/scalar/inverted.rs +++ b/rust/lance-index/src/scalar/inverted.rs @@ -68,7 +68,7 @@ impl InvertedIndexPlugin { inverted_index.update(data, index_store).await?; Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&details).unwrap(), - index_version: INVERTED_INDEX_VERSION, + index_version: current_fts_format_version().index_version(), files: Some(index_store.list_files_with_sizes().await?), }) } @@ -138,7 +138,7 @@ impl ScalarIndexPlugin for InvertedIndexPlugin { } fn version(&self) -> u32 { - INVERTED_INDEX_VERSION + max_supported_fts_format_version().index_version() } fn new_query_parser( @@ -215,3 +215,17 @@ impl ScalarIndexPlugin for InvertedIndexPlugin { Ok(serde_json::json!(&index_params)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_version_tracks_max_supported_format() { + let plugin = InvertedIndexPlugin; + assert_eq!( + plugin.version(), + max_supported_fts_format_version().index_version() + ); + } +} diff --git a/rust/lance-index/src/scalar/inverted/builder.rs b/rust/lance-index/src/scalar/inverted/builder.rs index 764ac65345e..6eb8575e5ab 100644 --- a/rust/lance-index/src/scalar/inverted/builder.rs +++ b/rust/lance-index/src/scalar/inverted/builder.rs @@ -70,6 +70,7 @@ static LANCE_FTS_POSTING_BATCH_ROWS: LazyLock = LazyLock::new(|| { .parse() .expect("failed to parse LANCE_FTS_POSTING_BATCH_ROWS") }); +const MAX_RETAINED_TOKEN_IDS: usize = 8 * 1024; fn default_num_workers() -> usize { let total_cpus = get_num_compute_intensive_cpus() + *IO_CORE_RESERVATION; @@ -120,6 +121,8 @@ pub struct InvertedIndexBuilder { new_partitions: Vec, fragment_mask: Option, token_set_format: TokenSetFormat, + format_version: InvertedListFormatVersion, + posting_tail_codec: PostingTailCodec, src_store: Option>, progress: Arc, } @@ -159,10 +162,25 @@ impl InvertedIndexBuilder { src_store: store, token_set_format, fragment_mask, + format_version: current_fts_format_version(), + posting_tail_codec: current_fts_format_version().posting_tail_codec(), progress: noop_progress(), } } + pub fn with_posting_tail_codec(mut self, posting_tail_codec: PostingTailCodec) -> Self { + self.format_version = + InvertedListFormatVersion::from_posting_tail_codec(posting_tail_codec); + self.posting_tail_codec = posting_tail_codec; + self + } + + pub fn with_format_version(mut self, format_version: InvertedListFormatVersion) -> Self { + self.format_version = format_version; + self.posting_tail_codec = format_version.posting_tail_codec(); + self + } + pub fn with_progress(mut self, progress: Arc) -> Self { self.progress = progress; self @@ -206,32 +224,29 @@ impl InvertedIndexBuilder { let with_position = self.params.with_position; let worker_memory_limit_bytes = resolve_worker_memory_limit_bytes(&self.params, num_workers); + let worker_config = IndexWorkerConfig { + with_position, + format_version: self.format_version, + fragment_mask: self.fragment_mask, + token_set_format: self.token_set_format, + worker_memory_limit_bytes, + }; let next_id = self.partitions.iter().map(|id| id + 1).max().unwrap_or(0); let id_alloc = Arc::new(AtomicU64::new(next_id)); let tokenized_count = Arc::new(AtomicU64::new(0)); let (sender, receiver) = async_channel::bounded(num_workers); - let (builder_tx, builder_rx) = async_channel::bounded::(1); + let dest_store = dest_store.clone_arc(); let mut index_tasks = Vec::with_capacity(num_workers); for _ in 0..num_workers { let tokenizer = tokenizer.clone(); let receiver: async_channel::Receiver = receiver.clone(); - let builder_tx = builder_tx.clone(); + let dest_store = dest_store.clone(); let id_alloc = id_alloc.clone(); let progress = self.progress.clone(); - let fragment_mask = self.fragment_mask; - let token_set_format = self.token_set_format; let tokenized_count = tokenized_count.clone(); index_tasks.push(tokio::task::spawn(async move { - let mut worker = IndexWorker::new( - tokenizer, - builder_tx, - with_position, - id_alloc, - fragment_mask, - token_set_format, - worker_memory_limit_bytes, - ) - .await?; + let mut worker = + IndexWorker::new(tokenizer, dest_store, id_alloc, worker_config).await?; while let Ok(batch) = receiver.recv().await { let num_rows = batch.num_rows(); worker.process_batch(batch).await?; @@ -245,12 +260,6 @@ impl InvertedIndexBuilder { worker.finish().await })); } - let writer = async { - while let Ok(mut builder) = builder_rx.recv().await { - builder.write(dest_store).await?; - } - Result::Ok(()) - }; let index_build = async { // Keep the channel lifetime tied to the worker tasks so senders observe @@ -303,19 +312,14 @@ impl InvertedIndexBuilder { spawn_cpu(move || merge_all_tail_partitions(tail_partitions)).await?; if let Some(builder) = merged_tail_partitions { self.new_partitions.push(builder.id()); - builder_tx.send(builder).await.map_err(|err| { - Error::execution(format!( - "failed to send merged tail partition to writer: {err}" - )) - })?; + let mut builder = builder; + builder.write(dest_store.as_ref()).await?; } - drop(builder_tx); log::info!("wait workers indexing elapsed: {:?}", start.elapsed()); Result::Ok(()) }; - let (_, _) = tokio::try_join!(writer, index_build)?; - Ok(()) + index_build.await } pub async fn remap( @@ -349,14 +353,32 @@ impl InvertedIndexBuilder { } async fn write_metadata(&self, dest_store: &dyn IndexStore, partitions: &[u64]) -> Result<()> { - let metadata = HashMap::from_iter(vec![ + let mut metadata = HashMap::from_iter(vec![ ("partitions".to_owned(), serde_json::to_string(&partitions)?), ("params".to_owned(), serde_json::to_string(&self.params)?), ( TOKEN_SET_FORMAT_KEY.to_owned(), self.token_set_format.to_string(), ), + ( + POSTING_TAIL_CODEC_KEY.to_owned(), + self.posting_tail_codec.as_str().to_owned(), + ), ]); + if self.params.with_position && self.format_version.uses_shared_position_stream() { + metadata.insert( + POSITIONS_LAYOUT_KEY.to_owned(), + POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(), + ); + metadata.insert( + POSITIONS_CODEC_KEY.to_owned(), + self.format_version + .position_codec() + .expect("shared positions require a codec") + .as_str() + .to_owned(), + ); + } let mut writer = dest_store .new_index_file(METADATA_FILE, Arc::new(Schema::empty())) .await?; @@ -374,14 +396,32 @@ impl InvertedIndexBuilder { partition: u64, // Modify parameter type ) -> Result<()> { let partitions = vec![partition]; - let metadata = HashMap::from_iter(vec![ + let mut metadata = HashMap::from_iter(vec![ ("partitions".to_owned(), serde_json::to_string(&partitions)?), ("params".to_owned(), serde_json::to_string(&self.params)?), ( TOKEN_SET_FORMAT_KEY.to_owned(), self.token_set_format.to_string(), ), + ( + POSTING_TAIL_CODEC_KEY.to_owned(), + self.posting_tail_codec.as_str().to_owned(), + ), ]); + if self.params.with_position && self.format_version.uses_shared_position_stream() { + metadata.insert( + POSITIONS_LAYOUT_KEY.to_owned(), + POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(), + ); + metadata.insert( + POSITIONS_CODEC_KEY.to_owned(), + self.format_version + .position_codec() + .expect("shared positions require a codec") + .as_str() + .to_owned(), + ); + } // Use partition ID to generate a unique temporary filename let file_name = part_metadata_file_path(partition); let mut writer = dest_store @@ -483,6 +523,8 @@ pub struct InnerBuilder { id: u64, with_position: bool, token_set_format: TokenSetFormat, + format_version: InvertedListFormatVersion, + posting_tail_codec: PostingTailCodec, pub(crate) tokens: TokenSet, pub(crate) posting_lists: Vec, pub(crate) docs: DocSet, @@ -490,16 +532,49 @@ pub struct InnerBuilder { impl InnerBuilder { pub fn new(id: u64, with_position: bool, token_set_format: TokenSetFormat) -> Self { + Self::new_with_format_version( + id, + with_position, + token_set_format, + current_fts_format_version(), + ) + } + + pub fn new_with_format_version( + id: u64, + with_position: bool, + token_set_format: TokenSetFormat, + format_version: InvertedListFormatVersion, + ) -> Self { Self { id, with_position, token_set_format, + format_version, + posting_tail_codec: format_version.posting_tail_codec(), tokens: TokenSet::default(), posting_lists: Vec::new(), docs: DocSet::default(), } } + pub fn new_with_posting_tail_codec( + id: u64, + with_position: bool, + token_set_format: TokenSetFormat, + posting_tail_codec: PostingTailCodec, + ) -> Self { + let format_version = if posting_tail_codec == PostingTailCodec::Fixed32 { + InvertedListFormatVersion::V1 + } else { + InvertedListFormatVersion::V2 + }; + let mut builder = + Self::new_with_format_version(id, with_position, token_set_format, format_version); + builder.posting_tail_codec = posting_tail_codec; + builder + } + pub fn id(&self) -> u64 { self.id } @@ -550,6 +625,8 @@ impl InnerBuilder { id: _, with_position, token_set_format, + format_version, + posting_tail_codec, tokens, posting_lists, docs, @@ -567,6 +644,18 @@ impl InnerBuilder { self.token_set_format, token_set_format ))); } + if self.format_version != format_version { + return Err(Error::index(format!( + "cannot merge partitions with mismatched FTS format versions: {:?} vs {:?}", + self.format_version, format_version + ))); + } + if self.posting_tail_codec != posting_tail_codec { + return Err(Error::index(format!( + "cannot merge partitions with mismatched posting tail codecs: {:?} vs {:?}", + self.posting_tail_codec, posting_tail_codec + ))); + } let mut token_id_map = vec![u32::MAX; posting_lists.len()]; match tokens.tokens { @@ -591,8 +680,9 @@ impl InnerBuilder { for (row_id, num_tokens) in docs.iter() { self.docs.append(*row_id, *num_tokens); } - self.posting_lists - .resize_with(self.tokens.len(), || PostingListBuilder::new(with_position)); + self.posting_lists.resize_with(self.tokens.len(), || { + PostingListBuilder::new_with_posting_tail_codec(with_position, self.posting_tail_codec) + }); for (token_id, posting_list) in posting_lists.into_iter().enumerate() { if posting_list.is_empty() { @@ -632,7 +722,7 @@ impl InnerBuilder { let mut writer = store .new_index_file( &posting_file_path(self.id), - inverted_list_schema(self.with_position), + inverted_list_schema_for_version(self.with_position, self.format_version), ) .await?; let posting_lists = std::mem::take(&mut self.posting_lists); @@ -644,16 +734,25 @@ impl InnerBuilder { self.with_position ); let with_position = self.with_position; - let schema = inverted_list_schema(self.with_position); + let format_version = self.format_version; + let schema = inverted_list_schema_for_version(self.with_position, self.format_version); let docs_for_batches = docs.clone(); let schema_for_batches = schema.clone(); let batch_rows = *LANCE_FTS_POSTING_BATCH_ROWS; let (tx, rx) = async_channel::bounded(*LANCE_FTS_WRITE_QUEUE_SIZE); let producer = spawn_cpu(move || { - let mut batch_builder = - PostingListBatchBuilder::new(schema_for_batches.clone(), with_position, batch_rows); + let mut batch_builder = PostingListBatchBuilder::new( + schema_for_batches.clone(), + with_position, + format_version, + batch_rows, + ); for posting_list in posting_lists { - posting_list.append_to_batch_with_docs(&docs_for_batches, &mut batch_builder)?; + posting_list.append_to_batch_with_docs( + &docs_for_batches, + &mut batch_builder, + format_version, + )?; if batch_builder.len() < batch_rows { continue; } @@ -721,7 +820,7 @@ impl InnerBuilder { struct IndexWorker { tokenizer: Box, - builder_tx: async_channel::Sender, + dest_store: Arc, id_alloc: Arc, builder: InnerBuilder, partitions: Vec, @@ -731,10 +830,8 @@ struct IndexWorker { total_doc_length: usize, fragment_mask: Option, token_set_format: TokenSetFormat, - token_occurrences: HashMap, token_ids: Vec, last_token_count: usize, - last_unique_token_count: usize, } struct TailPartition { @@ -746,6 +843,15 @@ struct WorkerOutput { tail_partition: Option, } +#[derive(Debug, Clone, Copy)] +struct IndexWorkerConfig { + with_position: bool, + format_version: InvertedListFormatVersion, + fragment_mask: Option, + token_set_format: TokenSetFormat, + worker_memory_limit_bytes: u64, +} + impl IndexWorker { fn posting_lists_overhead_size(&self) -> u64 { (self.builder.posting_lists.capacity() * std::mem::size_of::()) as u64 @@ -771,43 +877,52 @@ impl IndexWorker { } } + fn temporary_memory_size(&self) -> u64 { + (self.token_ids.capacity() * std::mem::size_of::()) as u64 + } + + fn trim_temporary_buffers(&mut self) { + if self.token_ids.capacity() > MAX_RETAINED_TOKEN_IDS { + self.token_ids = Vec::with_capacity(self.last_token_count.min(MAX_RETAINED_TOKEN_IDS)); + } + } + async fn new( tokenizer: Box, - builder_tx: async_channel::Sender, - with_position: bool, + dest_store: Arc, id_alloc: Arc, - fragment_mask: Option, - token_set_format: TokenSetFormat, - worker_memory_limit_bytes: u64, + config: IndexWorkerConfig, ) -> Result { - let schema = inverted_list_schema(with_position); + let schema = inverted_list_schema_for_version(config.with_position, config.format_version); Ok(Self { tokenizer, - builder_tx, - builder: InnerBuilder::new( + dest_store, + builder: InnerBuilder::new_with_format_version( id_alloc.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - | fragment_mask.unwrap_or(0), - with_position, - token_set_format, + | config.fragment_mask.unwrap_or(0), + config.with_position, + config.token_set_format, + config.format_version, ), partitions: Vec::new(), id_alloc, schema, memory_size: 0, - worker_memory_limit_bytes, + worker_memory_limit_bytes: config.worker_memory_limit_bytes, total_doc_length: 0, - fragment_mask, - token_set_format, - token_occurrences: HashMap::new(), + fragment_mask: config.fragment_mask, + token_set_format: config.token_set_format, token_ids: Vec::new(), last_token_count: 0, - last_unique_token_count: 0, }) } fn has_position(&self) -> bool { - self.schema.column_with_name(POSITION_COL).is_some() + self.schema + .column_with_name(COMPRESSED_POSITION_COL) + .is_some() + || self.schema.column_with_name(POSITION_COL).is_some() } async fn process_batch(&mut self, batch: RecordBatch) -> Result<()> { @@ -821,24 +936,54 @@ impl IndexWorker { let with_position = self.has_position(); for (doc, row_id) in docs { let builder_was_empty = self.builder.docs.is_empty(); + let old_temporary_memory_size = self.temporary_memory_size(); let old_token_memory_size = self.builder.tokens.memory_size() as u64; + let doc_id = self.builder.docs.len() as u32; let mut token_num: u32 = 0; + let mut posting_memory_delta = 0i64; if with_position { - if self.token_occurrences.capacity() < self.last_unique_token_count { - self.token_occurrences - .reserve(self.last_unique_token_count - self.token_occurrences.capacity()); + if self.token_ids.capacity() < self.last_token_count { + self.token_ids + .reserve(self.last_token_count - self.token_ids.capacity()); } - self.token_occurrences.clear(); + self.token_ids.clear(); + let builder = &mut self.builder; + let token_ids = &mut self.token_ids; + let memory_size = &mut self.memory_size; + let posting_tail_codec = builder.posting_tail_codec; let mut token_stream = self.tokenizer.token_stream_for_doc(doc); while token_stream.advance() { let token = token_stream.token_mut(); let token_text = std::mem::take(&mut token.text); - let token_id = self.builder.tokens.add(token_text); - self.token_occurrences - .entry(token_id) - .or_insert_with(|| PositionRecorder::new(true)) - .push(token.position as u32); + let token_id = builder.tokens.add(token_text); + if token_id as usize == builder.posting_lists.len() { + let old_posting_lists_overhead_size = (builder.posting_lists.capacity() + * std::mem::size_of::()) + as u64; + builder.posting_lists.push( + PostingListBuilder::new_with_posting_tail_codec( + true, + posting_tail_codec, + ), + ); + let new_posting_lists_overhead_size = (builder.posting_lists.capacity() + * std::mem::size_of::()) + as u64; + Self::adjust_tracked_value( + memory_size, + old_posting_lists_overhead_size, + new_posting_lists_overhead_size, + ); + } + let posting_list = &mut builder.posting_lists[token_id as usize]; + let old_posting_memory_size = posting_list.size(); + if posting_list.add_occurrence(doc_id, token.position as u32)? { + token_ids.push(token_id); + } + let new_posting_memory_size = posting_list.size(); + posting_memory_delta += + new_posting_memory_size as i64 - old_posting_memory_size as i64; token_num += 1; } } else { @@ -862,21 +1007,27 @@ impl IndexWorker { self.builder.tokens.memory_size() as u64, ); - let old_posting_lists_overhead_size = self.posting_lists_overhead_size(); - self.builder - .posting_lists - .resize_with(self.builder.tokens.len(), || { - PostingListBuilder::new(with_position) - }); - let new_posting_lists_overhead_size = self.posting_lists_overhead_size(); - Self::adjust_tracked_value( - &mut self.memory_size, - old_posting_lists_overhead_size, - new_posting_lists_overhead_size, - ); + if !with_position { + let old_posting_lists_overhead_size = self.posting_lists_overhead_size(); + self.builder + .posting_lists + .resize_with(self.builder.tokens.len(), || { + PostingListBuilder::new_with_posting_tail_codec( + false, + self.builder.posting_tail_codec, + ) + }); + let new_posting_lists_overhead_size = self.posting_lists_overhead_size(); + Self::adjust_tracked_value( + &mut self.memory_size, + old_posting_lists_overhead_size, + new_posting_lists_overhead_size, + ); + } let old_doc_memory_size = self.builder.docs.memory_size() as u64; - let doc_id = self.builder.docs.append(row_id, token_num); + let appended_doc_id = self.builder.docs.append(row_id, token_num); + debug_assert_eq!(appended_doc_id, doc_id); self.adjust_tracked_memory_size( old_doc_memory_size, self.builder.docs.memory_size() as u64, @@ -884,14 +1035,11 @@ impl IndexWorker { self.total_doc_length += doc.len(); if with_position { - let mut token_occurrences = std::mem::take(&mut self.token_occurrences); - let unique_tokens = token_occurrences.len(); - let mut posting_memory_delta = 0i64; - for (token_id, term_positions) in token_occurrences.drain() { + for &token_id in &self.token_ids { let (old_posting_memory_size, new_posting_memory_size) = { let posting_list = &mut self.builder.posting_lists[token_id as usize]; let old_posting_memory_size = posting_list.size(); - posting_list.add(doc_id, term_positions); + posting_list.finish_open_doc(doc_id)?; let new_posting_memory_size = posting_list.size(); (old_posting_memory_size, new_posting_memory_size) }; @@ -899,11 +1047,8 @@ impl IndexWorker { new_posting_memory_size as i64 - old_posting_memory_size as i64; } Self::apply_delta(&mut self.memory_size, posting_memory_delta); - self.token_occurrences = token_occurrences; - self.last_unique_token_count = unique_tokens; } else if token_num > 0 { self.token_ids.sort_unstable(); - let mut posting_memory_delta = 0i64; let mut iter = self.token_ids.iter(); let mut current = *iter.next().unwrap(); let mut count = 1u32; @@ -937,7 +1082,12 @@ impl IndexWorker { new_posting_memory_size as i64 - old_posting_memory_size as i64; Self::apply_delta(&mut self.memory_size, posting_memory_delta); } - self.last_token_count = token_num as usize; + self.last_token_count = self.token_ids.len(); + self.trim_temporary_buffers(); + self.adjust_tracked_memory_size( + old_temporary_memory_size, + self.temporary_memory_size(), + ); if self.builder.docs.len() == 1 && self.memory_size > self.worker_memory_limit_bytes { return Err(Error::invalid_input(format!( @@ -966,25 +1116,31 @@ impl IndexWorker { "flushing posting lists, memory size: {} MiB", self.memory_size / (1024 * 1024) ); - self.memory_size = 0; + self.memory_size = self.temporary_memory_size(); let with_position = self.has_position(); + let format_version = self.builder.format_version; let builder = std::mem::replace( &mut self.builder, - InnerBuilder::new( + InnerBuilder::new_with_format_version( self.id_alloc .fetch_add(1, std::sync::atomic::Ordering::Relaxed) | self.fragment_mask.unwrap_or(0), with_position, self.token_set_format, + format_version, ), ); let written_partition_id = builder.id(); - self.builder_tx.send(builder).await.map_err(|err| { - Error::execution(format!( - "failed to send finalized partition {} to writer: {err}", - written_partition_id - )) - })?; + let mut builder = builder; + builder + .write(self.dest_store.as_ref()) + .await + .map_err(|err| { + Error::execution(format!( + "failed to write finalized partition {}: {err}", + written_partition_id + )) + })?; self.partitions.push(written_partition_id); Ok(()) } @@ -1006,26 +1162,11 @@ impl IndexWorker { #[derive(Debug, Clone)] pub enum PositionRecorder { - Position(SmallVec<[u32; 4]>), + Position(SmallVec<[u32; 2]>), Count(u32), } impl PositionRecorder { - fn new(with_position: bool) -> Self { - if with_position { - Self::Position(SmallVec::new()) - } else { - Self::Count(0) - } - } - - fn push(&mut self, position: u32) { - match self { - Self::Position(positions) => positions.push(position), - Self::Count(count) => *count += 1, - } - } - pub fn len(&self) -> u32 { match self { Self::Position(positions) => positions.len() as u32, @@ -1092,9 +1233,25 @@ pub fn legacy_inverted_list_schema(with_position: bool) -> SchemaRef { } pub fn inverted_list_schema(with_position: bool) -> SchemaRef { + inverted_list_schema_for_version(with_position, current_fts_format_version()) +} + +pub fn inverted_list_schema_for_version( + with_position: bool, + format_version: InvertedListFormatVersion, +) -> SchemaRef { + match format_version { + InvertedListFormatVersion::V1 => inverted_list_schema_v1(with_position), + InvertedListFormatVersion::V2 => inverted_list_schema_with_tail_codec_and_position_codec( + with_position, + PostingTailCodec::VarintDelta, + Some(PositionStreamCodec::PackedDelta), + ), + } +} + +fn inverted_list_schema_v1(with_position: bool) -> SchemaRef { let mut fields = vec![ - // we compress the posting lists (including row ids and frequencies), - // and store the compressed posting lists, so it's a large binary array arrow_schema::Field::new( POSTING_COL, datatypes::DataType::List(Arc::new(Field::new( @@ -1125,6 +1282,70 @@ pub fn inverted_list_schema(with_position: bool) -> SchemaRef { Arc::new(arrow_schema::Schema::new(fields)) } +pub fn inverted_list_schema_with_tail_codec( + with_position: bool, + posting_tail_codec: PostingTailCodec, +) -> SchemaRef { + inverted_list_schema_with_tail_codec_and_position_codec( + with_position, + posting_tail_codec, + Some(PositionStreamCodec::PackedDelta), + ) +} + +fn inverted_list_schema_with_tail_codec_and_position_codec( + with_position: bool, + posting_tail_codec: PostingTailCodec, + position_codec: Option, +) -> SchemaRef { + let mut fields = vec![ + // we compress the posting lists (including row ids and frequencies), + // and store the compressed posting lists, so it's a large binary array + arrow_schema::Field::new( + POSTING_COL, + datatypes::DataType::List(Arc::new(Field::new( + "item", + datatypes::DataType::LargeBinary, + true, + ))), + false, + ), + arrow_schema::Field::new(MAX_SCORE_COL, datatypes::DataType::Float32, false), + arrow_schema::Field::new(LENGTH_COL, datatypes::DataType::UInt32, false), + ]; + if with_position { + fields.push(arrow_schema::Field::new( + COMPRESSED_POSITION_COL, + arrow_schema::DataType::LargeBinary, + false, + )); + fields.push(arrow_schema::Field::new( + POSITION_BLOCK_OFFSET_COL, + arrow_schema::DataType::List(Arc::new(arrow_schema::Field::new( + "item", + arrow_schema::DataType::UInt32, + true, + ))), + false, + )); + } + let mut metadata = HashMap::from([( + POSTING_TAIL_CODEC_KEY.to_owned(), + posting_tail_codec.as_str().to_owned(), + )]); + if let Some(position_codec) = position_codec.filter(|_| with_position) { + metadata.insert( + POSITIONS_LAYOUT_KEY.to_owned(), + POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(), + ); + metadata.insert( + POSITIONS_CODEC_KEY.to_owned(), + position_codec.as_str().to_owned(), + ); + } + Arc::new(arrow_schema::Schema::new_with_metadata(fields, metadata)) +} + /// Flatten the string list stream into a string stream pub struct FlattenStream { /// Inner record batch stream with 2 columns: @@ -1318,6 +1539,8 @@ async fn merge_metadata_files( let mut all_partitions = Vec::new(); let mut params = None; let mut token_set_format = None; + let mut format_version = None; + let mut posting_tail_codec = None; for file_name in part_metadata_files { let reader = store.open_index_file(file_name).await?; @@ -1348,6 +1571,12 @@ async fn merge_metadata_files( { token_set_format = Some(TokenSetFormat::from_str(name)?); } + if format_version.is_none() { + format_version = Some(parse_format_version_from_metadata(metadata)?); + } + if posting_tail_codec.is_none() { + posting_tail_codec = Some(parse_posting_tail_codec(metadata)?); + } } // Create ID mapping: sorted original IDs -> 0,1,2... @@ -1426,7 +1655,9 @@ async fn merge_metadata_files( remapped_partitions.clone(), token_set_format, None, - ); + ) + .with_format_version(format_version.unwrap_or(InvertedListFormatVersion::V1)) + .with_posting_tail_codec(posting_tail_codec.unwrap_or(PostingTailCodec::Fixed32)); builder .write_metadata(&*store, &remapped_partitions) .await?; @@ -1484,7 +1715,7 @@ mod tests { use super::*; use crate::metrics::NoOpMetricsCollector; use crate::progress::IndexBuildProgress; - use crate::scalar::{IndexFile, IndexReader, IndexWriter}; + use crate::scalar::{IndexFile, IndexReader, IndexWriter, ScalarIndex}; use arrow_array::{RecordBatch, StringArray, UInt64Array}; use arrow_schema::{DataType, Field, Schema}; use async_trait::async_trait; @@ -1508,7 +1739,7 @@ mod tests { RecordBatch::try_new(schema, vec![docs, row_ids]).unwrap() } - #[derive(Debug, Default)] + #[derive(Debug, Default, Clone)] struct CountingStore { write_count: Arc, } @@ -1557,6 +1788,10 @@ mod tests { self } + fn clone_arc(&self) -> Arc { + Arc::new(self.clone()) + } + fn io_parallelism(&self) -> usize { 1 } @@ -1640,16 +1875,18 @@ mod tests { let tokenizer = params.build()?; let token_set_format = TokenSetFormat::default(); let id_alloc = Arc::new(AtomicU64::new(0)); - let (builder_tx, builder_rx) = async_channel::bounded(2); let mut worker1 = IndexWorker::new( tokenizer.clone(), - builder_tx.clone(), - params.with_position, + src_store.clone(), id_alloc.clone(), - None, - token_set_format, - u64::MAX, + IndexWorkerConfig { + with_position: params.with_position, + format_version: InvertedListFormatVersion::V1, + fragment_mask: None, + token_set_format, + worker_memory_limit_bytes: u64::MAX, + }, ) .await?; worker1 @@ -1661,18 +1898,18 @@ mod tests { partitions.push(tail_partition.builder.id()); tail_partition.builder.write(src_store.as_ref()).await?; } - while let Ok(mut builder) = builder_rx.try_recv() { - builder.write(src_store.as_ref()).await?; - } let mut worker2 = IndexWorker::new( tokenizer.clone(), - builder_tx, - params.with_position, + src_store.clone(), id_alloc.clone(), - None, - token_set_format, - u64::MAX, + IndexWorkerConfig { + with_position: params.with_position, + format_version: InvertedListFormatVersion::V1, + fragment_mask: None, + token_set_format, + worker_memory_limit_bytes: u64::MAX, + }, ) .await?; worker2 @@ -1688,13 +1925,6 @@ mod tests { assert_eq!(partitions.len(), 2); assert_ne!(partitions[0], partitions[1]); - // Drain finalized builders and write them to the source store, mirroring the - // direct-to-destination writer task used by the production indexing path. - // The bounded channel size keeps these writes backpressured in normal execution. - while let Ok(mut builder) = builder_rx.try_recv() { - builder.write(src_store.as_ref()).await?; - } - let builder = InvertedIndexBuilder::from_existing_index( InvertedIndexParams::default(), Some(src_store.clone()), @@ -1721,6 +1951,99 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_update_preserves_existing_posting_tail_codec() -> Result<()> { + let src_dir = TempDir::default(); + let dest_dir = TempDir::default(); + let src_store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + src_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + let dest_store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + dest_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + let posting_tail_codec = PostingTailCodec::Fixed32; + let mut partition = InnerBuilder::new_with_posting_tail_codec( + 0, + false, + TokenSetFormat::default(), + posting_tail_codec, + ); + partition.tokens.add("hello".to_owned()); + let mut posting_list = + PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec); + posting_list.add(0, PositionRecorder::Count(1)); + partition.posting_lists.push(posting_list); + partition.docs.append(100, 1); + partition.write(src_store.as_ref()).await?; + + let metadata_writer = InvertedIndexBuilder::from_existing_index( + InvertedIndexParams::default(), + Some(src_store.clone()), + vec![0], + TokenSetFormat::default(), + None, + ) + .with_posting_tail_codec(posting_tail_codec); + metadata_writer + .write_metadata(src_store.as_ref(), &[0]) + .await?; + + let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?; + let schema = Arc::new(Schema::new(vec![ + Field::new("doc", DataType::Utf8, true), + Field::new(ROW_ID, DataType::UInt64, false), + ])); + let docs = Arc::new(StringArray::from(vec![Some("hello again")])); + let row_ids = Arc::new(UInt64Array::from(vec![101u64])); + let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?; + let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)])); + index + .update(Box::pin(stream), dest_store.as_ref(), None) + .await?; + + let updated = + InvertedIndex::load(dest_store.clone(), None, &LanceCache::no_cache()).await?; + assert_eq!(updated.partitions.len(), 2); + for partition in &updated.partitions { + assert_eq!( + partition.inverted_list.posting_tail_codec(), + posting_tail_codec + ); + } + + let metadata = dest_store.open_index_file(METADATA_FILE).await?; + assert_eq!( + metadata.schema().metadata.get(POSTING_TAIL_CODEC_KEY), + Some(&posting_tail_codec.as_str().to_owned()) + ); + + Ok(()) + } + + #[test] + fn test_with_posting_tail_codec_syncs_format_version() { + let builder = InvertedIndexBuilder::from_existing_index( + InvertedIndexParams::default(), + None, + Vec::new(), + TokenSetFormat::default(), + None, + ) + .with_format_version(InvertedListFormatVersion::V2) + .with_posting_tail_codec(PostingTailCodec::Fixed32); + assert_eq!(builder.format_version, InvertedListFormatVersion::V1); + assert_eq!(builder.posting_tail_codec, PostingTailCodec::Fixed32); + + let builder = builder.with_posting_tail_codec(PostingTailCodec::VarintDelta); + assert_eq!(builder.format_version, InvertedListFormatVersion::V2); + assert_eq!(builder.posting_tail_codec, PostingTailCodec::VarintDelta); + } + #[tokio::test] async fn test_inverted_index_without_positions_tracks_frequency() -> Result<()> { let index_dir = TempDir::default(); @@ -1983,6 +2306,101 @@ mod tests { ); } + #[tokio::test] + async fn test_worker_trims_position_temp_buffers() -> Result<()> { + let tokenizer = InvertedIndexParams::default().with_position(true).build()?; + let store = Arc::new(CountingStore::new()); + let id_alloc = Arc::new(AtomicU64::new(0)); + let mut worker = IndexWorker::new( + tokenizer, + store, + id_alloc, + IndexWorkerConfig { + with_position: true, + format_version: InvertedListFormatVersion::V1, + fragment_mask: None, + token_set_format: TokenSetFormat::default(), + worker_memory_limit_bytes: u64::MAX, + }, + ) + .await?; + + let doc = (0..(MAX_RETAINED_TOKEN_IDS * 2)) + .map(|i| format!("tok{i}")) + .collect::>() + .join(" "); + worker.process_batch(make_doc_batch(&doc, 0)).await?; + + assert!(worker.token_ids.is_empty()); + assert!(worker.token_ids.capacity() <= MAX_RETAINED_TOKEN_IDS); + assert!(worker.memory_size >= worker.temporary_memory_size()); + Ok(()) + } + + #[tokio::test] + async fn test_worker_flush_keeps_position_temp_memory_bounded() -> Result<()> { + let tokenizer = InvertedIndexParams::default().with_position(true).build()?; + let store = Arc::new(CountingStore::new()); + let id_alloc = Arc::new(AtomicU64::new(0)); + let mut worker = IndexWorker::new( + tokenizer, + store, + id_alloc, + IndexWorkerConfig { + with_position: true, + format_version: InvertedListFormatVersion::V1, + fragment_mask: None, + token_set_format: TokenSetFormat::default(), + worker_memory_limit_bytes: u64::MAX, + }, + ) + .await?; + + let doc = std::iter::repeat_n("common", 32_768) + .collect::>() + .join(" "); + let mut observed_post_flush_memory = Vec::new(); + for row_id in 0..8 { + worker.process_batch(make_doc_batch(&doc, row_id)).await?; + worker.flush().await?; + observed_post_flush_memory.push(worker.memory_size); + } + + let max_memory = *observed_post_flush_memory.iter().max().unwrap(); + let min_memory = *observed_post_flush_memory.iter().min().unwrap(); + assert!( + max_memory <= min_memory.saturating_add(256 * 1024), + "post-flush worker memory drifted upward: {observed_post_flush_memory:?}" + ); + Ok(()) + } + + #[tokio::test] + async fn test_worker_flush_writes_partition_directly() -> Result<()> { + let tokenizer = InvertedIndexParams::default().with_position(true).build()?; + let store = Arc::new(CountingStore::new()); + let id_alloc = Arc::new(AtomicU64::new(0)); + let mut worker = IndexWorker::new( + tokenizer, + store.clone(), + id_alloc, + IndexWorkerConfig { + with_position: true, + format_version: InvertedListFormatVersion::V1, + fragment_mask: None, + token_set_format: TokenSetFormat::default(), + worker_memory_limit_bytes: u64::MAX, + }, + ) + .await?; + worker + .process_batch(make_doc_batch("alpha beta gamma", 0)) + .await?; + worker.flush().await?; + assert!(store.write_count() > 0); + Ok(()) + } + #[test] fn test_resolve_worker_memory_limit_uses_default_when_unset() { let params = InvertedIndexParams::default(); diff --git a/rust/lance-index/src/scalar/inverted/encoding.rs b/rust/lance-index/src/scalar/inverted/encoding.rs index 9d299d516df..a25d7cfc4c9 100644 --- a/rust/lance-index/src/scalar/inverted/encoding.rs +++ b/rust/lance-index/src/scalar/inverted/encoding.rs @@ -4,9 +4,10 @@ use std::io::Write; use super::builder::BLOCK_SIZE; +use super::index::{PositionStreamCodec, PostingTailCodec}; use arrow::array::LargeBinaryBuilder; use bitpacking::{BitPacker, BitPacker4x}; -use lance_core::Result; +use lance_core::{Error, Result}; // we compress the posting list to multiple blocks of fixed number of elements (BLOCK_SIZE), // returns a LargeBinaryArray, where each binary is a compressed block (128 row ids + 128 frequencies) @@ -17,21 +18,34 @@ use lance_core::Result; // - n bytes for the packed doc ids // - 1 byte for the number of bits used to pack the frequencies // - n bytes for the packed frequencies -// if the block is not full (the last block), we don't compress it -// we directly write the remainder to the buffer with the format: -// - 4 bytes for the max block score -// - 4*n bytes for the doc ids -// - 4*n bytes for the frequencies -// where n is the number of elements in the block +// if the block is not full (the last block), we encode the remainder separately +// using the configured remainder codec. // compress the posting list to multiple blocks of fixed number of elements (BLOCK_SIZE), // returns a LargeBinaryArray, where each binary is a compressed block (128 row ids + 128 frequencies) #[cfg(test)] pub fn compress_posting_list<'a>( + length: usize, + doc_ids: impl Iterator, + frequencies: impl Iterator, + block_max_scores: impl Iterator, +) -> Result { + compress_posting_list_with_tail_codec( + length, + doc_ids, + frequencies, + block_max_scores, + PostingTailCodec::VarintDelta, + ) +} + +#[cfg(test)] +pub fn compress_posting_list_with_tail_codec<'a>( length: usize, doc_ids: impl Iterator, frequencies: impl Iterator, mut block_max_scores: impl Iterator, + tail_codec: PostingTailCodec, ) -> Result { if length < BLOCK_SIZE { // directly do remainder compression to avoid overhead of creating buffer @@ -39,12 +53,10 @@ pub fn compress_posting_list<'a>( // write the max score of the block let max_score = block_max_scores.next().unwrap(); let _ = builder.write(max_score.to_le_bytes().as_ref())?; - compress_remainder( + compress_posting_remainder( doc_ids.copied().collect::>().as_slice(), - &mut builder, - )?; - compress_remainder( frequencies.copied().collect::>().as_slice(), + tail_codec, &mut builder, )?; builder.append_value(""); @@ -82,8 +94,7 @@ pub fn compress_posting_list<'a>( // write the max score of the block let max_score = block_max_scores.next().unwrap(); let _ = builder.write(max_score.to_le_bytes().as_ref())?; - compress_remainder(&doc_id_buffer, &mut builder)?; - compress_remainder(&freq_buffer, &mut builder)?; + compress_posting_remainder(&doc_id_buffer, &freq_buffer, tail_codec, &mut builder)?; builder.append_value(""); } Ok(builder.finish()) @@ -106,12 +117,12 @@ pub fn encode_full_posting_block_into( pub fn encode_remainder_posting_block_into( doc_ids: &[u32], frequencies: &[u32], + codec: PostingTailCodec, block: &mut Vec, ) -> Result<()> { debug_assert_eq!(doc_ids.len(), frequencies.len()); block.extend_from_slice(&0f32.to_le_bytes()); - compress_remainder(doc_ids, block)?; - compress_remainder(frequencies, block)?; + compress_posting_remainder(doc_ids, frequencies, codec, block)?; Ok(()) } @@ -137,13 +148,65 @@ fn compress_block(data: &[u32], buffer: &mut [u8], builder: &mut impl Write) -> } #[inline] -fn compress_remainder(data: &[u32], builder: &mut impl Write) -> Result<()> { +fn compress_raw_remainder(data: &[u32], builder: &mut impl Write) -> Result<()> { for value in data.iter() { let _ = builder.write(value.to_le_bytes().as_ref())?; } Ok(()) } +#[inline] +fn write_varint_u32(builder: &mut impl Write, mut value: u32) -> Result<()> { + let mut bytes = [0u8; 5]; + let mut len = 0usize; + while value >= 0x80 { + bytes[len] = (value as u8) | 0x80; + value >>= 7; + len += 1; + } + bytes[len] = value as u8; + len += 1; + let _ = builder.write(&bytes[..len])?; + Ok(()) +} + +#[inline] +fn compress_posting_remainder( + doc_ids: &[u32], + frequencies: &[u32], + codec: PostingTailCodec, + builder: &mut impl Write, +) -> Result<()> { + debug_assert_eq!(doc_ids.len(), frequencies.len()); + match codec { + PostingTailCodec::Fixed32 => { + compress_raw_remainder(doc_ids, builder)?; + compress_raw_remainder(frequencies, builder)?; + } + PostingTailCodec::VarintDelta => { + let mut previous = 0u32; + for (index, &doc_id) in doc_ids.iter().enumerate() { + let delta = if index == 0 { + doc_id + } else { + doc_id.checked_sub(previous).ok_or_else(|| { + Error::index(format!( + "doc ids must be sorted within a posting tail block, got {} after {}", + doc_id, previous + )) + })? + }; + write_varint_u32(builder, delta)?; + previous = doc_id; + } + for &frequency in frequencies { + write_varint_u32(builder, frequency)?; + } + } + } + Ok(()) +} + pub fn compress_positions(positions: &[u32]) -> Result { let mut builder = LargeBinaryBuilder::with_capacity( positions.len().div_ceil(BLOCK_SIZE), @@ -165,19 +228,394 @@ pub fn compress_positions(positions: &[u32]) -> Result 0 { - compress_remainder(&positions[length - remainder..], &mut builder)?; + compress_raw_remainder(&positions[length - remainder..], &mut builder)?; builder.append_value(""); } Ok(builder.finish()) } +#[inline] +fn encode_varint_u32(dst: &mut Vec, mut value: u32) { + while value >= 0x80 { + dst.push((value as u8) | 0x80); + value >>= 7; + } + dst.push(value as u8); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct PositionBlockBuilder { + codec: PositionStreamCodec, + encoded_bytes: Vec, + pending_deltas: Vec, +} + +impl Default for PositionBlockBuilder { + fn default() -> Self { + Self::new(PositionStreamCodec::PackedDelta) + } +} + +impl PositionBlockBuilder { + pub(super) fn new(codec: PositionStreamCodec) -> Self { + Self { + codec, + encoded_bytes: Vec::new(), + pending_deltas: Vec::new(), + } + } + + pub(super) fn size(&self) -> usize { + self.encoded_bytes.capacity() + self.pending_deltas.capacity() * std::mem::size_of::() + } + + pub(super) fn append_doc_positions(&mut self, positions: &[u32]) -> Result<()> { + let mut previous = 0u32; + for (index, &position) in positions.iter().enumerate() { + let delta = if index == 0 { + position + } else { + position.checked_sub(previous).ok_or_else(|| { + Error::index(format!( + "positions must be sorted within a document, got {} after {}", + position, previous + )) + })? + }; + self.push_delta(delta)?; + previous = position; + } + Ok(()) + } + + pub(super) fn append_position( + &mut self, + position: u32, + previous_in_doc: Option, + ) -> Result<()> { + let delta = match previous_in_doc { + Some(previous) => position.checked_sub(previous).ok_or_else(|| { + Error::index(format!( + "positions must be sorted within a document, got {} after {}", + position, previous + )) + })?, + None => position, + }; + self.push_delta(delta) + } + + pub(super) fn finish(self) -> Vec { + let mut bytes = self.encoded_bytes; + match self.codec { + PositionStreamCodec::VarintDocDelta | PositionStreamCodec::PackedDelta => { + for delta in self.pending_deltas { + encode_varint_u32(&mut bytes, delta); + } + } + } + bytes + } + + pub(super) fn decode_into(&self, frequencies: &[u32], dst: &mut Vec) -> Result<()> { + let bytes = self.clone().finish(); + decode_position_stream_block(bytes.as_slice(), frequencies, self.codec, dst) + } + + fn push_delta(&mut self, delta: u32) -> Result<()> { + match self.codec { + PositionStreamCodec::VarintDocDelta => { + encode_varint_u32(&mut self.encoded_bytes, delta); + } + PositionStreamCodec::PackedDelta => { + self.pending_deltas.push(delta); + if self.pending_deltas.len() == BLOCK_SIZE { + let mut packed_buffer = [0u8; BLOCK_SIZE * 4 + 1]; + compress_block( + self.pending_deltas.as_slice(), + &mut packed_buffer, + &mut self.encoded_bytes, + )?; + self.pending_deltas.clear(); + } + } + } + Ok(()) + } +} + +#[inline] +fn decode_varint_u32(src: &[u8], offset: &mut usize) -> Result { + let mut value = 0u32; + let mut shift = 0u32; + while *offset < src.len() { + let byte = src[*offset]; + *offset += 1; + value |= u32::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + shift += 7; + if shift >= 35 { + return Err(Error::index( + "invalid u32 varint in position stream".to_owned(), + )); + } + } + Err(Error::index( + "unexpected EOF while decoding position stream".to_owned(), + )) +} + +#[cfg(test)] +fn encode_position_stream_varint_block_into( + positions: &[u32], + frequencies: &[u32], + dst: &mut Vec, +) -> Result<()> { + let mut offset = 0usize; + for &frequency in frequencies { + let frequency = frequency as usize; + let end = offset + .checked_add(frequency) + .ok_or_else(|| Error::index("position block length overflow".to_owned()))?; + if end > positions.len() { + return Err(Error::index(format!( + "position block has {} positions but frequencies require at least {}", + positions.len(), + end + ))); + } + let mut previous = 0u32; + for (index, &position) in positions[offset..end].iter().enumerate() { + let delta = if index == 0 { + position + } else { + position.checked_sub(previous).ok_or_else(|| { + Error::index(format!( + "positions must be sorted within a document, got {} after {}", + position, previous + )) + })? + }; + encode_varint_u32(dst, delta); + previous = position; + } + offset = end; + } + if offset != positions.len() { + return Err(Error::index(format!( + "position block has {} trailing positions after consuming {} frequencies", + positions.len() - offset, + frequencies.len() + ))); + } + Ok(()) +} + +fn decode_position_stream_varint_block( + src: &[u8], + frequencies: &[u32], + dst: &mut Vec, +) -> Result<()> { + let mut offset = 0usize; + for &frequency in frequencies { + let mut previous = 0u32; + for index in 0..frequency as usize { + let delta = decode_varint_u32(src, &mut offset)?; + let position = if index == 0 { + delta + } else { + previous.checked_add(delta).ok_or_else(|| { + Error::index("position stream overflow while decoding".to_owned()) + })? + }; + dst.push(position); + previous = position; + } + } + if offset != src.len() { + return Err(Error::index(format!( + "position stream has {} trailing bytes after decoding block", + src.len() - offset + ))); + } + Ok(()) +} + +#[cfg(test)] +fn encode_position_stream_packed_block_into( + positions: &[u32], + frequencies: &[u32], + dst: &mut Vec, +) -> Result<()> { + let mut delta_buffer = [0u32; BLOCK_SIZE]; + let mut delta_count = 0usize; + let mut packed_buffer = [0u8; BLOCK_SIZE * 4 + 1]; + let mut offset = 0usize; + + for &frequency in frequencies { + let frequency = frequency as usize; + let end = offset + .checked_add(frequency) + .ok_or_else(|| Error::index("position block length overflow".to_owned()))?; + if end > positions.len() { + return Err(Error::index(format!( + "position block has {} positions but frequencies require at least {}", + positions.len(), + end + ))); + } + let mut previous = 0u32; + for (index, &position) in positions[offset..end].iter().enumerate() { + let delta = if index == 0 { + position + } else { + position.checked_sub(previous).ok_or_else(|| { + Error::index(format!( + "positions must be sorted within a document, got {} after {}", + position, previous + )) + })? + }; + delta_buffer[delta_count] = delta; + delta_count += 1; + if delta_count == BLOCK_SIZE { + compress_block(&delta_buffer, &mut packed_buffer, dst)?; + delta_count = 0; + } + previous = position; + } + offset = end; + } + + if offset != positions.len() { + return Err(Error::index(format!( + "position block has {} trailing positions after consuming {} frequencies", + positions.len() - offset, + frequencies.len() + ))); + } + + for delta in &delta_buffer[..delta_count] { + encode_varint_u32(dst, *delta); + } + Ok(()) +} + +fn decode_position_stream_packed_block( + src: &[u8], + frequencies: &[u32], + dst: &mut Vec, +) -> Result<()> { + let total_positions = frequencies.iter().try_fold(0usize, |total, &frequency| { + total.checked_add(frequency as usize).ok_or_else(|| { + Error::index("position stream length overflow while decoding".to_owned()) + }) + })?; + + let full_delta_blocks = total_positions / BLOCK_SIZE; + let tail_len = total_positions % BLOCK_SIZE; + + let compressor = BitPacker4x::new(); + let mut packed_offset = 0usize; + let mut packed_values = [0u32; BLOCK_SIZE]; + let mut deltas = Vec::with_capacity(total_positions); + + for _ in 0..full_delta_blocks { + if packed_offset >= src.len() { + return Err(Error::index( + "unexpected EOF while decoding packed position stream".to_owned(), + )); + } + let num_bits = src[packed_offset]; + packed_offset += 1; + let consumed = compressor.decompress(&src[packed_offset..], &mut packed_values, num_bits); + packed_offset += consumed; + deltas.extend_from_slice(&packed_values); + } + + for _ in 0..tail_len { + deltas.push(decode_varint_u32(src, &mut packed_offset)?); + } + + if packed_offset != src.len() { + return Err(Error::index(format!( + "position stream has {} trailing bytes after decoding block", + src.len() - packed_offset + ))); + } + + let mut delta_offset = 0usize; + for &frequency in frequencies { + let mut previous = 0u32; + for index in 0..frequency as usize { + let delta = deltas[delta_offset]; + delta_offset += 1; + let position = if index == 0 { + delta + } else { + previous.checked_add(delta).ok_or_else(|| { + Error::index("position stream overflow while decoding".to_owned()) + })? + }; + dst.push(position); + previous = position; + } + } + debug_assert_eq!(delta_offset, deltas.len()); + Ok(()) +} + +#[cfg(test)] +pub fn encode_position_stream_block_into( + positions: &[u32], + frequencies: &[u32], + codec: PositionStreamCodec, + dst: &mut Vec, +) -> Result<()> { + match codec { + PositionStreamCodec::VarintDocDelta => { + encode_position_stream_varint_block_into(positions, frequencies, dst) + } + PositionStreamCodec::PackedDelta => { + encode_position_stream_packed_block_into(positions, frequencies, dst) + } + } +} + +pub fn decode_position_stream_block( + src: &[u8], + frequencies: &[u32], + codec: PositionStreamCodec, + dst: &mut Vec, +) -> Result<()> { + match codec { + PositionStreamCodec::VarintDocDelta => { + decode_position_stream_varint_block(src, frequencies, dst) + } + PositionStreamCodec::PackedDelta => { + decode_position_stream_packed_block(src, frequencies, dst) + } + } +} + /// decompress the posting list from a LargeBinaryArray /// returns a vector of (row_id, frequency) tuples #[cfg(test)] pub fn decompress_posting_list( num_docs: u32, posting_list: &arrow::array::LargeBinaryArray, +) -> Result<(Vec, Vec)> { + decompress_posting_list_with_tail_codec(num_docs, posting_list, PostingTailCodec::VarintDelta) +} + +#[cfg(test)] +pub fn decompress_posting_list_with_tail_codec( + num_docs: u32, + posting_list: &arrow::array::LargeBinaryArray, + tail_codec: PostingTailCodec, ) -> Result<(Vec, Vec)> { let mut doc_ids: Vec = Vec::with_capacity(num_docs as usize); let mut frequencies: Vec = Vec::with_capacity(num_docs as usize); @@ -192,7 +630,13 @@ pub fn decompress_posting_list( let remainder = num_docs as usize % BLOCK_SIZE; if remainder > 0 { let compressed = posting_list.value(bitpacking_blocks); - decompress_posting_remainder(compressed, remainder, &mut doc_ids, &mut frequencies); + decompress_posting_remainder( + compressed, + remainder, + tail_codec, + &mut doc_ids, + &mut frequencies, + ); } Ok((doc_ids, frequencies)) @@ -212,7 +656,7 @@ pub fn decompress_positions(compressed: &arrow::array::LargeBinaryArray) -> Vec< let remainder = num_positions as usize % BLOCK_SIZE; if remainder > 0 { let compressed_block = compressed.value(num_blocks + 1); - decompress_remainder(compressed_block, remainder, &mut positions); + decompress_raw_remainder(compressed_block, remainder, &mut positions); } positions @@ -237,12 +681,45 @@ pub fn decompress_posting_block( pub fn decompress_posting_remainder( block: &[u8], n: usize, + codec: PostingTailCodec, doc_ids: &mut Vec, frequencies: &mut Vec, ) { let block = &block[4..]; - decompress_remainder(block, n, doc_ids); - decompress_remainder(&block[n * 4..], n, frequencies); + match codec { + PostingTailCodec::Fixed32 => { + decompress_raw_remainder(block, n, doc_ids); + decompress_raw_remainder(&block[n * 4..], n, frequencies); + } + PostingTailCodec::VarintDelta => { + let mut offset = 0usize; + let mut previous = 0u32; + for index in 0..n { + let delta = decode_varint_u32(block, &mut offset) + .expect("posting tail doc ids should contain valid varints"); + let doc_id = if index == 0 { + delta + } else { + previous + .checked_add(delta) + .expect("posting tail doc id delta should not overflow") + }; + doc_ids.push(doc_id); + previous = doc_id; + } + for _ in 0..n { + let frequency = decode_varint_u32(block, &mut offset) + .expect("posting tail frequencies should contain valid varints"); + frequencies.push(frequency); + } + assert_eq!( + offset, + block.len(), + "posting tail block has {} trailing bytes after decoding", + block.len() - offset + ); + } + } } pub fn decode_full_posting_block(block: &[u8], doc_ids: &mut Vec, frequencies: &mut Vec) { @@ -270,13 +747,24 @@ fn decompress_block(block: &[u8], buffer: &mut [u32; BLOCK_SIZE], res: &mut Vec< res.extend_from_slice(&buffer[..]); } -pub fn decompress_remainder(compressed: &[u8], n: usize, dest: &mut Vec) { +pub fn decompress_raw_remainder(compressed: &[u8], n: usize, dest: &mut Vec) { for bytes in compressed.chunks_exact(4).take(n) { let data = u32::from_le_bytes(bytes.try_into().unwrap()); dest.push(data); } } +pub fn read_posting_tail_first_doc(block: &[u8], codec: PostingTailCodec) -> u32 { + match codec { + PostingTailCodec::Fixed32 => u32::from_le_bytes(block[4..8].try_into().unwrap()), + PostingTailCodec::VarintDelta => { + let mut offset = 4usize; + decode_varint_u32(block, &mut offset) + .expect("posting tail block should contain a valid first doc id") + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -321,6 +809,27 @@ mod tests { Ok(()) } + #[test] + fn test_compress_posting_list_fixed32_tail_still_roundtrips() -> Result<()> { + let doc_ids = vec![3_u32, 10_u32, 24_u32]; + let frequencies = vec![1_u32, 7_u32, 2_u32]; + let posting_list = compress_posting_list_with_tail_codec( + doc_ids.len(), + doc_ids.iter(), + frequencies.iter(), + std::iter::once(1.0_f32), + PostingTailCodec::Fixed32, + )?; + let (decoded_doc_ids, decoded_frequencies) = decompress_posting_list_with_tail_codec( + doc_ids.len() as u32, + &posting_list, + PostingTailCodec::Fixed32, + )?; + assert_eq!(decoded_doc_ids, doc_ids); + assert_eq!(decoded_frequencies, frequencies); + Ok(()) + } + #[test] fn test_compress_positions() -> Result<()> { let num_positions: usize = BLOCK_SIZE * 2 - 7; @@ -345,4 +854,47 @@ mod tests { assert_eq!(positions.len(), num_positions); Ok(()) } + + #[test] + fn test_encode_position_stream_block_roundtrip() -> Result<()> { + let frequencies = vec![1, 3, 2, 4]; + let positions = vec![7, 1, 3, 8, 2, 100, 0, 4, 9, 25]; + for codec in [ + PositionStreamCodec::VarintDocDelta, + PositionStreamCodec::PackedDelta, + ] { + let mut encoded = Vec::new(); + encode_position_stream_block_into(&positions, &frequencies, codec, &mut encoded)?; + let mut decoded = Vec::new(); + decode_position_stream_block(&encoded, &frequencies, codec, &mut decoded)?; + assert_eq!(decoded, positions); + assert!(encoded.len() < positions.len() * std::mem::size_of::()); + } + Ok(()) + } + + #[test] + fn test_incremental_position_block_builder_matches_batch_encoder() -> Result<()> { + let frequencies = vec![1, 3, 2, 4, 1, 5]; + let positions = vec![7, 1, 3, 8, 2, 100, 0, 4, 9, 25, 11, 2, 6, 7, 10, 15]; + + let mut builder = PositionBlockBuilder::new(PositionStreamCodec::PackedDelta); + let mut offset = 0usize; + for &frequency in &frequencies { + let end = offset + frequency as usize; + builder.append_doc_positions(&positions[offset..end])?; + offset = end; + } + + let incremental = builder.finish(); + let mut batch = Vec::new(); + encode_position_stream_block_into( + &positions, + &frequencies, + PositionStreamCodec::PackedDelta, + &mut batch, + )?; + assert_eq!(incremental, batch); + Ok(()) + } } diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index 7c1662edcb7..b273ef4b740 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -46,10 +46,12 @@ use std::sync::LazyLock; use tokio::task::spawn_blocking; use tracing::{info, instrument}; +use super::encoding::PositionBlockBuilder; +use super::iter::PostingListIterator; use super::{InvertedIndexBuilder, InvertedIndexParams, wand::*}; use super::{ builder::{ - BLOCK_SIZE, ScoredDoc, doc_file_path, inverted_list_schema, posting_file_path, + BLOCK_SIZE, ScoredDoc, doc_file_path, inverted_list_schema_for_version, posting_file_path, token_file_path, }, iter::PlainPostingListIterator, @@ -60,7 +62,6 @@ use super::{ builder::{InnerBuilder, PositionRecorder}, iter::CompressedPostingListIterator, }; -use super::{encoding::compress_positions, iter::PostingListIterator}; use crate::Index; use crate::frag_reuse::FragReuseIndex; use crate::pbold; @@ -75,8 +76,10 @@ use crate::{prefilter::PreFilter, scalar::inverted::iter::take_fst_keys}; use std::str::FromStr; // Version 0: Arrow TokenSetFormat (legacy) -// Version 1: Fst TokenSetFormat (new default, incompatible clients < 0.38) -pub const INVERTED_INDEX_VERSION: u32 = 1; +// Version 1: Fst TokenSetFormat with per-doc compressed positions +// Version 2: Fst TokenSetFormat with shared posting-list position streams. +pub const INVERTED_INDEX_VERSION_V1: u32 = 1; +pub const INVERTED_INDEX_VERSION_V2: u32 = 2; pub const TOKENS_FILE: &str = "tokens.lance"; pub const INVERT_LIST_FILE: &str = "invert.lance"; pub const DOCS_FILE: &str = "docs.lance"; @@ -90,6 +93,7 @@ pub const TOKEN_TOTAL_LENGTH_COL: &str = "_token_total_length"; pub const FREQUENCY_COL: &str = "_frequency"; pub const POSITION_COL: &str = "_position"; pub const COMPRESSED_POSITION_COL: &str = "_compressed_position"; +pub const POSITION_BLOCK_OFFSET_COL: &str = "_position_block_offset"; pub const POSTING_COL: &str = "_posting"; pub const MAX_SCORE_COL: &str = "_max_score"; pub const LENGTH_COL: &str = "_length"; @@ -97,6 +101,14 @@ pub const BLOCK_MAX_SCORE_COL: &str = "_block_max_score"; pub const NUM_TOKEN_COL: &str = "_num_tokens"; pub const SCORE_COL: &str = "_score"; pub const TOKEN_SET_FORMAT_KEY: &str = "token_set_format"; +pub const POSTING_TAIL_CODEC_KEY: &str = "posting_tail_codec"; +pub const POSITIONS_LAYOUT_KEY: &str = "positions_layout"; +pub const POSITIONS_CODEC_KEY: &str = "positions_codec"; +pub const POSTING_TAIL_CODEC_FIXED32_V1: &str = "fixed32_v1"; +pub const POSTING_TAIL_CODEC_VARINT_DELTA_V1: &str = "varint_delta_v1"; +pub const POSITIONS_LAYOUT_SHARED_STREAM_V2: &str = "shared_stream_v2"; +pub const POSITIONS_CODEC_VARINT_DOC_DELTA_V2: &str = "varint_doc_delta_v2"; +pub const POSITIONS_CODEC_PACKED_DELTA_V1: &str = "packed_delta_v1"; // Just a heuristic when we need to pre-allocate memory for tokens pub const ESTIMATED_MAX_TOKENS_PER_ROW: usize = 4 * 1024; @@ -108,6 +120,77 @@ pub static FTS_SCHEMA: LazyLock = static ROW_ID_SCHEMA: LazyLock = LazyLock::new(|| Arc::new(Schema::new(vec![ROW_ID_FIELD.clone()]))); +fn resolve_fts_format_version( + value: Option<&str>, +) -> std::result::Result { + value.unwrap_or("1").parse() +} + +pub fn current_fts_format_version() -> InvertedListFormatVersion { + resolve_fts_format_version(std::env::var("LANCE_FTS_FORMAT_VERSION").ok().as_deref()) + .expect("failed to parse LANCE_FTS_FORMAT_VERSION") +} + +pub fn max_supported_fts_format_version() -> InvertedListFormatVersion { + InvertedListFormatVersion::V2 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum InvertedListFormatVersion { + #[default] + V1, + V2, +} + +impl InvertedListFormatVersion { + pub fn from_posting_tail_codec(codec: PostingTailCodec) -> Self { + match codec { + PostingTailCodec::Fixed32 => Self::V1, + PostingTailCodec::VarintDelta => Self::V2, + } + } + + pub fn index_version(self) -> u32 { + match self { + Self::V1 => INVERTED_INDEX_VERSION_V1, + Self::V2 => INVERTED_INDEX_VERSION_V2, + } + } + + pub fn posting_tail_codec(self) -> PostingTailCodec { + match self { + Self::V1 => PostingTailCodec::Fixed32, + Self::V2 => PostingTailCodec::VarintDelta, + } + } + + pub fn position_codec(self) -> Option { + match self { + Self::V1 => None, + Self::V2 => Some(PositionStreamCodec::PackedDelta), + } + } + + pub fn uses_shared_position_stream(self) -> bool { + matches!(self, Self::V2) + } +} + +impl FromStr for InvertedListFormatVersion { + type Err = Error; + + fn from_str(s: &str) -> std::result::Result { + match s.trim() { + "1" | "v1" | "V1" => Ok(Self::V1), + "2" | "v2" | "V2" => Ok(Self::V2), + other => Err(Error::index(format!( + "unsupported FTS format version {}, expected 1 or 2", + other + ))), + } + } +} + #[derive(Debug)] struct PartitionCandidates { tokens_by_position: Vec, @@ -161,6 +244,97 @@ impl DeepSizeOf for TokenSetFormat { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum PositionStreamCodec { + VarintDocDelta, + #[default] + PackedDelta, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum PostingTailCodec { + Fixed32, + #[default] + VarintDelta, +} + +impl PostingTailCodec { + pub fn as_str(self) -> &'static str { + match self { + Self::Fixed32 => POSTING_TAIL_CODEC_FIXED32_V1, + Self::VarintDelta => POSTING_TAIL_CODEC_VARINT_DELTA_V1, + } + } + + fn from_metadata_value(value: &str) -> Result { + match value.trim() { + POSTING_TAIL_CODEC_FIXED32_V1 => Ok(Self::Fixed32), + POSTING_TAIL_CODEC_VARINT_DELTA_V1 => Ok(Self::VarintDelta), + other => Err(Error::index(format!( + "unsupported posting tail codec {}", + other + ))), + } + } +} + +pub(super) fn parse_posting_tail_codec( + metadata: &HashMap, +) -> Result { + Ok(metadata + .get(POSTING_TAIL_CODEC_KEY) + .map(|codec| PostingTailCodec::from_metadata_value(codec)) + .transpose()? + .unwrap_or(PostingTailCodec::Fixed32)) +} + +impl PositionStreamCodec { + pub fn as_str(self) -> &'static str { + match self { + Self::VarintDocDelta => POSITIONS_CODEC_VARINT_DOC_DELTA_V2, + Self::PackedDelta => POSITIONS_CODEC_PACKED_DELTA_V1, + } + } + + fn from_metadata_value(value: &str) -> Result { + match value.trim() { + POSITIONS_CODEC_VARINT_DOC_DELTA_V2 => Ok(Self::VarintDocDelta), + POSITIONS_CODEC_PACKED_DELTA_V1 => Ok(Self::PackedDelta), + other => Err(Error::index(format!( + "unsupported positions codec {}", + other + ))), + } + } +} + +fn parse_shared_position_codec(metadata: &HashMap) -> Result { + if let Some(codec) = metadata.get(POSITIONS_CODEC_KEY) { + return PositionStreamCodec::from_metadata_value(codec); + } + + match metadata + .get(POSITIONS_LAYOUT_KEY) + .map(|layout| layout.as_str()) + { + Some(POSITIONS_LAYOUT_SHARED_STREAM_V2) => Ok(PositionStreamCodec::VarintDocDelta), + _ => Ok(PositionStreamCodec::VarintDocDelta), + } +} + +pub(super) fn parse_format_version_from_metadata( + metadata: &HashMap, +) -> Result { + if metadata.contains_key(POSITIONS_CODEC_KEY) || metadata.contains_key(POSITIONS_LAYOUT_KEY) { + return Ok(InvertedListFormatVersion::V2); + } + if parse_posting_tail_codec(metadata)? == PostingTailCodec::VarintDelta { + Ok(InvertedListFormatVersion::V2) + } else { + Ok(InvertedListFormatVersion::V1) + } +} + #[derive(Clone)] pub struct InvertedIndex { params: InvertedIndexParams, @@ -187,6 +361,31 @@ impl DeepSizeOf for InvertedIndex { } impl InvertedIndex { + fn format_version(&self) -> InvertedListFormatVersion { + self.partitions + .first() + .map(|partition| { + InvertedListFormatVersion::from_posting_tail_codec( + partition.inverted_list.posting_tail_codec(), + ) + }) + .unwrap_or_else(current_fts_format_version) + } + + fn index_version(&self) -> u32 { + match self.token_set_format { + TokenSetFormat::Arrow => 0, + TokenSetFormat::Fst => self.format_version().index_version(), + } + } + + fn posting_tail_codec(&self) -> PostingTailCodec { + self.partitions + .first() + .map(|partition| partition.inverted_list.posting_tail_codec()) + .unwrap_or_default() + } + fn to_builder(&self) -> InvertedIndexBuilder { self.to_builder_with_offset(None) } @@ -201,6 +400,7 @@ impl InvertedIndex { self.token_set_format, fragment_mask, ) + .with_posting_tail_codec(self.posting_tail_codec()) } else { let partitions = match fragment_mask { Some(fragment_mask) => self @@ -222,6 +422,7 @@ impl InvertedIndex { self.token_set_format, fragment_mask, ) + .with_format_version(self.format_version()) } } @@ -614,15 +815,9 @@ impl ScalarIndex for InvertedIndex { let details = pbold::InvertedIndexDetails::try_from(&self.params)?; - // Use version 0 for Arrow format (legacy), version 1 for Fst format (new) - let index_version = match self.token_set_format { - TokenSetFormat::Arrow => 0, - TokenSetFormat::Fst => INVERTED_INDEX_VERSION, - }; - Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&details).unwrap(), - index_version, + index_version: self.index_version(), files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -637,15 +832,9 @@ impl ScalarIndex for InvertedIndex { let details = pbold::InvertedIndexDetails::try_from(&self.params)?; - // Use version 0 for Arrow format (legacy), version 1 for Fst format (new) - let index_version = match self.token_set_format { - TokenSetFormat::Arrow => 0, - TokenSetFormat::Fst => INVERTED_INDEX_VERSION, - }; - Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&details).unwrap(), - index_version, + index_version: self.index_version(), files: Some(dest_store.list_files_with_sizes().await?), }) } @@ -852,10 +1041,11 @@ impl InvertedPartition { } pub async fn into_builder(self) -> Result { - let mut builder = InnerBuilder::new( + let mut builder = InnerBuilder::new_with_posting_tail_codec( self.id, self.inverted_list.has_positions(), self.token_set_format, + self.inverted_list.posting_tail_codec(), ); builder.tokens = self.tokens; builder.docs = self.docs; @@ -1232,10 +1422,19 @@ pub struct PostingListReader { lengths: Option>, has_position: bool, + posting_tail_codec: PostingTailCodec, + positions_layout: PositionsLayout, index_cache: WeakLanceCache, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PositionsLayout { + None, + LegacyPerDoc, + SharedStream(PositionStreamCodec), +} + impl std::fmt::Debug for PostingListReader { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("InvertedListReader") @@ -1258,7 +1457,15 @@ impl PostingListReader { reader: Arc, index_cache: &LanceCache, ) -> Result { - let has_position = reader.schema().field(POSITION_COL).is_some(); + let positions_layout = if reader.schema().field(COMPRESSED_POSITION_COL).is_some() { + PositionsLayout::SharedStream(parse_shared_position_codec(&reader.schema().metadata)?) + } else if reader.schema().field(POSITION_COL).is_some() { + PositionsLayout::LegacyPerDoc + } else { + PositionsLayout::None + }; + let posting_tail_codec = parse_posting_tail_codec(&reader.schema().metadata)?; + let has_position = positions_layout != PositionsLayout::None; let (offsets, max_scores, lengths) = if reader.schema().field(POSTING_COL).is_none() { let (offsets, max_scores) = Self::load_metadata(reader.schema())?; (Some(offsets), max_scores, None) @@ -1283,6 +1490,8 @@ impl PostingListReader { max_scores, lengths, has_position, + posting_tail_codec, + positions_layout, index_cache: WeakLanceCache::from(index_cache), }) } @@ -1322,6 +1531,10 @@ impl PostingListReader { self.has_position } + pub(crate) fn posting_tail_codec(&self) -> PostingTailCodec { + self.posting_tail_codec + } + pub(crate) fn posting_len(&self, token_id: u32) -> usize { let token_id = token_id as usize; @@ -1353,7 +1566,17 @@ impl PostingListReader { } else { let token_id = token_id as usize; let columns = if with_position { - vec![POSTING_COL, POSITION_COL] + match self.positions_layout { + PositionsLayout::SharedStream(_) => { + vec![ + POSTING_COL, + COMPRESSED_POSITION_COL, + POSITION_BLOCK_OFFSET_COL, + ] + } + PositionsLayout::LegacyPerDoc => vec![POSTING_COL, POSITION_COL], + PositionsLayout::None => vec![POSTING_COL], + } } else { vec![POSTING_COL] }; @@ -1418,8 +1641,10 @@ impl PostingListReader { batch: &RecordBatch, max_score: Option, length: Option, + posting_tail_codec: PostingTailCodec, ) -> Result { - let posting_list = PostingList::from_batch(batch, max_score, length)?; + let posting_list = + PostingList::from_batch_with_tail_codec(batch, max_score, length, posting_tail_codec)?; Ok(posting_list) } @@ -1436,6 +1661,7 @@ impl PostingListReader { self.lengths .as_ref() .map(|lengths| lengths[token_id as usize]), + self.posting_tail_codec, ) } @@ -1444,6 +1670,7 @@ impl PostingListReader { offsets: Option>, max_scores: Option>, lengths: Option>, + posting_tail_codec: PostingTailCodec, ) -> Result> { let token_count = if let Some(offsets) = offsets.as_ref() { offsets.len() @@ -1471,6 +1698,7 @@ impl PostingListReader { &batch, max_scores.as_ref().map(|scores| scores[token_id]), lengths.as_ref().map(|lengths| lengths[token_id]), + posting_tail_codec, )?; posting_lists.push((token_id as u32, posting_list)); } @@ -1487,9 +1715,16 @@ impl PostingListReader { let offsets = self.offsets.clone(); let max_scores = self.max_scores.clone(); let lengths = self.lengths.clone(); + let posting_tail_codec = self.posting_tail_codec; let populate_start = Instant::now(); let posting_lists = spawn_blocking(move || { - Self::build_prewarm_posting_lists(batch, offsets, max_scores, lengths) + Self::build_prewarm_posting_lists( + batch, + offsets, + max_scores, + lengths, + posting_tail_codec, + ) }) .await .map_err(|err| { @@ -1537,20 +1772,57 @@ impl PostingListReader { })) } - async fn read_positions(&self, token_id: u32) -> Result { + async fn read_positions(&self, token_id: u32) -> Result { let positions = self.index_cache.get_or_insert_with_key(PositionKey { token_id }, || async move { - let batch = self - .reader - .read_range(self.posting_list_range(token_id), Some(&[POSITION_COL])) - .await.map_err(|e| { - match e { - Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()), - e => e - } - })?; - Result::Ok(Positions(batch[POSITION_COL] - .as_list::() - .clone())) + let positions = match self.positions_layout { + PositionsLayout::None => { + return Err(Error::invalid_input( + "position is not found but required for phrase queries, try recreating the index with position".to_owned(), + )); + } + PositionsLayout::LegacyPerDoc => { + let batch = self + .reader + .read_range(self.posting_list_range(token_id), Some(&[POSITION_COL])) + .await + .map_err(|e| match e { + Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()), + e => e, + })?; + CompressedPositionStorage::LegacyPerDoc( + batch[POSITION_COL].as_list::().value(0).as_list::().clone(), + ) + } + PositionsLayout::SharedStream(codec) => { + let batch = self + .reader + .read_range( + self.posting_list_range(token_id), + Some(&[COMPRESSED_POSITION_COL, POSITION_BLOCK_OFFSET_COL]), + ) + .await + .map_err(|e| match e { + Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()), + e => e, + })?; + let bytes = batch[COMPRESSED_POSITION_COL] + .as_binary::() + .value(0) + .to_vec(); + let block_offsets = batch[POSITION_BLOCK_OFFSET_COL] + .as_list::() + .value(0) + .as_primitive::() + .values() + .to_vec(); + CompressedPositionStorage::SharedStream(SharedPositionStream::new( + codec, + block_offsets, + bytes, + )) + } + }; + Result::Ok(Positions(positions)) }).await?; Ok(positions.0.clone()) } @@ -1575,7 +1847,14 @@ impl PostingListReader { None => vec![POSTING_COL], }; if with_position { - base_columns.push(POSITION_COL); + match self.positions_layout { + PositionsLayout::None => {} + PositionsLayout::LegacyPerDoc => base_columns.push(POSITION_COL), + PositionsLayout::SharedStream(_) => { + base_columns.push(COMPRESSED_POSITION_COL); + base_columns.push(POSITION_BLOCK_OFFSET_COL); + } + } } base_columns } @@ -1584,11 +1863,16 @@ impl PostingListReader { /// New type just to allow Positions implement DeepSizeOf so it can be put /// in the cache. #[derive(Clone)] -pub struct Positions(ListArray); +pub struct Positions(CompressedPositionStorage); impl DeepSizeOf for Positions { fn deep_size_of_children(&self, _context: &mut deepsize::Context) -> usize { - self.0.get_buffer_memory_size() + match &self.0 { + CompressedPositionStorage::LegacyPerDoc(positions) => { + positions.get_buffer_memory_size() + } + CompressedPositionStorage::SharedStream(stream) => stream.size(), + } } } @@ -1619,6 +1903,73 @@ impl CacheKey for PositionKey { } } +#[derive(Debug, Clone, PartialEq)] +pub enum CompressedPositionStorage { + LegacyPerDoc(ListArray), + SharedStream(SharedPositionStream), +} + +impl DeepSizeOf for CompressedPositionStorage { + fn deep_size_of_children(&self, _context: &mut deepsize::Context) -> usize { + match self { + Self::LegacyPerDoc(positions) => positions.get_buffer_memory_size(), + Self::SharedStream(stream) => stream.size(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SharedPositionStream { + codec: PositionStreamCodec, + block_offsets: Vec, + bytes: Vec, +} + +impl SharedPositionStream { + pub fn new(codec: PositionStreamCodec, block_offsets: Vec, bytes: Vec) -> Self { + Self { + codec, + block_offsets, + bytes, + } + } + + pub fn codec(&self) -> PositionStreamCodec { + self.codec + } + + pub fn block_count(&self) -> usize { + self.block_offsets.len() + } + + pub fn block_range(&self, index: usize) -> Range { + let start = self.block_offsets[index] as usize; + let end = self + .block_offsets + .get(index + 1) + .map(|offset| *offset as usize) + .unwrap_or(self.bytes.len()); + start..end + } + + pub fn block(&self, index: usize) -> &[u8] { + let range = self.block_range(index); + &self.bytes[range] + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn block_offsets(&self) -> &[u32] { + &self.block_offsets + } + + pub fn size(&self) -> usize { + self.block_offsets.capacity() * std::mem::size_of::() + self.bytes.capacity() + } +} + #[derive(Debug, Clone, DeepSizeOf)] pub enum PostingList { Plain(PlainPostingList), @@ -1630,12 +1981,26 @@ impl PostingList { batch: &RecordBatch, max_score: Option, length: Option, + ) -> Result { + let posting_tail_codec = parse_posting_tail_codec(batch.schema_ref().metadata())?; + Self::from_batch_with_tail_codec(batch, max_score, length, posting_tail_codec) + } + + pub fn from_batch_with_tail_codec( + batch: &RecordBatch, + max_score: Option, + length: Option, + posting_tail_codec: PostingTailCodec, ) -> Result { match batch.column_by_name(POSTING_COL) { Some(_) => { debug_assert!(max_score.is_some() && length.is_some()); - let posting = - CompressedPostingList::from_batch(batch, max_score.unwrap(), length.unwrap()); + let posting = CompressedPostingList::from_batch( + batch, + max_score.unwrap(), + length.unwrap(), + posting_tail_codec, + ); Ok(Self::Compressed(posting)) } None => { @@ -1656,11 +2021,18 @@ impl PostingList { } } - pub fn set_positions(&mut self, positions: ListArray) { + pub fn set_positions(&mut self, positions: CompressedPositionStorage) { match self { - Self::Plain(posting) => posting.positions = Some(positions), + Self::Plain(posting) => match positions { + CompressedPositionStorage::LegacyPerDoc(positions) => { + posting.positions = Some(positions) + } + CompressedPositionStorage::SharedStream(_) => { + unreachable!("shared position stream is not supported for plain postings") + } + }, Self::Compressed(posting) => { - posting.positions = Some(positions.value(0).as_list::().clone()); + posting.positions = Some(positions); } } } @@ -1684,7 +2056,14 @@ impl PostingList { } pub fn into_builder(self, docs: &DocSet) -> PostingListBuilder { - let mut builder = PostingListBuilder::new(self.has_position()); + let posting_tail_codec = match &self { + Self::Plain(_) => PostingTailCodec::Fixed32, + Self::Compressed(posting) => posting.posting_tail_codec, + }; + let mut builder = PostingListBuilder::new_with_posting_tail_codec( + self.has_position(), + posting_tail_codec, + ); match self { // legacy format Self::Plain(posting) => { @@ -1843,7 +2222,8 @@ pub struct CompressedPostingList { // each binary is a block of compressed data // that contains `BLOCK_SIZE` doc ids and then `BLOCK_SIZE` frequencies pub blocks: LargeBinaryArray, - pub positions: Option, + pub posting_tail_codec: PostingTailCodec, + pub positions: Option, } impl DeepSizeOf for CompressedPostingList { @@ -1852,7 +2232,12 @@ impl DeepSizeOf for CompressedPostingList { + self .positions .as_ref() - .map(Array::get_buffer_memory_size) + .map(|positions| match positions { + CompressedPositionStorage::LegacyPerDoc(positions) => { + positions.get_buffer_memory_size() + } + CompressedPositionStorage::SharedStream(stream) => stream.size(), + }) .unwrap_or(0) } } @@ -1862,31 +2247,56 @@ impl CompressedPostingList { blocks: LargeBinaryArray, max_score: f32, length: u32, - positions: Option, + posting_tail_codec: PostingTailCodec, + positions: Option, ) -> Self { Self { max_score, length, blocks, + posting_tail_codec, positions, } } - pub fn from_batch(batch: &RecordBatch, max_score: f32, length: u32) -> Self { + pub fn from_batch( + batch: &RecordBatch, + max_score: f32, + length: u32, + posting_tail_codec: PostingTailCodec, + ) -> Self { debug_assert_eq!(batch.num_rows(), 1); let blocks = batch[POSTING_COL] .as_list::() .value(0) .as_binary::() .clone(); - let positions = batch - .column_by_name(POSITION_COL) - .map(|col| col.as_list::().value(0).as_list::().clone()); + let positions = if let Some(col) = batch.column_by_name(COMPRESSED_POSITION_COL) { + let bytes = col.as_binary::().value(0).to_vec(); + let block_offsets = batch[POSITION_BLOCK_OFFSET_COL] + .as_list::() + .value(0) + .as_primitive::() + .values() + .to_vec(); + let codec = parse_shared_position_codec(batch.schema_ref().metadata()) + .expect("shared position stream codec metadata should be valid"); + Some(CompressedPositionStorage::SharedStream( + SharedPositionStream::new(codec, block_offsets, bytes), + )) + } else { + batch.column_by_name(POSITION_COL).map(|col| { + CompressedPositionStorage::LegacyPerDoc( + col.as_list::().value(0).as_list::().clone(), + ) + }) + }; Self { max_score, length, blocks, + posting_tail_codec, positions, } } @@ -1895,6 +2305,7 @@ impl CompressedPostingList { CompressedPostingListIterator::new( self.length as usize, self.blocks.clone(), + self.posting_tail_codec, self.positions.clone(), ) } @@ -1906,7 +2317,13 @@ impl CompressedPostingList { pub fn block_least_doc_id(&self, block_idx: usize) -> u32 { let block = self.blocks.value(block_idx); - block[4..8].try_into().map(u32::from_le_bytes).unwrap() + let remainder = self.length as usize % BLOCK_SIZE; + let is_remainder_block = remainder > 0 && block_idx + 1 == self.blocks.len(); + if is_remainder_block { + super::encoding::read_posting_tail_first_doc(block, self.posting_tail_codec) + } else { + block[4..8].try_into().map(u32::from_le_bytes).unwrap() + } } } @@ -1952,9 +2369,19 @@ impl EncodedBlocks { self.bytes[start..start + 4].copy_from_slice(&score.to_le_bytes()); } - fn append_remainder_block(&mut self, doc_ids: &[u32], frequencies: &[u32]) -> Result<()> { + fn append_remainder_block_with_codec( + &mut self, + doc_ids: &[u32], + frequencies: &[u32], + codec: PostingTailCodec, + ) -> Result<()> { self.offsets.push(self.bytes.len() as u32); - super::encoding::encode_remainder_posting_block_into(doc_ids, frequencies, &mut self.bytes) + super::encoding::encode_remainder_posting_block_into( + doc_ids, + frequencies, + codec, + &mut self.bytes, + ) } fn into_array(mut self) -> LargeBinaryArray { @@ -1974,46 +2401,49 @@ impl EncodedBlocks { } #[derive(Debug, Clone, PartialEq, Eq, Default)] -struct CompressedDocPositions { - blocks: Vec>, +struct EncodedPositionBlocks { + offsets: Vec, + bytes: Vec, } -impl CompressedDocPositions { - fn from_positions(positions: &[u32]) -> Result { - let compressed = compress_positions(positions)?; - let blocks = compressed - .iter() - .map(|block| { - block - .map(|value| value.to_vec()) - .ok_or_else(|| Error::index("null compressed position block".to_owned())) - }) - .collect::>>()?; - Ok(Self { blocks }) +impl EncodedPositionBlocks { + fn size(&self) -> usize { + self.offsets.capacity() * std::mem::size_of::() + self.bytes.capacity() } - fn size(&self) -> usize { - self.blocks - .iter() - .map(|block| block.capacity()) - .sum::() - + self.blocks.capacity() * std::mem::size_of::>() + fn block(&self, index: usize) -> &[u8] { + let start = self.offsets[index] as usize; + let end = self + .offsets + .get(index + 1) + .map(|offset| *offset as usize) + .unwrap_or(self.bytes.len()); + &self.bytes[start..end] } - fn decompress(&self) -> Vec { - let mut builder = LargeBinaryBuilder::with_capacity(self.blocks.len(), 0); - for block in &self.blocks { - builder.append_value(block); - } - super::encoding::decompress_positions(&builder.finish()) + fn push_encoded_block(&mut self, block: &[u8]) -> usize { + let start = self.bytes.len(); + self.offsets.push(start as u32); + self.bytes.extend_from_slice(block); + self.bytes.len() - start + } + + fn into_stream(self) -> SharedPositionStream { + SharedPositionStream::new(PositionStreamCodec::PackedDelta, self.offsets, self.bytes) } } #[derive(Debug)] pub struct PostingListBuilder { + with_positions: bool, + posting_tail_codec: PostingTailCodec, encoded_blocks: Option>, - positions: Option>, + encoded_position_blocks: Option>, tail_entries: Vec, + tail_positions: PositionBlockBuilder, + open_doc_id: Option, + open_doc_frequency: u32, + open_doc_last_position: Option, memory_size_bytes: u32, len: u32, } @@ -2023,18 +2453,49 @@ pub(super) struct PostingListBatchBuilder { postings: ListBuilder, max_scores: Float32Builder, lengths: UInt32Builder, - positions: Option>>, + positions: BatchPositionsBuilder, len: usize, } +enum BatchPositionsBuilder { + None, + Legacy(ListBuilder>), + Shared { + bytes: LargeBinaryBuilder, + block_offsets: ListBuilder, + }, +} + +struct PostingListParts<'a> { + with_positions: bool, + posting_tail_codec: PostingTailCodec, + length: usize, + encoded_blocks: EncodedBlocks, + encoded_position_blocks: EncodedPositionBlocks, + tail_entries: &'a [RawDocInfo], + tail_position_block: Option>, +} + impl PostingListBatchBuilder { - pub fn new(schema: SchemaRef, with_positions: bool, capacity: usize) -> Self { - let positions = with_positions.then(|| { - ListBuilder::with_capacity( - ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity), + pub fn new( + schema: SchemaRef, + with_positions: bool, + format_version: InvertedListFormatVersion, + capacity: usize, + ) -> Self { + let positions = if !with_positions { + BatchPositionsBuilder::None + } else if format_version.uses_shared_position_stream() { + BatchPositionsBuilder::Shared { + bytes: LargeBinaryBuilder::with_capacity(capacity, 0), + block_offsets: ListBuilder::with_capacity(UInt32Builder::new(), capacity), + } + } else { + BatchPositionsBuilder::Legacy(ListBuilder::with_capacity( + ListBuilder::new(LargeBinaryBuilder::new()), capacity, - ) - }); + )) + }; Self { schema, postings: ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity), @@ -2058,7 +2519,7 @@ impl PostingListBatchBuilder { compressed: LargeBinaryArray, max_score: f32, length: u32, - positions: Option<&[CompressedDocPositions]>, + positions: Option<&CompressedPositionStorage>, ) -> Result<()> { { let values = self.postings.values(); @@ -2070,20 +2531,55 @@ impl PostingListBatchBuilder { self.max_scores.append_value(max_score); self.lengths.append_value(length); - if let Some(position_builder) = self.positions.as_mut() { - let positions = positions.ok_or_else(|| { - Error::index("positions builder missing position data".to_owned()) - })?; - { - let docs_builder = position_builder.values(); - for compressed_doc in positions { - for block in &compressed_doc.blocks { - docs_builder.values().append_value(block); + match &mut self.positions { + BatchPositionsBuilder::None => {} + BatchPositionsBuilder::Shared { + bytes, + block_offsets, + } => { + let positions = positions.ok_or_else(|| { + Error::index(format!( + "positions builder missing position data for posting length {}", + length + )) + })?; + let CompressedPositionStorage::SharedStream(positions) = positions else { + return Err(Error::index( + "shared positions builder received legacy positions".to_owned(), + )); + }; + bytes.append_value(positions.bytes()); + let offsets_builder = block_offsets.values(); + for &offset in positions.block_offsets() { + offsets_builder.append_value(offset); + } + block_offsets.append(true); + } + BatchPositionsBuilder::Legacy(position_lists) => { + let positions = positions.ok_or_else(|| { + Error::index(format!( + "positions builder missing position data for posting length {}", + length + )) + })?; + let CompressedPositionStorage::LegacyPerDoc(positions) = positions else { + return Err(Error::index( + "legacy positions builder received shared position stream".to_owned(), + )); + }; + let docs_builder = position_lists.values(); + for doc_idx in 0..positions.len() { + let doc_positions = positions.value(doc_idx); + let compressed_positions = doc_positions.as_binary::(); + for block_idx in 0..compressed_positions.len() { + docs_builder + .values() + .append_value(compressed_positions.value(block_idx)); } docs_builder.append(true); } + position_lists.append(true); } - position_builder.append(true); } self.len += 1; @@ -2096,8 +2592,18 @@ impl PostingListBatchBuilder { Arc::new(self.max_scores.finish()) as ArrayRef, Arc::new(self.lengths.finish()) as ArrayRef, ]; - if let Some(positions) = self.positions.as_mut() { - columns.push(Arc::new(positions.finish()) as ArrayRef); + match &mut self.positions { + BatchPositionsBuilder::None => {} + BatchPositionsBuilder::Legacy(position_lists) => { + columns.push(Arc::new(position_lists.finish()) as ArrayRef); + } + BatchPositionsBuilder::Shared { + bytes, + block_offsets, + } => { + columns.push(Arc::new(bytes.finish()) as ArrayRef); + columns.push(Arc::new(block_offsets.finish()) as ArrayRef); + } } self.len = 0; RecordBatch::try_new(self.schema.clone(), columns).map_err(Error::from) @@ -2110,14 +2616,30 @@ impl PostingListBuilder { } pub fn has_positions(&self) -> bool { - self.positions.is_some() + self.with_positions } - pub fn new(_with_position: bool) -> Self { + pub fn new(with_position: bool) -> Self { + Self::new_with_posting_tail_codec( + with_position, + current_fts_format_version().posting_tail_codec(), + ) + } + + pub fn new_with_posting_tail_codec( + with_position: bool, + posting_tail_codec: PostingTailCodec, + ) -> Self { Self { + with_positions: with_position, + posting_tail_codec, encoded_blocks: None, - positions: None, + encoded_position_blocks: None, tail_entries: Vec::new(), + tail_positions: PositionBlockBuilder::default(), + open_doc_id: None, + open_doc_frequency: 0, + open_doc_last_position: None, len: 0, memory_size_bytes: 0, } @@ -2139,32 +2661,63 @@ impl PostingListBuilder { &self, mut visit: impl FnMut(u32, u32, Option>) -> std::result::Result<(), E>, ) -> std::result::Result<(), E> { - let mut position_index = 0usize; let mut doc_ids = Vec::with_capacity(BLOCK_SIZE); let mut frequencies = Vec::with_capacity(BLOCK_SIZE); + let mut decoded_positions = Vec::new(); + let mut position_block_index = 0usize; if let Some(encoded_blocks) = self.encoded_blocks.as_deref() { for block in encoded_blocks.iter() { doc_ids.clear(); frequencies.clear(); super::encoding::decode_full_posting_block(block, &mut doc_ids, &mut frequencies); + decoded_positions.clear(); + if self.with_positions { + let position_blocks = self + .encoded_position_blocks + .as_deref() + .expect("positions must exist for posting list"); + super::encoding::decode_position_stream_block( + position_blocks.block(position_block_index), + &frequencies, + PositionStreamCodec::PackedDelta, + &mut decoded_positions, + ) + .expect("position stream decoding should succeed"); + position_block_index += 1; + } + let mut offset = 0usize; for (doc_id, frequency) in doc_ids.iter().copied().zip(frequencies.iter().copied()) { - let positions = self.positions.as_ref().map(|positions| { - let decompressed = positions[position_index].decompress(); - position_index += 1; - decompressed + let positions = self.with_positions.then(|| { + let end = offset + frequency as usize; + let doc_positions = decoded_positions[offset..end].to_vec(); + offset = end; + doc_positions }); visit(doc_id, frequency, positions)?; } } } + let mut decoded_tail_positions = Vec::new(); + if self.with_positions && !self.tail_entries.is_empty() { + let tail_frequencies = self + .tail_entries + .iter() + .map(|entry| entry.frequency) + .collect::>(); + self.tail_positions + .decode_into(tail_frequencies.as_slice(), &mut decoded_tail_positions) + .expect("tail position stream decoding should succeed"); + } + let mut tail_offset = 0usize; for entry in &self.tail_entries { - let positions = self.positions.as_ref().map(|positions| { - let decompressed = positions[position_index].decompress(); - position_index += 1; - decompressed + let positions = self.with_positions.then(|| { + let end = tail_offset + entry.frequency as usize; + let doc_positions = decoded_tail_positions[tail_offset..end].to_vec(); + tail_offset = end; + doc_positions }); visit(entry.doc_id, entry.frequency, positions)?; } @@ -2173,6 +2726,10 @@ impl PostingListBuilder { } pub fn add(&mut self, doc_id: u32, term_positions: PositionRecorder) { + debug_assert!( + self.open_doc_id.is_none(), + "cannot add closed doc while a positions doc is still open" + ); let tail_entries_capacity_before = self.tail_entries.capacity(); self.tail_entries .push(RawDocInfo::new(doc_id, term_positions.len())); @@ -2184,23 +2741,12 @@ impl PostingListBuilder { ); } if let PositionRecorder::Position(positions_in_doc) = term_positions { - if self.positions.is_none() { - self.positions = Some(Vec::new()); - self.add_memory_bytes(std::mem::size_of::>()); - } - let positions = self.positions.as_mut().expect("positions must exist"); - let positions_capacity_before = positions.capacity(); - let compressed_doc = CompressedDocPositions::from_positions(&positions_in_doc) - .expect("position compression should succeed"); - let compressed_doc_size = compressed_doc.size(); - positions.push(compressed_doc); - let positions_capacity_after = positions.capacity(); - let mut positions_delta = compressed_doc_size; - if positions_capacity_after > positions_capacity_before { - positions_delta += (positions_capacity_after - positions_capacity_before) - * std::mem::size_of::(); - } - self.add_memory_bytes(positions_delta); + debug_assert!(self.with_positions); + let old_size = self.tail_positions.size(); + self.tail_positions + .append_doc_positions(positions_in_doc.as_slice()) + .expect("position stream encoding should succeed"); + self.adjust_tail_positions_size(old_size); } self.len += 1; @@ -2210,35 +2756,79 @@ impl PostingListBuilder { } } - fn collect_entries(&self) -> Vec<(u32, u32, Option>)> { - let mut entries = Vec::with_capacity(self.len()); - let mut position_index = 0usize; + pub fn add_occurrence(&mut self, doc_id: u32, position: u32) -> Result { + if !self.with_positions { + return Err(Error::index( + "cannot append streamed positions to a posting list without positions".to_owned(), + )); + } - if let Some(encoded_blocks) = self.encoded_blocks.as_deref() { - for block in encoded_blocks.iter() { - let mut doc_ids = Vec::with_capacity(BLOCK_SIZE); - let mut frequencies = Vec::with_capacity(BLOCK_SIZE); - super::encoding::decode_full_posting_block(block, &mut doc_ids, &mut frequencies); - for (doc_id, frequency) in doc_ids.into_iter().zip(frequencies.into_iter()) { - let positions = self.positions.as_ref().map(|positions| { - let decompressed = positions[position_index].decompress(); - position_index += 1; - decompressed - }); - entries.push((doc_id, frequency, positions)); - } + match self.open_doc_id { + Some(open_doc_id) if open_doc_id == doc_id => { + let old_size = self.tail_positions.size(); + self.tail_positions + .append_position(position, self.open_doc_last_position)?; + self.adjust_tail_positions_size(old_size); + self.open_doc_frequency += 1; + self.open_doc_last_position = Some(position); + Ok(false) + } + Some(open_doc_id) => Err(Error::index(format!( + "posting list received doc {} before finishing open doc {}", + doc_id, open_doc_id + ))), + None => { + let old_size = self.tail_positions.size(); + self.tail_positions.append_position(position, None)?; + self.adjust_tail_positions_size(old_size); + self.open_doc_id = Some(doc_id); + self.open_doc_frequency = 1; + self.open_doc_last_position = Some(position); + self.len += 1; + Ok(true) } } + } - for entry in &self.tail_entries { - let positions = self.positions.as_ref().map(|positions| { - let decompressed = positions[position_index].decompress(); - position_index += 1; - decompressed - }); - entries.push((entry.doc_id, entry.frequency, positions)); + pub fn finish_open_doc(&mut self, doc_id: u32) -> Result<()> { + if !self.with_positions { + return Ok(()); } + match self.open_doc_id { + Some(open_doc_id) if open_doc_id == doc_id => { + let tail_entries_capacity_before = self.tail_entries.capacity(); + self.tail_entries + .push(RawDocInfo::new(doc_id, self.open_doc_frequency)); + let tail_entries_capacity_after = self.tail_entries.capacity(); + if tail_entries_capacity_after > tail_entries_capacity_before { + self.add_memory_bytes( + (tail_entries_capacity_after - tail_entries_capacity_before) + * std::mem::size_of::(), + ); + } + self.open_doc_id = None; + self.open_doc_frequency = 0; + self.open_doc_last_position = None; + if self.tail_entries.len() == BLOCK_SIZE { + self.flush_tail_block()?; + } + Ok(()) + } + Some(open_doc_id) => Err(Error::index(format!( + "attempted to finish doc {} while doc {} is still open", + doc_id, open_doc_id + ))), + None => Ok(()), + } + } + fn collect_entries(&self) -> Vec<(u32, u32, Option>)> { + let mut entries = Vec::with_capacity(self.len()); + self.for_each_entry(|doc_id, frequency, positions| { + entries.push((doc_id, frequency, positions)); + Ok::<(), ()>(()) + }) + .expect("collecting posting list entries should not fail"); entries } @@ -2252,26 +2842,38 @@ impl PostingListBuilder { .expect("encoded blocks must exist") } + fn encoded_position_blocks_mut(&mut self) -> &mut EncodedPositionBlocks { + if self.encoded_position_blocks.is_none() { + self.encoded_position_blocks = Some(Box::default()); + self.add_memory_bytes(std::mem::size_of::()); + } + self.encoded_position_blocks + .as_deref_mut() + .expect("encoded position blocks must exist") + } + fn flush_tail_block(&mut self) -> Result<()> { if self.tail_entries.is_empty() { return Ok(()); } + debug_assert!( + self.open_doc_id.is_none(), + "cannot flush a posting block while a document is still open" + ); debug_assert_eq!(self.tail_entries.len(), BLOCK_SIZE); + let mut doc_ids = [0u32; BLOCK_SIZE]; + let mut frequencies = [0u32; BLOCK_SIZE]; + for (index, entry) in self.tail_entries.iter().enumerate() { + doc_ids[index] = entry.doc_id; + frequencies[index] = entry.frequency; + } let encoded_blocks_size_before = self .encoded_blocks .as_ref() .map(|encoded_blocks| encoded_blocks.size()) .unwrap_or(0usize); - { - let mut doc_ids = [0u32; BLOCK_SIZE]; - let mut frequencies = [0u32; BLOCK_SIZE]; - for (index, entry) in self.tail_entries.iter().enumerate() { - doc_ids[index] = entry.doc_id; - frequencies[index] = entry.frequency; - } - self.encoded_blocks_mut() - .push_full_block(&doc_ids, &frequencies)?; - } + self.encoded_blocks_mut() + .push_full_block(&doc_ids, &frequencies)?; let encoded_blocks_size_after = self .encoded_blocks .as_ref() @@ -2280,10 +2882,41 @@ impl PostingListBuilder { if encoded_blocks_size_after > encoded_blocks_size_before { self.add_memory_bytes(encoded_blocks_size_after - encoded_blocks_size_before); } + if self.with_positions { + let encoded_positions_size_before = self + .encoded_position_blocks + .as_ref() + .map(|encoded| encoded.size()) + .unwrap_or(0usize); + let released_tail_positions_bytes = self.tail_positions.size(); + let tail_position_block = std::mem::take(&mut self.tail_positions).finish(); + self.encoded_position_blocks_mut() + .push_encoded_block(tail_position_block.as_slice()); + let encoded_positions_size_after = self + .encoded_position_blocks + .as_ref() + .map(|encoded| encoded.size()) + .unwrap_or(0usize); + if released_tail_positions_bytes > 0 { + self.subtract_memory_bytes(released_tail_positions_bytes); + } + if encoded_positions_size_after > encoded_positions_size_before { + self.add_memory_bytes(encoded_positions_size_after - encoded_positions_size_before); + } + } self.tail_entries.clear(); Ok(()) } + fn adjust_tail_positions_size(&mut self, old_size: usize) { + let new_size = self.tail_positions.size(); + if new_size > old_size { + self.add_memory_bytes(new_size - old_size); + } else if old_size > new_size { + self.subtract_memory_bytes(old_size - new_size); + } + } + fn add_memory_bytes(&mut self, bytes: usize) { self.memory_size_bytes = self .memory_size_bytes @@ -2293,25 +2926,45 @@ impl PostingListBuilder { .expect("posting list memory size overflowed u32"); } - fn build_positions_column( - positions: Option<&[CompressedDocPositions]>, - len: usize, - ) -> Result> { + fn subtract_memory_bytes(&mut self, bytes: usize) { + self.memory_size_bytes = self + .memory_size_bytes + .checked_sub( + u32::try_from(bytes).expect("posting list memory size delta overflowed u32"), + ) + .expect("posting list memory size underflowed u32"); + } + + fn build_position_columns( + positions: Option, + ) -> Result> { let Some(positions) = positions else { - return Ok(None); + return Ok(Vec::new()); }; + match positions { + CompressedPositionStorage::LegacyPerDoc(positions) => { + Ok(vec![Arc::new(ListArray::try_new( + Arc::new(Field::new("item", positions.data_type().clone(), true)), + OffsetBuffer::new(ScalarBuffer::from(vec![0_i32, positions.len() as i32])), + Arc::new(positions) as ArrayRef, + None, + )?) as ArrayRef]) + } + CompressedPositionStorage::SharedStream(positions) => { + let mut columns = Vec::with_capacity(2); + columns.push( + Arc::new(LargeBinaryArray::from(vec![Some(positions.bytes())])) as ArrayRef, + ); - let mut position_builder = - ListBuilder::new(ListBuilder::with_capacity(LargeBinaryBuilder::new(), len)); - let docs_builder = position_builder.values(); - for compressed_doc in positions.iter() { - for block in &compressed_doc.blocks { - docs_builder.values().append_value(block); + let mut offsets_builder = ListBuilder::new(UInt32Builder::new()); + for &offset in positions.block_offsets() { + offsets_builder.values().append_value(offset); + } + offsets_builder.append(true); + columns.push(Arc::new(offsets_builder.finish()) as ArrayRef); + Ok(columns) } - docs_builder.append(true); } - position_builder.append(true); - Ok(Some(Arc::new(position_builder.finish()) as ArrayRef)) } fn build_batch( @@ -2319,6 +2972,7 @@ impl PostingListBuilder { compressed: LargeBinaryArray, max_score: f32, schema: SchemaRef, + positions: Option, ) -> Result { let length = self.len(); let offsets = OffsetBuffer::new(ScalarBuffer::from(vec![0, compressed.len() as i32])); @@ -2334,38 +2988,81 @@ impl PostingListBuilder { length as u32, ))) as ArrayRef, ]; - - if let Some(position_col) = Self::build_positions_column(self.positions.as_deref(), length)? - { - columns.push(position_col); - } + columns.extend(Self::build_position_columns(positions)?); let batch = RecordBatch::try_new(schema, columns)?; Ok(batch) } + fn build_legacy_positions(&self) -> Result { + let mut positions_builder = ListBuilder::new(LargeBinaryBuilder::new()); + self.for_each_entry(|_doc_id, frequency, positions| { + let positions = positions.ok_or_else(|| { + Error::index(format!( + "legacy position writer missing positions for frequency {}", + frequency + )) + })?; + let compressed = super::encoding::compress_positions(positions.as_slice())?; + for block_idx in 0..compressed.len() { + positions_builder + .values() + .append_value(compressed.value(block_idx)); + } + positions_builder.append(true); + Ok::<(), Error>(()) + })?; + Ok(positions_builder.finish()) + } + pub(super) fn append_to_batch_with_docs( self, docs: &DocSet, batch_builder: &mut PostingListBatchBuilder, + format_version: InvertedListFormatVersion, ) -> Result<()> { + let legacy_positions = + if self.with_positions && !format_version.uses_shared_position_stream() { + Some(self.build_legacy_positions()?) + } else { + None + }; let Self { + with_positions, + posting_tail_codec, encoded_blocks, - positions, + encoded_position_blocks, tail_entries, + tail_positions, + open_doc_id, + open_doc_frequency, + open_doc_last_position, len, .. } = self; - let length = len as usize; - let (compressed, max_score) = Self::build_compressed_with_scores_from_parts( - length, - encoded_blocks + debug_assert!(open_doc_id.is_none()); + debug_assert_eq!(open_doc_frequency, 0); + debug_assert!(open_doc_last_position.is_none()); + let parts = PostingListParts { + with_positions, + posting_tail_codec, + length: len as usize, + encoded_blocks: encoded_blocks .map(|encoded_blocks| *encoded_blocks) .unwrap_or_default(), - tail_entries.as_slice(), - docs, - )?; - batch_builder.append(compressed, max_score, len, positions.as_deref()) + encoded_position_blocks: encoded_position_blocks + .map(|encoded_positions| *encoded_positions) + .unwrap_or_default(), + tail_entries: tail_entries.as_slice(), + tail_position_block: with_positions.then(|| tail_positions.finish()), + }; + let (compressed, shared_positions, max_score) = + Self::build_compressed_with_scores_from_parts(parts, docs)?; + let positions = match legacy_positions { + Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)), + None => shared_positions.map(CompressedPositionStorage::SharedStream), + }; + batch_builder.append(compressed, max_score, len, positions.as_ref()) } fn extend_tail_components( @@ -2380,11 +3077,18 @@ impl PostingListBuilder { } fn build_compressed_with_scores_from_parts( - length: usize, - mut encoded_blocks: EncodedBlocks, - tail_entries: &[RawDocInfo], + parts: PostingListParts<'_>, docs: &DocSet, - ) -> Result<(LargeBinaryArray, f32)> { + ) -> Result<(LargeBinaryArray, Option, f32)> { + let PostingListParts { + with_positions, + posting_tail_codec, + length, + mut encoded_blocks, + mut encoded_position_blocks, + tail_entries, + tail_position_block, + } = parts; let avgdl = docs.average_length(); let idf_scale = idf(length, docs.len()) * (K1 + 1.0); let mut max_score = f32::MIN; @@ -2417,18 +3121,37 @@ impl PostingListBuilder { frequencies.iter().copied(), ); max_score = max_score.max(block_score); - encoded_blocks.append_remainder_block(doc_ids.as_slice(), frequencies.as_slice())?; + encoded_blocks.append_remainder_block_with_codec( + doc_ids.as_slice(), + frequencies.as_slice(), + posting_tail_codec, + )?; encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score); + if with_positions { + encoded_position_blocks.push_encoded_block( + tail_position_block + .as_deref() + .expect("tail position block must exist for postings with positions"), + ); + } } - Ok((encoded_blocks.into_array(), max_score)) + Ok(( + encoded_blocks.into_array(), + with_positions.then(|| encoded_position_blocks.into_stream()), + max_score, + )) } fn build_compressed_with_block_scores_from_parts( + with_positions: bool, + posting_tail_codec: PostingTailCodec, mut encoded_blocks: EncodedBlocks, + mut encoded_position_blocks: EncodedPositionBlocks, tail_entries: &[RawDocInfo], + tail_position_block: Option>, mut block_max_scores: impl Iterator, - ) -> Result<(LargeBinaryArray, f32)> { + ) -> Result<(LargeBinaryArray, Option, f32)> { let mut max_score = f32::MIN; let mut doc_ids = Vec::with_capacity(BLOCK_SIZE); let mut frequencies = Vec::with_capacity(BLOCK_SIZE); @@ -2447,69 +3170,160 @@ impl PostingListBuilder { .ok_or_else(|| Error::index("missing tail block max score".to_owned()))?; max_score = max_score.max(block_score); Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies); - encoded_blocks.append_remainder_block(doc_ids.as_slice(), frequencies.as_slice())?; + encoded_blocks.append_remainder_block_with_codec( + doc_ids.as_slice(), + frequencies.as_slice(), + posting_tail_codec, + )?; encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score); + if with_positions { + encoded_position_blocks.push_encoded_block( + tail_position_block + .as_deref() + .expect("tail position block must exist for postings with positions"), + ); + } } - Ok((encoded_blocks.into_array(), max_score)) + Ok(( + encoded_blocks.into_array(), + with_positions.then(|| encoded_position_blocks.into_stream()), + max_score, + )) } pub fn to_batch(self, block_max_scores: Vec) -> Result { - let schema = inverted_list_schema(self.has_positions()); + let format_version = if self.posting_tail_codec == PostingTailCodec::Fixed32 { + InvertedListFormatVersion::V1 + } else { + InvertedListFormatVersion::V2 + }; + let schema = inverted_list_schema_for_version(self.has_positions(), format_version); + let legacy_positions = + if self.with_positions && !format_version.uses_shared_position_stream() { + Some(self.build_legacy_positions()?) + } else { + None + }; let Self { + with_positions, + posting_tail_codec, encoded_blocks, - positions, + encoded_position_blocks, tail_entries, + tail_positions, + open_doc_id, + open_doc_frequency, + open_doc_last_position, len, .. } = self; - let (compressed, max_score) = Self::build_compressed_with_block_scores_from_parts( - encoded_blocks - .map(|encoded_blocks| *encoded_blocks) - .unwrap_or_default(), - tail_entries.as_slice(), - block_max_scores.into_iter(), - )?; + debug_assert!(open_doc_id.is_none()); + debug_assert_eq!(open_doc_frequency, 0); + debug_assert!(open_doc_last_position.is_none()); + let (compressed, shared_positions, max_score) = + Self::build_compressed_with_block_scores_from_parts( + with_positions, + posting_tail_codec, + encoded_blocks + .map(|encoded_blocks| *encoded_blocks) + .unwrap_or_default(), + encoded_position_blocks + .map(|encoded_positions| *encoded_positions) + .unwrap_or_default(), + tail_entries.as_slice(), + with_positions.then(|| tail_positions.finish()), + block_max_scores.into_iter(), + )?; let builder = Self { + with_positions, + posting_tail_codec, encoded_blocks: None, - positions, + encoded_position_blocks: None, tail_entries: Vec::new(), + tail_positions: PositionBlockBuilder::default(), + open_doc_id: None, + open_doc_frequency: 0, + open_doc_last_position: None, memory_size_bytes: 0, len, }; - builder.build_batch(compressed, max_score, schema) + let positions = match legacy_positions { + Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)), + None => shared_positions.map(CompressedPositionStorage::SharedStream), + }; + builder.build_batch(compressed, max_score, schema, positions) } pub fn to_batch_with_docs(self, docs: &DocSet, schema: SchemaRef) -> Result { + let format_version = if schema.column_with_name(POSITION_COL).is_some() + && schema.column_with_name(COMPRESSED_POSITION_COL).is_none() + { + InvertedListFormatVersion::V1 + } else { + InvertedListFormatVersion::V2 + }; + let legacy_positions = + if self.with_positions && !format_version.uses_shared_position_stream() { + Some(self.build_legacy_positions()?) + } else { + None + }; let Self { + with_positions, + posting_tail_codec, encoded_blocks, - positions, + encoded_position_blocks, tail_entries, + tail_positions, + open_doc_id, + open_doc_frequency, + open_doc_last_position, len, .. } = self; - let length = len as usize; - let (compressed, max_score) = Self::build_compressed_with_scores_from_parts( - length, - encoded_blocks + debug_assert!(open_doc_id.is_none()); + debug_assert_eq!(open_doc_frequency, 0); + debug_assert!(open_doc_last_position.is_none()); + let parts = PostingListParts { + with_positions, + posting_tail_codec, + length: len as usize, + encoded_blocks: encoded_blocks .map(|encoded_blocks| *encoded_blocks) .unwrap_or_default(), - tail_entries.as_slice(), - docs, - )?; + encoded_position_blocks: encoded_position_blocks + .map(|encoded_positions| *encoded_positions) + .unwrap_or_default(), + tail_entries: tail_entries.as_slice(), + tail_position_block: with_positions.then(|| tail_positions.finish()), + }; + let (compressed, shared_positions, max_score) = + Self::build_compressed_with_scores_from_parts(parts, docs)?; let builder = Self { + with_positions, + posting_tail_codec, encoded_blocks: None, - positions, + encoded_position_blocks: None, tail_entries: Vec::new(), + tail_positions: PositionBlockBuilder::default(), + open_doc_id: None, + open_doc_frequency: 0, + open_doc_last_position: None, memory_size_bytes: 0, len, }; - builder.build_batch(compressed, max_score, schema) + let positions = match legacy_positions { + Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)), + None => shared_positions.map(CompressedPositionStorage::SharedStream), + }; + builder.build_batch(compressed, max_score, schema, positions) } pub fn remap(&mut self, removed: &[u32]) { let mut cursor = 0; - let mut new_builder = Self::new(self.has_positions()); + let mut new_builder = + Self::new_with_posting_tail_codec(self.has_positions(), self.posting_tail_codec); for (doc_id, freq, positions) in self.iter() { while cursor < removed.len() && removed[cursor] < doc_id { cursor += 1; @@ -3246,24 +4060,36 @@ pub fn is_phrase_query(query: &str) -> bool { #[cfg(test)] mod tests { use crate::scalar::inverted::lance_tokenizer::DocType; + use datafusion::physical_plan::stream::RecordBatchStreamAdapter; + use futures::stream; use lance_core::cache::LanceCache; use lance_core::utils::tempfile::TempObjDir; use lance_io::object_store::ObjectStore; use crate::metrics::NoOpMetricsCollector; use crate::prefilter::NoFilter; + use crate::scalar::ScalarIndex; use crate::scalar::inverted::builder::{InnerBuilder, PositionRecorder, inverted_list_schema}; - use crate::scalar::inverted::encoding::decompress_posting_list; + use crate::scalar::inverted::encoding::{ + compress_positions, compress_posting_list_with_tail_codec, + decompress_posting_list_with_tail_codec, encode_position_stream_block_into, + }; use crate::scalar::inverted::query::{FtsSearchParams, Operator}; use crate::scalar::lance_format::LanceIndexStore; - use arrow::array::AsArray; + use arrow::array::{AsArray, LargeBinaryBuilder, ListBuilder, UInt32Builder}; use arrow::datatypes::{Float32Type, UInt32Type}; + use arrow_array::{ArrayRef, Float32Array, RecordBatch, StringArray, UInt32Array, UInt64Array}; + use arrow_schema::{DataType, Field, Schema}; + use std::collections::HashMap; + use std::sync::Arc; use super::*; #[tokio::test] async fn test_posting_builder_remap() { - let mut builder = PostingListBuilder::new(false); + let posting_tail_codec = PostingTailCodec::Fixed32; + let mut builder = + PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec); let n = BLOCK_SIZE + 3; for i in 0..n { builder.add(i as u32, PositionRecorder::Count(1)); @@ -3271,7 +4097,8 @@ mod tests { let removed = vec![5, 7]; builder.remap(&removed); - let mut expected = PostingListBuilder::new(false); + let mut expected = + PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec); for i in 0..n - removed.len() { expected.add(i as u32, PositionRecorder::Count(1)); } @@ -3282,12 +4109,13 @@ mod tests { // BLOCK_SIZE + 3 elements should be reduced to BLOCK_SIZE + 1, // there are still 2 blocks. let batch = builder.to_batch(vec![1.0, 2.0]).unwrap(); - let (doc_ids, freqs) = decompress_posting_list( + let (doc_ids, freqs) = decompress_posting_list_with_tail_codec( (n - removed.len()) as u32, batch[POSTING_COL] .as_list::() .value(0) .as_binary::(), + posting_tail_codec, ) .unwrap(); assert!( @@ -3312,23 +4140,15 @@ mod tests { .iter() .map(|encoded_blocks| std::mem::size_of::() + encoded_blocks.size()) .sum::(); - let positions_size = builder - .positions - .as_ref() - .map(|positions| positions.iter().map(CompressedDocPositions::size).sum()) - .unwrap_or(0usize); - let positions_capacity = builder - .positions + let encoded_positions_size = builder + .encoded_position_blocks .as_ref() - .map(|positions| { - std::mem::size_of::>() - + positions.capacity() * std::mem::size_of::() - }) + .map(|positions| std::mem::size_of::() + positions.size()) .unwrap_or(0usize); (encoded_blocks_size + builder.tail_entries.capacity() * std::mem::size_of::() - + positions_capacity - + positions_size) as u64 + + builder.tail_positions.size() + + encoded_positions_size) as u64 } let mut builder = PostingListBuilder::new(true); @@ -3342,6 +4162,340 @@ mod tests { assert_eq!(builder.size(), tracked_memory_size(&builder)); } + #[test] + fn test_posting_builder_flush_releases_tail_position_capacity() { + let mut builder = PostingListBuilder::new(true); + let positions = smallvec::SmallVec::<[u32; 2]>::from_vec((0..1024).collect()); + for doc_id in 0..BLOCK_SIZE as u32 { + builder.add(doc_id, PositionRecorder::Position(positions.clone())); + } + + assert_eq!(builder.tail_positions.size(), 0); + assert_eq!(builder.size(), { + let encoded_blocks_size = builder + .encoded_blocks + .iter() + .map(|encoded_blocks| std::mem::size_of::() + encoded_blocks.size()) + .sum::(); + let encoded_positions_size = builder + .encoded_position_blocks + .as_ref() + .map(|positions| std::mem::size_of::() + positions.size()) + .unwrap_or(0usize); + (encoded_blocks_size + + builder.tail_entries.capacity() * std::mem::size_of::() + + builder.tail_positions.size() + + encoded_positions_size) as u64 + }); + } + + #[test] + fn test_posting_builder_streamed_positions_roundtrip() { + let mut builder = PostingListBuilder::new(true); + assert!(builder.add_occurrence(0, 1).unwrap()); + assert!(!builder.add_occurrence(0, 4).unwrap()); + assert!(!builder.add_occurrence(0, 9).unwrap()); + builder.finish_open_doc(0).unwrap(); + + assert!(builder.add_occurrence(2, 3).unwrap()); + builder.finish_open_doc(2).unwrap(); + + let entries = builder.iter().collect::>(); + assert_eq!( + entries, + vec![ + (0_u32, 3_u32, Some(vec![1_u32, 4_u32, 9_u32])), + (2_u32, 1_u32, Some(vec![3_u32])), + ] + ); + } + + #[test] + fn test_posting_builder_roundtrip_shared_positions() { + let entries = vec![ + (0_u32, vec![1_u32, 5]), + (2, vec![0, 4, 9]), + (4, vec![7]), + (8, vec![3, 10]), + (13, vec![2, 11, 30]), + ]; + let mut builder = + PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::VarintDelta); + for (doc_id, positions) in &entries { + builder.add( + *doc_id, + PositionRecorder::Position(positions.clone().into()), + ); + } + + let batch = builder.to_batch(vec![1.0]).unwrap(); + assert!(batch.column_by_name(COMPRESSED_POSITION_COL).is_some()); + assert!(batch.column_by_name(POSITION_COL).is_none()); + assert_eq!( + batch.schema_ref().metadata().get(POSTING_TAIL_CODEC_KEY), + Some(&PostingTailCodec::VarintDelta.as_str().to_owned()) + ); + assert_eq!( + batch.schema_ref().metadata().get(POSITIONS_LAYOUT_KEY), + Some(&POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned()) + ); + assert_eq!( + batch.schema_ref().metadata().get(POSITIONS_CODEC_KEY), + Some(&PositionStreamCodec::PackedDelta.as_str().to_owned()) + ); + + let posting = + PostingList::from_batch(&batch, Some(1.0), Some(entries.len() as u32)).unwrap(); + let actual = posting + .iter() + .map(|(doc_id, freq, positions)| { + (doc_id as u32, freq, positions.unwrap().collect::>()) + }) + .collect::>(); + let expected = entries + .iter() + .map(|(doc_id, positions)| (*doc_id, positions.len() as u32, positions.clone())) + .collect::>(); + assert_eq!(actual, expected); + } + + #[test] + fn test_posting_builder_roundtrip_legacy_positions() { + let entries = vec![(0_u32, vec![1_u32, 5]), (2, vec![0, 4, 9]), (4, vec![7])]; + let mut builder = + PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::Fixed32); + for (doc_id, positions) in &entries { + builder.add( + *doc_id, + PositionRecorder::Position(positions.clone().into()), + ); + } + + let batch = builder.to_batch(vec![1.0]).unwrap(); + assert!(batch.column_by_name(POSITION_COL).is_some()); + assert!(batch.column_by_name(COMPRESSED_POSITION_COL).is_none()); + assert_eq!( + batch.schema_ref().metadata().get(POSTING_TAIL_CODEC_KEY), + None + ); + assert_eq!( + batch.schema_ref().metadata().get(POSITIONS_LAYOUT_KEY), + None + ); + assert_eq!(batch.schema_ref().metadata().get(POSITIONS_CODEC_KEY), None); + + let posting = + PostingList::from_batch(&batch, Some(1.0), Some(entries.len() as u32)).unwrap(); + let actual = posting + .iter() + .map(|(doc_id, freq, positions)| { + (doc_id as u32, freq, positions.unwrap().collect::>()) + }) + .collect::>(); + let expected = entries + .iter() + .map(|(doc_id, positions)| (*doc_id, positions.len() as u32, positions.clone())) + .collect::>(); + assert_eq!(actual, expected); + } + + #[test] + fn test_resolve_fts_format_version_defaults_to_v1() { + assert_eq!( + resolve_fts_format_version(None).unwrap(), + InvertedListFormatVersion::V1 + ); + assert_eq!( + resolve_fts_format_version(Some("2")).unwrap(), + InvertedListFormatVersion::V2 + ); + } + + #[test] + fn test_legacy_compressed_positions_still_readable() { + let doc_ids = [1_u32, 3_u32]; + let frequencies = [2_u32, 3_u32]; + let posting = compress_posting_list_with_tail_codec( + doc_ids.len(), + doc_ids.iter(), + frequencies.iter(), + std::iter::once(1.0_f32), + PostingTailCodec::Fixed32, + ) + .unwrap(); + + let mut posting_builder = ListBuilder::new(LargeBinaryBuilder::new()); + for idx in 0..posting.len() { + posting_builder.values().append_value(posting.value(idx)); + } + posting_builder.append(true); + + let mut positions_builder = ListBuilder::new(ListBuilder::new(LargeBinaryBuilder::new())); + for positions in [vec![1_u32, 5_u32], vec![0_u32, 4_u32, 9_u32]] { + let compressed = compress_positions(&positions).unwrap(); + let doc_builder = positions_builder.values(); + for idx in 0..compressed.len() { + doc_builder.values().append_value(compressed.value(idx)); + } + doc_builder.append(true); + } + positions_builder.append(true); + + let schema = Arc::new(Schema::new(vec![ + Field::new( + POSTING_COL, + DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))), + false, + ), + Field::new(MAX_SCORE_COL, DataType::Float32, false), + Field::new(LENGTH_COL, DataType::UInt32, false), + Field::new( + POSITION_COL, + DataType::List(Arc::new(Field::new( + "item", + DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))), + true, + ))), + false, + ), + ])); + let batch = RecordBatch::try_new( + schema, + vec![ + Arc::new(posting_builder.finish()) as ArrayRef, + Arc::new(Float32Array::from(vec![1.0])) as ArrayRef, + Arc::new(UInt32Array::from(vec![doc_ids.len() as u32])) as ArrayRef, + Arc::new(positions_builder.finish()) as ArrayRef, + ], + ) + .unwrap(); + + let posting = + PostingList::from_batch(&batch, Some(1.0), Some(doc_ids.len() as u32)).unwrap(); + let actual = posting + .iter() + .map(|(doc_id, freq, positions)| { + (doc_id as u32, freq, positions.unwrap().collect::>()) + }) + .collect::>(); + assert_eq!(actual, vec![(1, 2, vec![1, 5]), (3, 3, vec![0, 4, 9]),]); + } + + #[test] + fn test_shared_stream_v2_without_codec_still_readable() { + let doc_ids = [1_u32, 3_u32]; + let frequencies = [2_u32, 3_u32]; + let posting = compress_posting_list_with_tail_codec( + doc_ids.len(), + doc_ids.iter(), + frequencies.iter(), + std::iter::once(1.0_f32), + PostingTailCodec::Fixed32, + ) + .unwrap(); + + let mut posting_builder = ListBuilder::new(LargeBinaryBuilder::new()); + for idx in 0..posting.len() { + posting_builder.values().append_value(posting.value(idx)); + } + posting_builder.append(true); + + let positions = vec![1_u32, 5_u32, 0_u32, 4_u32, 9_u32]; + let mut encoded_positions = Vec::new(); + encode_position_stream_block_into( + &positions, + &frequencies, + PositionStreamCodec::VarintDocDelta, + &mut encoded_positions, + ) + .unwrap(); + + let mut position_offsets = ListBuilder::new(UInt32Builder::new()); + position_offsets.values().append_value(0); + position_offsets.append(true); + + let schema = Arc::new(Schema::new_with_metadata( + vec![ + Field::new( + POSTING_COL, + DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))), + false, + ), + Field::new(MAX_SCORE_COL, DataType::Float32, false), + Field::new(LENGTH_COL, DataType::UInt32, false), + Field::new(COMPRESSED_POSITION_COL, DataType::LargeBinary, false), + Field::new( + POSITION_BLOCK_OFFSET_COL, + DataType::List(Arc::new(Field::new("item", DataType::UInt32, true))), + false, + ), + ], + HashMap::from([( + POSITIONS_LAYOUT_KEY.to_owned(), + POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(), + )]), + )); + let batch = RecordBatch::try_new( + schema, + vec![ + Arc::new(posting_builder.finish()) as ArrayRef, + Arc::new(Float32Array::from(vec![1.0])) as ArrayRef, + Arc::new(UInt32Array::from(vec![doc_ids.len() as u32])) as ArrayRef, + Arc::new(arrow_array::LargeBinaryArray::from(vec![Some( + encoded_positions.as_slice(), + )])) as ArrayRef, + Arc::new(position_offsets.finish()) as ArrayRef, + ], + ) + .unwrap(); + + let posting = + PostingList::from_batch(&batch, Some(1.0), Some(doc_ids.len() as u32)).unwrap(); + let actual = posting + .iter() + .map(|(doc_id, freq, positions)| { + (doc_id as u32, freq, positions.unwrap().collect::>()) + }) + .collect::>(); + assert_eq!(actual, vec![(1, 2, vec![1, 5]), (3, 3, vec![0, 4, 9]),]); + } + + #[test] + fn test_shared_position_stream_is_smaller_for_sparse_positions() { + let mut builder = + PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::VarintDelta); + let mut legacy_positions = Vec::with_capacity(BLOCK_SIZE * 4); + for doc_id in 0..(BLOCK_SIZE * 4) as u32 { + let mut positions = vec![doc_id * 3 + 1]; + if doc_id % 8 == 0 { + positions.push(doc_id * 3 + 2); + } + builder.add(doc_id, PositionRecorder::Position(positions.clone().into())); + legacy_positions.push(positions); + } + + let batch = builder.to_batch(vec![1.0; 4]).unwrap(); + let shared_positions_size = batch[COMPRESSED_POSITION_COL].get_buffer_memory_size() + + batch[POSITION_BLOCK_OFFSET_COL].get_buffer_memory_size(); + + let mut positions_builder = ListBuilder::new(ListBuilder::new(LargeBinaryBuilder::new())); + for positions in legacy_positions { + let compressed = compress_positions(&positions).unwrap(); + let doc_builder = positions_builder.values(); + for idx in 0..compressed.len() { + doc_builder.values().append_value(compressed.value(idx)); + } + doc_builder.append(true); + } + positions_builder.append(true); + let legacy_positions_size = positions_builder.finish().get_buffer_memory_size(); + + assert!( + shared_positions_size < legacy_positions_size, + "expected shared position stream to be smaller than legacy per-doc storage, shared={shared_positions_size}, legacy={legacy_positions_size}", + ); + } + #[test] fn test_posting_list_batch_matches_docset_scoring() { let mut docs = DocSet::default(); @@ -3748,4 +4902,171 @@ mod tests { ); } } + + #[tokio::test] + async fn test_phrase_query_reads_legacy_per_doc_positions() { + let tmpdir = TempObjDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + tmpdir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + let mut builder = InnerBuilder::new_with_format_version( + 0, + true, + TokenSetFormat::default(), + InvertedListFormatVersion::V1, + ); + builder.tokens.add("hello".to_owned()); + builder.tokens.add("world".to_owned()); + builder + .posting_lists + .push(PostingListBuilder::new_with_posting_tail_codec( + true, + PostingTailCodec::Fixed32, + )); + builder + .posting_lists + .push(PostingListBuilder::new_with_posting_tail_codec( + true, + PostingTailCodec::Fixed32, + )); + builder.posting_lists[0].add(0, PositionRecorder::Position(vec![0].into())); + builder.posting_lists[1].add(0, PositionRecorder::Position(vec![1].into())); + builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into())); + builder.posting_lists[1].add(1, PositionRecorder::Position(vec![2].into())); + builder.docs.append(100, 2); + builder.docs.append(101, 2); + builder.write(store.as_ref()).await.unwrap(); + + let metadata = std::collections::HashMap::from_iter(vec![ + ( + "partitions".to_owned(), + serde_json::to_string(&vec![0_u64]).unwrap(), + ), + ( + "params".to_owned(), + serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(), + ), + ( + TOKEN_SET_FORMAT_KEY.to_owned(), + TokenSetFormat::default().to_string(), + ), + ]); + let mut writer = store + .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty())) + .await + .unwrap(); + writer.finish_with_metadata(metadata).await.unwrap(); + + let cache = Arc::new(LanceCache::with_capacity(4096)); + let index = InvertedIndex::load(store.clone(), None, cache.as_ref()) + .await + .unwrap(); + + let tokens = Arc::new(Tokens::new( + vec!["hello".to_owned(), "world".to_owned()], + DocType::Text, + )); + let params = Arc::new( + FtsSearchParams::new() + .with_limit(Some(10)) + .with_phrase_slop(Some(0)), + ); + let prefilter = Arc::new(NoFilter); + let metrics = Arc::new(NoOpMetricsCollector); + + let (row_ids, _scores) = index + .bm25_search(tokens, params, Operator::And, prefilter, metrics) + .await + .unwrap(); + + assert_eq!(row_ids, vec![100]); + } + + #[tokio::test] + async fn test_update_preserves_loaded_v2_format_version() -> Result<()> { + let src_dir = TempObjDir::default(); + let dest_dir = TempObjDir::default(); + let src_store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + src_dir.clone(), + Arc::new(LanceCache::no_cache()), + )); + let dest_store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + dest_dir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + let format_version = InvertedListFormatVersion::V2; + let posting_tail_codec = format_version.posting_tail_codec(); + let mut partition = InnerBuilder::new_with_format_version( + 0, + false, + TokenSetFormat::default(), + format_version, + ); + partition.tokens.add("hello".to_owned()); + let mut posting_list = + PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec); + posting_list.add(0, PositionRecorder::Count(1)); + partition.posting_lists.push(posting_list); + partition.docs.append(100, 1); + partition.write(src_store.as_ref()).await?; + + let metadata = HashMap::from([ + ( + "partitions".to_owned(), + serde_json::to_string(&vec![0_u64]).unwrap(), + ), + ( + "params".to_owned(), + serde_json::to_string(&InvertedIndexParams::default()).unwrap(), + ), + ( + TOKEN_SET_FORMAT_KEY.to_owned(), + TokenSetFormat::default().to_string(), + ), + ( + POSTING_TAIL_CODEC_KEY.to_owned(), + posting_tail_codec.as_str().to_owned(), + ), + ]); + let mut writer = src_store + .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty())) + .await + .unwrap(); + writer.finish_with_metadata(metadata).await.unwrap(); + + let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?; + assert_eq!(index.index_version(), format_version.index_version()); + + let schema = Arc::new(Schema::new(vec![ + Field::new("doc", DataType::Utf8, true), + Field::new(ROW_ID, DataType::UInt64, false), + ])); + let docs = Arc::new(StringArray::from(vec![Some("hello again")])); + let row_ids = Arc::new(UInt64Array::from(vec![101u64])); + let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?; + let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)])); + let created = index + .update(Box::pin(stream), dest_store.as_ref(), None) + .await?; + + assert_eq!(created.index_version, format_version.index_version()); + + let updated = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?; + assert_eq!(updated.index_version(), format_version.index_version()); + assert_eq!(updated.partitions.len(), 2); + for partition in &updated.partitions { + assert_eq!( + partition.inverted_list.posting_tail_codec(), + posting_tail_codec + ); + } + + Ok(()) + } } diff --git a/rust/lance-index/src/scalar/inverted/iter.rs b/rust/lance-index/src/scalar/inverted/iter.rs index fcbb35fa5a8..dc07b15769c 100644 --- a/rust/lance-index/src/scalar/inverted/iter.rs +++ b/rust/lance-index/src/scalar/inverted/iter.rs @@ -2,12 +2,15 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use arrow::array::AsArray; -use arrow_array::{Array, LargeBinaryArray, ListArray}; +use arrow_array::{Array, LargeBinaryArray}; use super::{ - PostingList, + CompressedPositionStorage, PostingList, PostingTailCodec, builder::BLOCK_SIZE, - encoding::{decompress_positions, decompress_posting_block, decompress_posting_remainder}, + encoding::{ + decode_position_stream_block, decompress_positions, decompress_posting_block, + decompress_posting_remainder, + }, }; pub enum PostingListIterator<'a> { @@ -23,6 +26,7 @@ impl<'a> PostingListIterator<'a> { Self::Compressed(Box::new(CompressedPostingListIterator::new( posting.length as usize, posting.blocks.clone(), + posting.posting_tail_codec, posting.positions.clone(), ))) } @@ -48,20 +52,52 @@ impl<'a> Iterator for PostingListIterator<'a> { pub type PlainPostingListIterator<'a> = Box + 'a>>)> + 'a>; +struct OwnedPositionsIter { + positions: Box<[u32]>, + index: usize, +} + +impl OwnedPositionsIter { + fn new(positions: &[u32]) -> Self { + Self { + positions: Box::<[u32]>::from(positions), + index: 0, + } + } +} + +impl Iterator for OwnedPositionsIter { + type Item = u32; + + fn next(&mut self) -> Option { + let position = self.positions.get(self.index).copied()?; + self.index += 1; + Some(position) + } +} + pub struct CompressedPostingListIterator { remainder: usize, blocks: LargeBinaryArray, next_block_idx: usize, - positions: Option, - idx: u32, - iter: InnerIterator, + posting_tail_codec: PostingTailCodec, + positions: Option, + idx: usize, + doc_ids: Vec, + frequencies: Vec, + doc_idx_in_block: usize, + decoded_positions: Vec, + position_offsets: Vec, buffer: [u32; BLOCK_SIZE], } -type InnerIterator = std::iter::Zip, std::vec::IntoIter>; - impl CompressedPostingListIterator { - pub fn new(length: usize, blocks: LargeBinaryArray, positions: Option) -> Self { + pub fn new( + length: usize, + blocks: LargeBinaryArray, + posting_tail_codec: PostingTailCodec, + positions: Option, + ) -> Self { debug_assert!(length > 0, "length: {}", length); debug_assert_eq!( length.div_ceil(BLOCK_SIZE), @@ -75,9 +111,14 @@ impl CompressedPostingListIterator { remainder: length % BLOCK_SIZE, blocks, next_block_idx: 0, + posting_tail_codec, positions, idx: 0, - iter: std::iter::zip(Vec::new(), Vec::new()), + doc_ids: Vec::new(), + frequencies: Vec::new(), + doc_idx_in_block: 0, + decoded_positions: Vec::new(), + position_offsets: Vec::new(), buffer: [0; BLOCK_SIZE], } } @@ -87,13 +128,24 @@ impl Iterator for CompressedPostingListIterator { type Item = (u32, u32, Option>>); fn next(&mut self) -> Option { - if let Some((doc_id, freq)) = self.iter.next() { - let positions = self.positions.as_ref().map(|p| { - let compressed = p.value(self.idx as usize); - let positions = decompress_positions(compressed.as_binary()); - Box::new(positions.into_iter()) as _ + if self.doc_idx_in_block < self.doc_ids.len() { + let doc_id = self.doc_ids[self.doc_idx_in_block]; + let freq = self.frequencies[self.doc_idx_in_block]; + let positions = self.positions.as_ref().map(|storage| match storage { + CompressedPositionStorage::LegacyPerDoc(list) => { + let compressed = list.value(self.idx); + let positions = decompress_positions(compressed.as_binary()); + Box::new(positions.into_iter()) as Box> + } + CompressedPositionStorage::SharedStream(_) => { + let start = self.position_offsets[self.doc_idx_in_block]; + let end = self.position_offsets[self.doc_idx_in_block + 1]; + Box::new(OwnedPositionsIter::new(&self.decoded_positions[start..end])) + as Box> + } }); self.idx += 1; + self.doc_idx_in_block += 1; return Some((doc_id, freq, positions)); } @@ -104,19 +156,43 @@ impl Iterator for CompressedPostingListIterator { let compressed = self.blocks.value(self.next_block_idx); self.next_block_idx += 1; - let mut doc_ids = Vec::with_capacity(BLOCK_SIZE); - let mut frequencies = Vec::with_capacity(BLOCK_SIZE); + self.doc_ids.clear(); + self.frequencies.clear(); if self.next_block_idx == self.blocks.len() && self.remainder > 0 { decompress_posting_remainder( compressed, self.remainder, - &mut doc_ids, - &mut frequencies, + self.posting_tail_codec, + &mut self.doc_ids, + &mut self.frequencies, ); } else { - decompress_posting_block(compressed, &mut self.buffer, &mut doc_ids, &mut frequencies); + decompress_posting_block( + compressed, + &mut self.buffer, + &mut self.doc_ids, + &mut self.frequencies, + ); + } + self.doc_idx_in_block = 0; + self.decoded_positions.clear(); + self.position_offsets.clear(); + if let Some(CompressedPositionStorage::SharedStream(stream)) = self.positions.as_ref() { + decode_position_stream_block( + stream.block(self.next_block_idx - 1), + self.frequencies.as_slice(), + stream.codec(), + &mut self.decoded_positions, + ) + .expect("shared position stream decoding should succeed"); + self.position_offsets.reserve(self.frequencies.len() + 1); + self.position_offsets.push(0); + let mut offset = 0usize; + for &frequency in &self.frequencies { + offset += frequency as usize; + self.position_offsets.push(offset); + } } - self.iter = std::iter::zip(doc_ids, frequencies); self.next() } } diff --git a/rust/lance-index/src/scalar/inverted/wand.rs b/rust/lance-index/src/scalar/inverted/wand.rs index f813121fa42..7c09ff82952 100644 --- a/rust/lance-index/src/scalar/inverted/wand.rs +++ b/rust/lance-index/src/scalar/inverted/wand.rs @@ -7,9 +7,8 @@ use std::{cell::UnsafeCell, collections::BinaryHeap}; use std::{cmp::Reverse, fmt::Debug}; use arrow::array::AsArray; -use arrow::datatypes::{Int32Type, UInt32Type}; -use arrow_array::{Array, UInt32Array}; -use arrow_schema::DataType; +use arrow::datatypes::Int32Type; +use arrow_array::Array; use itertools::Itertools; use lance_core::Result; use lance_core::utils::address::RowAddress; @@ -17,18 +16,22 @@ use lance_core::utils::mask::RowAddrMask; use crate::metrics::MetricsCollector; +use super::{ + CompressedPositionStorage, + query::Operator, + scorer::{K1, idf}, +}; use super::{ CompressedPostingList, DocSet, PostingList, RawDocInfo, builder::ScoredDoc, - encoding::{decompress_positions, decompress_posting_block, decompress_posting_remainder}, + encoding::{ + decode_position_stream_block, decompress_positions, decompress_posting_block, + decompress_posting_remainder, + }, query::FtsSearchParams, scorer::Scorer, }; use super::{DocInfo, builder::BLOCK_SIZE}; -use super::{ - query::Operator, - scorer::{K1, idf}, -}; const TERMINATED_DOC_ID: u64 = u64::MAX; @@ -60,6 +63,9 @@ struct CompressedState { doc_ids: Vec, freqs: Vec, buffer: Box<[u32; BLOCK_SIZE]>, + position_block_idx: Option, + position_values: Vec, + position_offsets: Vec, } impl CompressedState { @@ -69,21 +75,40 @@ impl CompressedState { doc_ids: Vec::with_capacity(BLOCK_SIZE), freqs: Vec::with_capacity(BLOCK_SIZE), buffer: Box::new([0; BLOCK_SIZE]), + position_block_idx: None, + position_values: Vec::new(), + position_offsets: Vec::new(), } } #[inline] - fn decompress(&mut self, block: &[u8], block_idx: usize, num_blocks: usize, length: u32) { + fn decompress( + &mut self, + block: &[u8], + block_idx: usize, + num_blocks: usize, + length: u32, + tail_codec: super::PostingTailCodec, + ) { self.doc_ids.clear(); self.freqs.clear(); let remainder = length as usize % BLOCK_SIZE; if block_idx + 1 == num_blocks && remainder != 0 { - decompress_posting_remainder(block, remainder, &mut self.doc_ids, &mut self.freqs); + decompress_posting_remainder( + block, + remainder, + tail_codec, + &mut self.doc_ids, + &mut self.freqs, + ); } else { decompress_posting_block(block, &mut self.buffer, &mut self.doc_ids, &mut self.freqs); } self.block_idx = block_idx; + self.position_block_idx = None; + self.position_values.clear(); + self.position_offsets.clear(); } } @@ -157,7 +182,13 @@ impl PostingIterator { let compressed = unsafe { &mut *self.compressed_state_ptr() }; if compressed.block_idx != block_idx || compressed.doc_ids.is_empty() { let block = list.blocks.value(block_idx); - compressed.decompress(block, block_idx, list.blocks.len(), list.length); + compressed.decompress( + block, + block_idx, + list.blocks.len(), + list.length, + list.posting_tail_codec, + ); } compressed as *mut CompressedState } @@ -230,14 +261,63 @@ impl PostingIterator { } } - fn positions(&self) -> Option> { + fn position_cursor(&self) -> Option> { match self.list { - PostingList::Plain(ref list) => list.positions(self.index), - PostingList::Compressed(ref list) => list.positions.as_ref().map(|p| { - let positions = p.value(self.index); - let positions = decompress_positions(positions.as_binary()); - Arc::new(UInt32Array::from(positions)) as Arc + PostingList::Plain(ref list) => list.positions.as_ref().map(|positions| { + let start = positions.value_offsets()[self.index] as usize; + let end = positions.value_offsets()[self.index + 1] as usize; + PositionCursor::new( + PositionValues::Owned( + positions.values().as_primitive::().values()[start..end] + .iter() + .map(|value| *value as u32) + .collect(), + ), + self.position as i32, + ) }), + PostingList::Compressed(ref list) => match list.positions.as_ref()? { + CompressedPositionStorage::LegacyPerDoc(positions) => { + let positions = positions.value(self.index); + let positions = decompress_positions(positions.as_binary()); + Some(PositionCursor::new( + PositionValues::Owned(positions), + self.position as i32, + )) + } + CompressedPositionStorage::SharedStream(stream) => { + let block_idx = self.index / BLOCK_SIZE; + let block_offset = self.index % BLOCK_SIZE; + let compressed = + unsafe { &mut *self.ensure_compressed_block_ptr(list, block_idx) }; + if compressed.position_block_idx != Some(block_idx) { + decode_position_stream_block( + stream.block(block_idx), + compressed.freqs.as_slice(), + stream.codec(), + &mut compressed.position_values, + ) + .expect("shared position stream decoding should succeed"); + compressed.position_offsets.clear(); + compressed + .position_offsets + .reserve(compressed.freqs.len() + 1); + compressed.position_offsets.push(0); + let mut offset = 0usize; + for &freq in &compressed.freqs { + offset += freq as usize; + compressed.position_offsets.push(offset); + } + compressed.position_block_idx = Some(block_idx); + } + let start = compressed.position_offsets[block_offset]; + let end = compressed.position_offsets[block_offset + 1]; + Some(PositionCursor::new( + PositionValues::Borrowed(&compressed.position_values[start..end]), + self.position as i32, + )) + } + }, } } @@ -782,15 +862,14 @@ impl<'a, S: Scorer> Wand<'a, S> { } fn check_positions(&self, slop: i32) -> bool { + if slop == 0 { + return self.check_exact_positions(); + } + let mut position_iters = self .postings .iter() - .map(|posting| { - PositionIterator::new( - posting.positions().expect("positions must exist"), - posting.position as i32, - ) - }) + .map(|posting| posting.position_cursor().expect("positions must exist")) .collect::>(); position_iters.sort_unstable_by_key(|iter| iter.position_in_query); @@ -821,71 +900,128 @@ impl<'a, S: Scorer> Wand<'a, S> { } position_iters.iter_mut().for_each(|iter| { - iter.next(max_relative_pos.unwrap()); + iter.advance_to_relative(max_relative_pos.unwrap()); }); } } + + fn check_exact_positions(&self) -> bool { + let mut position_iters = self + .postings + .iter() + .map(|posting| posting.position_cursor().expect("positions must exist")) + .collect::>(); + position_iters.sort_unstable_by_key(|iter| iter.len()); + let Some(lead) = position_iters.first() else { + return false; + }; + let lead_position = lead.position_in_query; + + loop { + let Some(anchor) = position_iters[0].absolute_position() else { + return false; + }; + let Some(base) = anchor.checked_sub(lead_position as u32) else { + position_iters[0].advance_next(); + continue; + }; + + let mut next_lead_relative = None; + let mut matched = true; + for follower in position_iters.iter_mut().skip(1) { + let Some(target) = base.checked_add(follower.position_in_query as u32) else { + return false; + }; + let Some(position) = follower.advance_to_absolute(target) else { + return false; + }; + if position != target { + next_lead_relative = Some(position as i32 - follower.position_in_query); + matched = false; + break; + } + } + + if matched { + return true; + } + + position_iters[0].advance_to_relative(next_lead_relative.unwrap()); + } + } +} + +#[derive(Debug)] +enum PositionValues<'a> { + Borrowed(&'a [u32]), + Owned(Vec), +} + +impl<'a> PositionValues<'a> { + fn as_slice(&self) -> &[u32] { + match self { + Self::Borrowed(values) => values, + Self::Owned(values) => values.as_slice(), + } + } + + fn len(&self) -> usize { + self.as_slice().len() + } } #[derive(Debug)] -struct PositionIterator { - // It's Int32Array for legacy index, - // UInt32Array for new index - positions: Arc, +struct PositionCursor<'a> { + positions: PositionValues<'a>, pub position_in_query: i32, index: usize, } -impl PositionIterator { - fn new(positions: Arc, position_in_query: i32) -> Self { - let mut iter = Self { +impl<'a> PositionCursor<'a> { + fn new(positions: PositionValues<'a>, position_in_query: i32) -> Self { + Self { positions, position_in_query, index: 0, - }; - iter.next(0); - iter + } + } + + fn len(&self) -> usize { + self.positions.len() + } + + fn absolute_position(&self) -> Option { + self.positions.as_slice().get(self.index).copied() } - // get the current relative position fn relative_position(&self) -> Option { - if self.index < self.positions.len() { - match self.positions.data_type() { - DataType::Int32 => Some( - self.positions.as_primitive::().value(self.index) - - self.position_in_query, - ), - DataType::UInt32 => Some( - self.positions - .as_primitive::() - .value(self.index) as i32 - - self.position_in_query, - ), - _ => { - unreachable!("position iterator only supports Int32 and UInt32"); - } - } - } else { - None - } + self.positions + .as_slice() + .get(self.index) + .map(|position| *position as i32 - self.position_in_query) } - // move to the next position that the relative position is greater than or equal to least_pos - fn next(&mut self, least_relative_pos: i32) { + fn advance_to_relative(&mut self, least_relative_pos: i32) { + if self.index >= self.len() { + return; + } let least_pos = least_relative_pos + self.position_in_query; - self.index = match self.positions.data_type() { - DataType::Int32 => self - .positions - .as_primitive::() - .values() - .partition_point(|&pos| pos < least_pos), - DataType::UInt32 => self - .positions - .as_primitive::() - .values() - .partition_point(|&pos| (pos as i32) < least_pos), - _ => unreachable!("position iterator only supports Int32 and UInt32"), - }; + let least_pos = least_pos.max(0) as u32; + let values = self.positions.as_slice(); + self.index += values[self.index..].partition_point(|&pos| pos < least_pos); + } + + fn advance_to_absolute(&mut self, least_pos: u32) -> Option { + if self.index >= self.len() { + return None; + } + let values = self.positions.as_slice(); + self.index += values[self.index..].partition_point(|&pos| pos < least_pos); + self.absolute_position() + } + + fn advance_next(&mut self) { + self.index = self.index.saturating_add(1).min(self.len()); } } @@ -899,7 +1035,8 @@ mod tests { use crate::{ metrics::NoOpMetricsCollector, scalar::inverted::{ - CompressedPostingList, PlainPostingList, encoding::compress_posting_list, + CompressedPostingList, PlainPostingList, PostingListBuilder, builder::PositionRecorder, + encoding::compress_posting_list, }, }; @@ -923,6 +1060,7 @@ mod tests { blocks, max_score, doc_ids.len() as u32, + crate::scalar::inverted::PostingTailCodec::VarintDelta, None, )) } else { @@ -935,6 +1073,43 @@ mod tests { } } + fn generate_posting_list_with_positions( + doc_ids: Vec, + positions_by_doc: Vec>, + max_score: f32, + is_compressed: bool, + ) -> PostingList { + let freqs = positions_by_doc + .iter() + .map(|positions| positions.len() as u32) + .collect::>(); + if is_compressed { + let mut builder = PostingListBuilder::new(true); + for (doc_id, positions) in doc_ids.iter().copied().zip(positions_by_doc) { + builder.add(doc_id, PositionRecorder::Position(positions.into())); + } + let batch = builder + .to_batch(vec![max_score; doc_ids.len().div_ceil(BLOCK_SIZE)]) + .unwrap(); + PostingList::from_batch(&batch, Some(max_score), Some(doc_ids.len() as u32)).unwrap() + } else { + let mut position_builder = + arrow::array::ListBuilder::new(arrow::array::Int32Builder::new()); + for positions in positions_by_doc { + for position in positions { + position_builder.values().append_value(position as i32); + } + position_builder.append(true); + } + PostingList::Plain(PlainPostingList::new( + ScalarBuffer::from_iter(doc_ids.iter().map(|id| *id as u64)), + ScalarBuffer::from_iter(freqs.iter().map(|freq| *freq as f32)), + Some(max_score), + Some(position_builder.finish()), + )) + } + } + #[rstest] #[tokio::test] async fn test_wand(#[values(false, true)] is_compressed: bool) { @@ -1062,4 +1237,56 @@ mod tests { "block max score should match stored value" ); } + + #[rstest] + fn test_exact_phrase_with_repeated_terms(#[values(false, true)] is_compressed: bool) { + let mut docs = DocSet::default(); + docs.append(0, 16); + + let token_a_positions = vec![vec![1_u32, 3, 10]]; + let token_b_positions = vec![vec![2_u32, 11]]; + let postings = vec![ + PostingIterator::new( + String::from("a"), + 0, + 0, + generate_posting_list_with_positions( + vec![0], + token_a_positions.clone(), + 1.0, + is_compressed, + ), + docs.len(), + ), + PostingIterator::new( + String::from("b"), + 1, + 1, + generate_posting_list_with_positions( + vec![0], + token_b_positions, + 1.0, + is_compressed, + ), + docs.len(), + ), + PostingIterator::new( + String::from("a"), + 2, + 2, + generate_posting_list_with_positions( + vec![0], + token_a_positions, + 1.0, + is_compressed, + ), + docs.len(), + ), + ]; + + let bm25 = IndexBM25Scorer::new(std::iter::empty()); + let wand = Wand::new(Operator::And, postings.into_iter(), &docs, bm25); + assert!(wand.check_exact_positions()); + assert!(wand.check_positions(0)); + } } diff --git a/rust/lance-index/src/scalar/lance_format.rs b/rust/lance-index/src/scalar/lance_format.rs index c09fd4b5914..3d1d08be8d2 100644 --- a/rust/lance-index/src/scalar/lance_format.rs +++ b/rust/lance-index/src/scalar/lance_format.rs @@ -33,7 +33,7 @@ use std::{any::Any, sync::Arc}; /// Scalar indices are made up of named collections of record batches. This /// struct relies on there being a dedicated directory for the index and stores /// each collection in a file in the lance format. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct LanceIndexStore { object_store: Arc, index_dir: Path, @@ -233,6 +233,10 @@ impl IndexStore for LanceIndexStore { self } + fn clone_arc(&self) -> Arc { + Arc::new(self.clone()) + } + fn io_parallelism(&self) -> usize { self.object_store.io_parallelism() } diff --git a/rust/lance/Cargo.toml b/rust/lance/Cargo.toml index 02fb37d2cc0..a979764c69e 100644 --- a/rust/lance/Cargo.toml +++ b/rust/lance/Cargo.toml @@ -97,6 +97,7 @@ libc = { workspace = true } clap = { workspace = true, features = ["derive"] } criterion = { workspace = true } approx.workspace = true +rand_distr.workspace = true dirs = "5.0.0" all_asserts = "2.3.1" mock_instant.workspace = true diff --git a/rust/lance/src/dataset/mem_wal/memtable/flush.rs b/rust/lance/src/dataset/mem_wal/memtable/flush.rs index 5869f6d2db7..ba2344fafaf 100644 --- a/rust/lance/src/dataset/mem_wal/memtable/flush.rs +++ b/rust/lance/src/dataset/mem_wal/memtable/flush.rs @@ -403,7 +403,7 @@ impl MemTableFlusher { total_rows: usize, ) -> Result<()> { use lance_index::pbold; - use lance_index::scalar::inverted::INVERTED_INDEX_VERSION; + use lance_index::scalar::inverted::current_fts_format_version; use lance_index::scalar::lance_format::LanceIndexStore; let fts_configs: Vec<_> = index_configs @@ -480,7 +480,7 @@ impl MemTableFlusher { dataset_version: dataset.version().version, fragment_bitmap: Some(fragment_ids), index_details: Some(Arc::new(index_details)), - index_version: INVERTED_INDEX_VERSION as i32, + index_version: current_fts_format_version().index_version() as i32, created_at: None, base_id: None, files: None, From 43c7f0bd3347b32a0fd478ca5d3700d6e6516390 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Fri, 20 Mar 2026 07:10:23 -0700 Subject: [PATCH 053/206] fix: 2.1/2.2 panic when a list column had small values and many empty values (#6234) If there are some non-null / non-empty values (so we can't use all-null branch) and they are small (so we use miniblock) and there are many repetition levels (can be caused by many empty / null lists) then we can get in a situation where we have too many rep/def levels for a chunk (exceed max chunk size). This PR detects that scenario and falls back to full-zip encoding in these cases (it's less performant decoding but these situations should be very rare or maybe in the future we can have different structure encodings for very sparse data) Closes #6184 --- .../src/encodings/logical/list.rs | 51 ++++++ .../src/encodings/logical/primitive.rs | 170 +++++++++++++----- 2 files changed, 181 insertions(+), 40 deletions(-) diff --git a/rust/lance-encoding/src/encodings/logical/list.rs b/rust/lance-encoding/src/encodings/logical/list.rs index 874cd83c204..674e9d8ae4b 100644 --- a/rust/lance-encoding/src/encodings/logical/list.rs +++ b/rust/lance-encoding/src/encodings/logical/list.rs @@ -889,4 +889,55 @@ mod tests { // Actual: panic at primitive.rs:1362 - assertion failed: rows_avail > 0 check_round_trip_encoding_of_data(vec![list_array], &test_cases, HashMap::new()).await; } + + #[rstest] + #[test_log::test(tokio::test)] + async fn test_sparse_large_string_list( + #[values(STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_FULLZIP)] + structural_encoding: &str, + ) { + // 2.5 million rows, mostly empty lists. ~100 lists have 10 short strings each. + let num_rows = 2_500_000u32; + let num_non_empty = 100u32; + let strings_per_list = 10; + + let items_builder = StringBuilder::new(); + let mut list_builder = ListBuilder::new(items_builder); + + // Spread non-empty lists evenly across the range + let step = num_rows / num_non_empty; + let mut next_non_empty = step / 2; + + for i in 0..num_rows { + if i == next_non_empty { + let vals: Vec> = (0..strings_per_list) + .map(|j| match j % 4 { + 0 => Some("a"), + 1 => Some("bb"), + 2 => Some("ccc"), + _ => Some("d"), + }) + .collect(); + list_builder.append_value(vals); + next_non_empty = next_non_empty.saturating_add(step); + } else { + list_builder.append_value([] as [Option<&str>; 0]); + } + } + let list_array = list_builder.finish(); + + let mut field_metadata = HashMap::new(); + field_metadata.insert( + STRUCTURAL_ENCODING_META_KEY.to_string(), + structural_encoding.into(), + ); + + let test_cases = TestCases::default() + .with_range(0..1000) + .with_range(0..num_rows as u64) + .with_indices(vec![0, (step / 2) as u64, num_rows as u64 - 1]) + .with_max_file_version(LanceFileVersion::V2_2); + check_round_trip_encoding_of_data(vec![Arc::new(list_array)], &test_cases, field_metadata) + .await; + } } diff --git a/rust/lance-encoding/src/encodings/logical/primitive.rs b/rust/lance-encoding/src/encodings/logical/primitive.rs index b5de0912a32..0e3db7e9a54 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive.rs @@ -3737,6 +3737,66 @@ impl PrimitiveStructuralEncoder { Self::is_narrow(data_block) } + /// Checks if the rep/def levels are too sparse for miniblock encoding. + /// + /// Miniblock chunks are limited to ~32KiB total. Data can use up to ~16KiB, + /// leaving ~16KiB for both rep and def buffers combined. Each chunk has at most + /// MAX_MINIBLOCK_VALUES (4096) data values, but when data has many empty/null + /// lists, the number of rep/def levels can far exceed the number of data values + /// (each empty list adds a level entry with no corresponding data value). + /// + /// We estimate the compressed bits per level by computing the max value in each + /// buffer and taking ceil(log2(max_val + 1)) — the minimum bits needed to + /// bitpack each level. We then calculate the maximum number of levels that fit + /// in 16KiB and compare against the actual levels-to-values ratio. + fn repdef_too_sparse_for_miniblock( + repdef: &crate::repdef::SerializedRepDefs, + num_values: u64, + ) -> bool { + if num_values == 0 { + return false; + } + let num_levels = repdef + .repetition_levels + .as_ref() + .map(|r| r.len() as u64) + .max(repdef.definition_levels.as_ref().map(|d| d.len() as u64)) + .unwrap_or(0); + if num_levels == 0 { + return false; + } + + // Compute bits needed per level for each buffer (ceil of log2(max+1)) + let bits_per_rep = repdef + .repetition_levels + .as_ref() + .and_then(|r| r.iter().max().copied()) + .map(|max_val| u16::BITS - max_val.leading_zeros()) + .unwrap_or(0) as u64; + let bits_per_def = repdef + .definition_levels + .as_ref() + .and_then(|d| d.iter().max().copied()) + .map(|max_val| u16::BITS - max_val.leading_zeros()) + .unwrap_or(0) as u64; + + let bits_per_level = bits_per_rep + bits_per_def; + if bits_per_level == 0 { + return false; + } + + // 16KiB budget for rep+def combined (half the ~32KiB chunk limit) + const REPDEF_BUDGET_BITS: u64 = 16 * 1024 * 8; + let max_levels_per_chunk = REPDEF_BUDGET_BITS / bits_per_level; + + // A chunk has at most MAX_MINIBLOCK_VALUES data values. The levels-to-values + // ratio tells us how many levels a chunk of that size would need. + let levels_per_chunk = + (num_levels as f64 / num_values as f64) * miniblock::MAX_MINIBLOCK_VALUES as f64; + + levels_per_chunk > max_levels_per_chunk as f64 + } + fn prefers_fullzip(encoding_metadata: &HashMap) -> bool { // Fullzip is the backup option so the only reason we wouldn't use it is if the // user specifically requested not to use it (in which case we're probably going @@ -3798,7 +3858,7 @@ impl PrimitiveStructuralEncoder { rep: Option>, def: Option>, support_large_chunk: bool, - ) -> SerializedMiniBlockPage { + ) -> Result { let bytes_rep = rep .as_ref() .map(|rep| rep.iter().map(|r| r.data.len()).sum::()) @@ -3842,11 +3902,21 @@ impl PrimitiveStructuralEncoder { // Write the buffer lengths if let Some(rep) = rep.as_ref() { - let bytes_rep = u16::try_from(rep.data.len()).unwrap(); + let bytes_rep = u16::try_from(rep.data.len()).map_err(|_| { + Error::internal(format!( + "Repetition buffer size ({} bytes) too large", + rep.data.len() + )) + })?; data_buffer.extend_from_slice(&bytes_rep.to_le_bytes()); } if let Some(def) = def.as_ref() { - let bytes_def = u16::try_from(def.data.len()).unwrap(); + let bytes_def = u16::try_from(def.data.len()).map_err(|_| { + Error::internal(format!( + "Definition buffer size ({} bytes) too large", + def.data.len() + )) + })?; data_buffer.extend_from_slice(&bytes_def.to_le_bytes()); } @@ -3916,11 +3986,11 @@ impl PrimitiveStructuralEncoder { let data_buffer = LanceBuffer::from(data_buffer); let metadata_buffer = LanceBuffer::from(meta_buffer); - SerializedMiniBlockPage { + Ok(SerializedMiniBlockPage { num_buffers: miniblocks.data.len() as u64, data: data_buffer, metadata: metadata_buffer, - } + }) } /// Compresses a buffer of levels into chunks @@ -4448,7 +4518,7 @@ impl PrimitiveStructuralEncoder { .map(|cd| std::mem::take(&mut cd.data)); let serialized = - Self::serialize_miniblocks(compressed_data, rep_data, def_data, support_large_chunk); + Self::serialize_miniblocks(compressed_data, rep_data, def_data, support_large_chunk)?; // Metadata, Data, Dictionary, (maybe) Repetition Index let mut data = Vec::with_capacity(4); @@ -5112,41 +5182,61 @@ impl PrimitiveStructuralEncoder { ); } - if let DataBlock::Dictionary(dict) = data_block { - log::debug!("Encoding column {} with {} items using dictionary encoding (already dictionary encoded)", column_idx, num_values); - let (mut indices_data_block, dictionary_data_block) = dict.into_parts(); - // TODO: https://github.com/lancedb/lance/issues/4809 - // If we compute stats on dictionary_data_block => panic. - // If we don't compute stats on indices_data_block => panic. - // This is messy. Don't make me call compute_stat ever. - indices_data_block.compute_stat(); - Self::encode_miniblock( - column_idx, - &field, - compression_strategy.as_ref(), - indices_data_block, - repdef, - row_number, - Some(dictionary_data_block), - num_rows, - support_large_chunk, - ) + // If the rep/def levels are too sparse for miniblock (e.g. many empty + // lists with very few values), fall back to fullzip to avoid exceeding + // the u16 per-chunk rep/def buffer size limit. + let too_sparse = Self::repdef_too_sparse_for_miniblock(&repdef, num_values); + + if !too_sparse { + if let DataBlock::Dictionary(dict) = data_block { + log::debug!("Encoding column {} with {} items using dictionary encoding (already dictionary encoded)", column_idx, num_values); + let (mut indices_data_block, dictionary_data_block) = dict.into_parts(); + // TODO: https://github.com/lancedb/lance/issues/4809 + // If we compute stats on dictionary_data_block => panic. + // If we don't compute stats on indices_data_block => panic. + // This is messy. Don't make me call compute_stat ever. + indices_data_block.compute_stat(); + return Self::encode_miniblock( + column_idx, + &field, + compression_strategy.as_ref(), + indices_data_block, + repdef, + row_number, + Some(dictionary_data_block), + num_rows, + support_large_chunk, + ); + } } else { + log::debug!( + "Encoding column {} with {} items using full-zip layout \ + (rep/def too sparse for mini-block)", + column_idx, + num_values + ); + } + + { // Try dictionary encoding first if applicable. If encoding aborts, fall back to the // preferred structural encoding. - let dict_result = Self::should_dictionary_encode(&data_block, &field, version) - .and_then(|budget| { - log::debug!( - "Encoding column {} with {} items using dictionary encoding (mini-block layout)", - column_idx, - num_values - ); - dict::dictionary_encode( - &data_block, - budget.max_dict_entries, - budget.max_encoded_size, - ) - }); + let dict_result = if too_sparse { + None + } else { + Self::should_dictionary_encode(&data_block, &field, version) + .and_then(|budget| { + log::debug!( + "Encoding column {} with {} items using dictionary encoding (mini-block layout)", + column_idx, + num_values + ); + dict::dictionary_encode( + &data_block, + budget.max_dict_entries, + budget.max_encoded_size, + ) + }) + }; if let Some((indices_data_block, dictionary_data_block)) = dict_result { Self::encode_miniblock( @@ -5160,7 +5250,7 @@ impl PrimitiveStructuralEncoder { num_rows, support_large_chunk, ) - } else if Self::prefers_miniblock(&data_block, encoding_metadata.as_ref()) { + } else if !too_sparse && Self::prefers_miniblock(&data_block, encoding_metadata.as_ref()) { log::debug!( "Encoding column {} with {} items using mini-block layout", column_idx, @@ -5177,7 +5267,7 @@ impl PrimitiveStructuralEncoder { num_rows, support_large_chunk, ) - } else if Self::prefers_fullzip(encoding_metadata.as_ref()) { + } else if too_sparse || Self::prefers_fullzip(encoding_metadata.as_ref()) { log::debug!( "Encoding column {} with {} items using full-zip layout", column_idx, From 382d8d3cb12bb55d8cbf8bcbfed9bf8c9f8c72d6 Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Fri, 20 Mar 2026 10:32:09 -0500 Subject: [PATCH 054/206] feat: bounding source fragments for compaction execution (#6232) Adding a `CompationOption` to allow bounding the number of source fragments to be included in a compaction execution. This effectively allows incremental compaction, for example compact the first 100k fragments, then next, etc; which is especially useful for heavily fragmented datasets. --- java/lance-jni/src/blocking_dataset.rs | 9 ++ java/lance-jni/src/optimize.rs | 22 ++- java/lance-jni/src/utils.rs | 4 + .../java/org/lance/compaction/Compaction.java | 12 +- .../lance/compaction/CompactionOptions.java | 26 +++- .../org/lance/compaction/CompactionTask.java | 6 +- python/python/lance/optimize.py | 8 + python/src/dataset/optimize.rs | 3 + rust/lance/src/dataset/optimize.rs | 142 +++++++++++++++++- 9 files changed, 218 insertions(+), 14 deletions(-) diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index 7831489c7e9..fe47d30c239 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -2633,6 +2633,14 @@ fn convert_java_compaction_options_to_rust( &[], )? .l()?; + let max_source_fragments = env + .call_method( + &java_options, + "getMaxSourceFragments", + "()Ljava/util/Optional;", + &[], + )? + .l()?; build_compaction_options( env, @@ -2646,6 +2654,7 @@ fn convert_java_compaction_options_to_rust( &defer_index_remap, &compaction_mode, &binary_copy_read_batch_bytes, + &max_source_fragments, config, ) } diff --git a/java/lance-jni/src/optimize.rs b/java/lance-jni/src/optimize.rs index 174283e243e..0ce92baeec8 100644 --- a/java/lance-jni/src/optimize.rs +++ b/java/lance-jni/src/optimize.rs @@ -45,6 +45,7 @@ pub extern "system" fn Java_org_lance_compaction_Compaction_nativePlanCompaction defer_index_remap: JObject, // Optional compaction_mode: JObject, // Optional binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional ) -> JObject<'local> { ok_or_throw_with_return!( env, @@ -60,7 +61,8 @@ pub extern "system" fn Java_org_lance_compaction_Compaction_nativePlanCompaction batch_size, defer_index_remap, compaction_mode, - binary_copy_read_batch_bytes + binary_copy_read_batch_bytes, + max_source_fragments ), JObject::null() ) @@ -80,6 +82,7 @@ fn inner_plan_compaction<'local>( defer_index_remap: JObject, // Optional compaction_mode: JObject, // Optional binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional ) -> Result> { let config = { let dataset = @@ -98,6 +101,7 @@ fn inner_plan_compaction<'local>( &defer_index_remap, &compaction_mode, &binary_copy_read_batch_bytes, + &max_source_fragments, &config, )?; @@ -125,6 +129,7 @@ pub extern "system" fn Java_org_lance_compaction_Compaction_nativeCommitCompacti defer_index_remap: JObject, // Optional compaction_mode: JObject, // Optional binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional ) -> JObject<'local> { ok_or_throw_with_return!( env, @@ -142,6 +147,7 @@ pub extern "system" fn Java_org_lance_compaction_Compaction_nativeCommitCompacti defer_index_remap, compaction_mode, binary_copy_read_batch_bytes, + max_source_fragments, ), JObject::null() ) @@ -162,6 +168,7 @@ fn inner_commit_compaction<'local>( defer_index_remap: JObject, // Optional compaction_mode: JObject, // Optional binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional ) -> Result> { let config = { let dataset = @@ -180,6 +187,7 @@ fn inner_commit_compaction<'local>( &defer_index_remap, &compaction_mode, &binary_copy_read_batch_bytes, + &max_source_fragments, &config, )?; let completed_tasks = import_vec_to_rust(env, &rewrite_results, |env, rewrite_result| { @@ -216,6 +224,7 @@ pub extern "system" fn Java_org_lance_compaction_CompactionTask_nativeExecute<'l defer_index_remap: JObject, // Optional compaction_mode: JObject, // Optional binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional ) -> JObject<'local> { ok_or_throw_with_return!( env, @@ -233,7 +242,8 @@ pub extern "system" fn Java_org_lance_compaction_CompactionTask_nativeExecute<'l batch_size, defer_index_remap, compaction_mode, - binary_copy_read_batch_bytes + binary_copy_read_batch_bytes, + max_source_fragments ), JObject::null() ) @@ -255,6 +265,7 @@ fn inner_execute_task<'local>( defer_index_remap: JObject, // Optional compaction_mode: JObject, // Optional binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional ) -> Result> { let task_data: TaskData = task_data.extract_object(env)?; let config = { @@ -274,6 +285,7 @@ fn inner_execute_task<'local>( &defer_index_remap, &compaction_mode, &binary_copy_read_batch_bytes, + &max_source_fragments, &config, )?; let compaction_task = CompactionTask { @@ -300,7 +312,7 @@ const REWRITE_RESULT_CLASS: &str = "org/lance/compaction/RewriteResult"; const REWRITE_RESULT_CONSTRUCTOR_SIG: &str = "(Lorg/lance/compaction/CompactionMetrics;Ljava/util/List;Ljava/util/List;J[B)V"; const COMPACTION_OPTIONS_CLASS: &str = "org/lance/compaction/CompactionOptions"; -const COMPACTION_OPTIONS_CONSTRUCTOR_SIG: &str = "(Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;)V"; +const COMPACTION_OPTIONS_CONSTRUCTOR_SIG: &str = "(Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/Optional;)V"; impl IntoJava for &TaskData { fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { @@ -362,6 +374,9 @@ impl IntoJava for &CompactionOptions { let binary_copy_read_batch_bytes = to_java_long_obj(env, self.binary_copy_read_batch_bytes.map(|v| v as i64))?; let binary_copy_read_batch_bytes_opt = to_java_optional(env, binary_copy_read_batch_bytes)?; + let max_source_fragments = + to_java_long_obj(env, self.max_source_fragments.map(|v| v as i64))?; + let max_source_fragments_opt = to_java_optional(env, max_source_fragments)?; Ok(env.new_object( COMPACTION_OPTIONS_CLASS, @@ -377,6 +392,7 @@ impl IntoJava for &CompactionOptions { JValueGen::Object(&defer_index_remap_opt), JValueGen::Object(&compaction_mode_opt), JValueGen::Object(&binary_copy_read_batch_bytes_opt), + JValueGen::Object(&max_source_fragments_opt), ], )?) } diff --git a/java/lance-jni/src/utils.rs b/java/lance-jni/src/utils.rs index 1e3c8c6aeb2..4fe11b289e9 100644 --- a/java/lance-jni/src/utils.rs +++ b/java/lance-jni/src/utils.rs @@ -141,6 +141,7 @@ pub fn build_compaction_options( defer_index_remap: &JObject, // Optional compaction_mode: &JObject, // Optional binary_copy_read_batch_bytes: &JObject, // Optional + max_source_fragments: &JObject, // Optional config: &std::collections::HashMap, ) -> Result { let mut compaction_options = CompactionOptions::from_dataset_config(config)?; @@ -181,6 +182,9 @@ pub fn build_compaction_options( compaction_options.binary_copy_read_batch_bytes = Some(binary_copy_read_batch_bytes_val as usize); } + if let Some(max_source_fragments_val) = env.get_long_opt(max_source_fragments)? { + compaction_options.max_source_fragments = Some(max_source_fragments_val as usize); + } Ok(compaction_options) } diff --git a/java/src/main/java/org/lance/compaction/Compaction.java b/java/src/main/java/org/lance/compaction/Compaction.java index 4845f2502ed..0ce7050900c 100644 --- a/java/src/main/java/org/lance/compaction/Compaction.java +++ b/java/src/main/java/org/lance/compaction/Compaction.java @@ -43,7 +43,8 @@ public static CompactionPlan planCompaction( compactionOptions.getBatchSize(), compactionOptions.getDeferIndexRemap(), compactionOptions.getCompactionMode(), - compactionOptions.getBinaryCopyReadBatchBytes()); + compactionOptions.getBinaryCopyReadBatchBytes(), + compactionOptions.getMaxSourceFragments()); } public static CompactionMetrics commitCompaction( @@ -63,7 +64,8 @@ public static CompactionMetrics commitCompaction( compactionOptions.getBatchSize(), compactionOptions.getDeferIndexRemap(), compactionOptions.getCompactionMode(), - compactionOptions.getBinaryCopyReadBatchBytes()); + compactionOptions.getBinaryCopyReadBatchBytes(), + compactionOptions.getMaxSourceFragments()); } public static native CompactionMetrics nativeCommitCompaction( @@ -78,7 +80,8 @@ public static native CompactionMetrics nativeCommitCompaction( Optional batchSize, Optional deferIndexRemap, Optional compactionMode, - Optional binaryCopyReadBatchBytes); + Optional binaryCopyReadBatchBytes, + Optional maxSourceFragments); private static native CompactionPlan nativePlanCompaction( Dataset dataset, @@ -91,5 +94,6 @@ private static native CompactionPlan nativePlanCompaction( Optional batchSize, Optional deferIndexRemap, Optional compactionMode, - Optional binaryCopyReadBatchBytes); + Optional binaryCopyReadBatchBytes, + Optional maxSourceFragments); } diff --git a/java/src/main/java/org/lance/compaction/CompactionOptions.java b/java/src/main/java/org/lance/compaction/CompactionOptions.java index d2d483f4970..7c3d65ffc3f 100644 --- a/java/src/main/java/org/lance/compaction/CompactionOptions.java +++ b/java/src/main/java/org/lance/compaction/CompactionOptions.java @@ -39,6 +39,7 @@ public class CompactionOptions implements Serializable { private Optional deferIndexRemap; private Optional compactionMode; private Optional binaryCopyReadBatchBytes; + private Optional maxSourceFragments; private CompactionOptions( Optional targetRowsPerFragment, @@ -50,7 +51,8 @@ private CompactionOptions( Optional batchSize, Optional deferIndexRemap, Optional compactionMode, - Optional binaryCopyReadBatchBytes) { + Optional binaryCopyReadBatchBytes, + Optional maxSourceFragments) { this.targetRowsPerFragment = targetRowsPerFragment; this.maxRowsPerGroup = maxRowsPerGroup; this.maxBytesPerFile = maxBytesPerFile; @@ -61,6 +63,7 @@ private CompactionOptions( this.deferIndexRemap = deferIndexRemap; this.compactionMode = compactionMode; this.binaryCopyReadBatchBytes = binaryCopyReadBatchBytes; + this.maxSourceFragments = maxSourceFragments; } public Optional getDeferIndexRemap() { @@ -76,6 +79,10 @@ public Optional getBinaryCopyReadBatchBytes() { return binaryCopyReadBatchBytes; } + public Optional getMaxSourceFragments() { + return maxSourceFragments; + } + public Optional getMaterializeDeletions() { return materializeDeletions; } @@ -121,6 +128,7 @@ public String toString() { .add("deferIndexRemap", deferIndexRemap.orElse(null)) .add("compactionMode", compactionMode.orElse(null)) .add("binaryCopyReadBatchBytes", binaryCopyReadBatchBytes.orElse(null)) + .add("maxSourceFragments", maxSourceFragments.orElse(null)) .toString(); } @@ -135,6 +143,7 @@ private void writeObject(ObjectOutputStream output) throws IOException { output.writeObject(deferIndexRemap.orElse(null)); output.writeObject(compactionMode.map(CompactionMode::getValue).orElse(null)); output.writeObject(binaryCopyReadBatchBytes.orElse(null)); + output.writeObject(maxSourceFragments.orElse(null)); } private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { @@ -157,6 +166,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou } } this.binaryCopyReadBatchBytes = Optional.ofNullable((Long) input.readObject()); + this.maxSourceFragments = Optional.ofNullable((Long) input.readObject()); } /** Builder for CompactionOptions. */ @@ -171,6 +181,7 @@ public static class Builder { private Optional deferIndexRemap = Optional.empty(); private Optional compactionMode = Optional.empty(); private Optional binaryCopyReadBatchBytes = Optional.empty(); + private Optional maxSourceFragments = Optional.empty(); private Builder() {} @@ -224,6 +235,16 @@ public Builder withBinaryCopyReadBatchBytes(long binaryCopyReadBatchBytes) { return this; } + /** + * Maximum number of source fragments to compact in a single run. Tasks are included until + * adding the next task would exceed this limit, allowing for incremental compaction. Fragments + * are processed oldest first. + */ + public Builder withMaxSourceFragments(long maxSourceFragments) { + this.maxSourceFragments = Optional.of(maxSourceFragments); + return this; + } + public CompactionOptions build() { return new CompactionOptions( targetRowsPerFragment, @@ -235,7 +256,8 @@ public CompactionOptions build() { batchSize, deferIndexRemap, compactionMode, - binaryCopyReadBatchBytes); + binaryCopyReadBatchBytes, + maxSourceFragments); } } } diff --git a/java/src/main/java/org/lance/compaction/CompactionTask.java b/java/src/main/java/org/lance/compaction/CompactionTask.java index 4d3d7b3b751..89ec364e980 100644 --- a/java/src/main/java/org/lance/compaction/CompactionTask.java +++ b/java/src/main/java/org/lance/compaction/CompactionTask.java @@ -55,7 +55,8 @@ public RewriteResult execute(Dataset dataset) { compactionOptions.getBatchSize(), compactionOptions.getDeferIndexRemap(), compactionOptions.getCompactionMode(), - compactionOptions.getBinaryCopyReadBatchBytes()); + compactionOptions.getBinaryCopyReadBatchBytes(), + compactionOptions.getMaxSourceFragments()); } private native RewriteResult nativeExecute( @@ -71,7 +72,8 @@ private native RewriteResult nativeExecute( Optional batchSize, Optional deferIndexRemap, Optional compactionMode, - Optional binaryCopyReadBatchBytes); + Optional binaryCopyReadBatchBytes, + Optional maxSourceFragments); public CompactionOptions getCompactionOptions() { return compactionOptions; diff --git a/python/python/lance/optimize.py b/python/python/lance/optimize.py index 5e4da3b1b0a..8b98308d442 100644 --- a/python/python/lance/optimize.py +++ b/python/python/lance/optimize.py @@ -81,3 +81,11 @@ class CompactionOptions(TypedDict): """ Whether to defer index remapping during compaction (default: False). """ + max_source_fragments: Optional[int] + """ + Maximum number of source fragments to compact in a single run. Tasks + are included until adding the next task would exceed this limit, + allowing for incremental compaction (e.g., compact 20 fragments at a + time). Fragments are processed oldest first. + (default: None, no limit) + """ diff --git a/python/src/dataset/optimize.rs b/python/src/dataset/optimize.rs index 2f6286c0e2a..4af47cbf0df 100644 --- a/python/src/dataset/optimize.rs +++ b/python/src/dataset/optimize.rs @@ -70,6 +70,9 @@ fn parse_compaction_options( "binary_copy_read_batch_bytes" => { opts.binary_copy_read_batch_bytes = value.extract()?; } + "max_source_fragments" => { + opts.max_source_fragments = value.extract()?; + } _ => { return Err(PyValueError::new_err(format!( "Invalid compaction option: {}", diff --git a/rust/lance/src/dataset/optimize.rs b/rust/lance/src/dataset/optimize.rs index cdde5268a1d..e051dfa0e52 100644 --- a/rust/lance/src/dataset/optimize.rs +++ b/rust/lance/src/dataset/optimize.rs @@ -201,6 +201,12 @@ pub struct CompactionOptions { /// Controls how much data is read at once when performing binary copy. /// Defaults to 16MB (16 * 1024 * 1024). pub binary_copy_read_batch_bytes: Option, + /// Maximum number of source fragments to compact in a single run. When set, + /// tasks are included in the plan until adding the next task would exceed + /// this limit. This allows for incremental compaction (e.g., compact 20 + /// fragments at a time). + /// Defaults to `None` (no limit, all eligible fragments are compacted). + pub max_source_fragments: Option, } #[allow(deprecated)] @@ -220,6 +226,7 @@ impl Default for CompactionOptions { enable_binary_copy: false, enable_binary_copy_force: false, binary_copy_read_batch_bytes: Some(16 * 1024 * 1024), + max_source_fragments: None, } } } @@ -242,6 +249,7 @@ impl CompactionOptions { /// - `lance.compaction.batch_size` /// - `lance.compaction.compaction_mode` /// - `lance.compaction.binary_copy_read_batch_bytes` + /// - `lance.compaction.max_source_fragments` pub fn from_dataset_config(config: &HashMap) -> Result { let mut opts = Self::default(); opts.apply_dataset_config(config)?; @@ -333,6 +341,14 @@ impl CompactionOptions { )) })?); } + "max_source_fragments" => { + self.max_source_fragments = Some(value.parse().map_err(|_| { + Error::invalid_input(format!( + "Invalid value for {}: '{}' (expected a non-negative integer)", + key, value + )) + })?); + } _ => { warn!("Ignoring unknown compaction config key: {}", key); } @@ -666,17 +682,31 @@ impl CompactionPlanner for DefaultCompactionPlanner { candidate_bins.push(bin); } - let final_bins = candidate_bins + let all_tasks: Vec = candidate_bins .into_iter() .filter(|bin| !bin.is_noop()) .flat_map(|bin| bin.split_for_size(self.options.target_rows_per_fragment)) .map(|bin| TaskData { fragments: bin.fragments, - }); + }) + .collect(); + + let tasks = if let Some(max_frags) = self.options.max_source_fragments { + let mut total_frags = 0; + all_tasks + .into_iter() + .take_while(|task| { + total_frags += task.fragments.len(); + total_frags <= max_frags + }) + .collect() + } else { + all_tasks + }; let mut compaction_plan = CompactionPlan::new(dataset.manifest.version, self.options.clone()); - compaction_plan.extend_tasks(final_bins); + compaction_plan.extend_tasks(tasks); Ok(compaction_plan) } @@ -4370,6 +4400,112 @@ mod tests { assert_eq!(opts.max_rows_per_group, 2048); } + #[tokio::test] + async fn test_max_source_fragments() { + let test_dir = TempStrDir::default(); + let test_uri = &test_dir; + + let data = sample_data(); + let schema = data.schema(); + + // Create 10 small fragments (100 rows each) via 10 appends + let write_params = WriteParams { + max_rows_per_file: 100, + ..Default::default() + }; + Dataset::write( + RecordBatchIterator::new(vec![Ok(data.slice(0, 100))], schema.clone()), + test_uri, + Some(write_params.clone()), + ) + .await + .unwrap(); + for i in 1..10 { + let mut append_params = write_params.clone(); + append_params.mode = WriteMode::Append; + Dataset::write( + RecordBatchIterator::new(vec![Ok(data.slice(i * 100, 100))], schema.clone()), + test_uri, + Some(append_params), + ) + .await + .unwrap(); + } + + let dataset = Dataset::open(test_uri).await.unwrap(); + assert_eq!(dataset.get_fragments().len(), 10); + + // Plan without limit - all 10 fragments should be candidates. + // Use a target that splits the 10 fragments into multiple tasks. + let opts_no_limit = CompactionOptions { + target_rows_per_fragment: 250, + ..Default::default() + }; + let plan_all = plan_compaction(&dataset, &opts_no_limit).await.unwrap(); + let total_source_frags: usize = plan_all.tasks().iter().map(|t| t.fragments.len()).sum(); + assert_eq!(total_source_frags, 10); + assert!( + plan_all.num_tasks() > 2, + "need multiple tasks to test bounding, got {}", + plan_all.num_tasks() + ); + + // Plan with max_source_fragments=4 should include tasks covering <= 4 + // source fragments + let opts_bounded = CompactionOptions { + target_rows_per_fragment: 250, + max_source_fragments: Some(4), + ..Default::default() + }; + let plan_bounded = plan_compaction(&dataset, &opts_bounded).await.unwrap(); + let bounded_source_frags: usize = + plan_bounded.tasks().iter().map(|t| t.fragments.len()).sum(); + assert!( + bounded_source_frags <= 4, + "expected at most 4 source fragments, got {bounded_source_frags}" + ); + assert!( + bounded_source_frags > 0, + "expected at least 1 source fragment in bounded plan" + ); + assert!( + plan_bounded.num_tasks() < plan_all.num_tasks(), + "bounded plan ({}) should have fewer tasks than unbounded ({})", + plan_bounded.num_tasks(), + plan_all.num_tasks() + ); + + // Execute bounded compaction incrementally + let mut dataset = dataset; + compact_files(&mut dataset, opts_bounded, None) + .await + .unwrap(); + let after_first = dataset.get_fragments().len(); + assert!( + after_first < 10, + "expected fewer than 10 fragments after first compaction, got {after_first}" + ); + assert!( + after_first > 1, + "expected partial compaction (not fully compacted), got {after_first}" + ); + + // Run again to make more progress + let opts_bounded = CompactionOptions { + target_rows_per_fragment: 250, + max_source_fragments: Some(4), + ..Default::default() + }; + compact_files(&mut dataset, opts_bounded, None) + .await + .unwrap(); + let after_second = dataset.get_fragments().len(); + assert!( + after_second <= after_first, + "expected progress: {after_second} should be <= {after_first}" + ); + } + #[tokio::test] async fn test_compaction_uses_manifest_config() { let test_dir = TempStrDir::default(); From a5e51049133401f78d4cd9120da833afe46d468a Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Fri, 20 Mar 2026 12:55:05 -0700 Subject: [PATCH 055/206] fix: filter out detached versions when scanning manifests (#6245) I am using detached manifests to do parallel data loading, and hit this bug that the finally committed table cannot be read because it cannot parse the detached manifest names. This is because we parse detached version as manifest scheme v2, and then try to parse version from it and fails. I think it's pending more discussion if we should treat detached manifests as a specific scheme, I tried to do that but it looks like the current code has some assumption that the file is either v1 or v2 scheme and detached is just a case within, so refactor gets pretty big and might not be worth it. This PR just do a quick fix so that we can at least open a dataset with detached manifests. I also added the ability to list detached manifests, the functionality seems missing so far. --- rust/lance-table/src/io/commit.rs | 140 +++++++++++++++++- rust/lance/src/dataset.rs | 21 +++ .../src/dataset/tests/dataset_transactions.rs | 119 +++++++++++++++ 3 files changed, 277 insertions(+), 3 deletions(-) diff --git a/rust/lance-table/src/io/commit.rs b/rust/lance-table/src/io/commit.rs index 42b88a8813e..079b4bb4da2 100644 --- a/rust/lance-table/src/io/commit.rs +++ b/rust/lance-table/src/io/commit.rs @@ -115,6 +115,19 @@ impl ManifestNamingScheme { } } + /// Parse a detached version from a filename like `d123456.manifest`. + /// + /// Returns the full version number with the detached mask bit set. + pub fn parse_detached_version(filename: &str) -> Option { + if !filename.starts_with(DETACHED_VERSION_PREFIX) { + return None; + } + let without_prefix = &filename[DETACHED_VERSION_PREFIX.len()..]; + without_prefix + .split_once('.') + .and_then(|(version_str, _)| version_str.parse::().ok()) + } + pub fn detect_scheme(filename: &str) -> Option { if filename.starts_with(DETACHED_VERSION_PREFIX) { // Currently, detached versions must imply V2 @@ -261,9 +274,14 @@ async fn current_manifest_path( let manifest_files = object_store.list(Some(base.child(VERSIONS_DIR))); let mut valid_manifests = manifest_files.try_filter_map(|res| { - if let Some(scheme) = ManifestNamingScheme::detect_scheme(res.location.filename().unwrap()) - { - future::ready(Ok(Some((scheme, res)))) + let filename = res.location.filename().unwrap(); + if let Some(scheme) = ManifestNamingScheme::detect_scheme(filename) { + // Only include if we can parse a version (skip detached versions) + if scheme.parse_version(filename).is_some() { + future::ready(Ok(Some((scheme, res)))) + } else { + future::ready(Ok(None)) + } } else { future::ready(Ok(None)) } @@ -428,6 +446,38 @@ fn list_manifests<'a>( .boxed() } +/// Convert object metadata to ManifestLocation for detached manifests. +fn detached_manifest_location_from_meta( + meta: object_store::ObjectMeta, +) -> Option { + let filename = meta.location.filename()?; + let version = ManifestNamingScheme::parse_detached_version(filename)?; + Some(ManifestLocation { + version, + path: meta.location, + size: Some(meta.size), + naming_scheme: ManifestNamingScheme::V2, + e_tag: meta.e_tag, + }) +} + +/// List all detached manifest files in the versions directory. +pub fn list_detached_manifests<'a>( + base_path: &Path, + object_store: &'a dyn OSObjectStore, +) -> impl Stream> + 'a { + object_store + .read_dir_all(&base_path.child(VERSIONS_DIR), None) + .filter_map(|obj_meta| { + futures::future::ready( + obj_meta + .map(detached_manifest_location_from_meta) + .transpose(), + ) + }) + .boxed() +} + fn make_staging_manifest_path(base: &Path) -> Result { let id = uuid::Uuid::new_v4().to_string(); Path::parse(format!("{base}-{id}")).map_err(|e| Error::io_source(Box::new(e))) @@ -464,6 +514,17 @@ pub trait CommitHandler: Debug + Send + Sync { default_resolve_version(base_path, version, object_store).await } + /// List detached manifest locations. + /// + /// Returns a stream of detached manifest locations in arbitrary order. + fn list_detached_manifest_locations<'a>( + &self, + base_path: &Path, + object_store: &'a ObjectStore, + ) -> BoxStream<'a, Result> { + list_detached_manifests(base_path, &object_store.inner).boxed() + } + /// If `sorted_descending` is `true`, the stream will yield manifests in descending /// order of version. When the object store has a lexicographically /// ordered list and the naming scheme is V2, this will use an optimized @@ -1218,4 +1279,77 @@ mod tests { assert_eq!(location.naming_scheme, naming_scheme); assert_eq!(location.path, naming_scheme.manifest_path(&base, 11)); } + + #[test] + fn test_parse_detached_version() { + // Valid detached version filenames + assert_eq!( + ManifestNamingScheme::parse_detached_version("d12345.manifest"), + Some(12345) + ); + assert_eq!( + ManifestNamingScheme::parse_detached_version("d9223372036854775808.manifest"), + Some(9223372036854775808) + ); + + // Invalid: not starting with 'd' prefix + assert_eq!( + ManifestNamingScheme::parse_detached_version("12345.manifest"), + None + ); + + // Invalid: regular V2 manifest + assert_eq!( + ManifestNamingScheme::parse_detached_version("18446744073709551615.manifest"), + None + ); + + // Invalid: no extension + assert_eq!(ManifestNamingScheme::parse_detached_version("d12345"), None); + } + + #[tokio::test] + async fn test_list_detached_manifests() { + use crate::format::DETACHED_VERSION_MASK; + use futures::TryStreamExt; + + let object_store = ObjectStore::memory(); + let base = Path::from("base"); + let versions_dir = base.child(VERSIONS_DIR); + + // Create some regular manifests + for version in [1, 2, 3] { + let path = ManifestNamingScheme::V2.manifest_path(&base, version); + object_store.put(&path, b"".as_slice()).await.unwrap(); + } + + // Create some detached manifests + let detached_versions: Vec = vec![ + 100 | DETACHED_VERSION_MASK, + 200 | DETACHED_VERSION_MASK, + 300 | DETACHED_VERSION_MASK, + ]; + for version in &detached_versions { + let path = versions_dir.child(format!("d{}.manifest", version)); + object_store.put(&path, b"".as_slice()).await.unwrap(); + } + + // List detached manifests + let detached_locations: Vec = + list_detached_manifests(&base, &object_store.inner) + .try_collect() + .await + .unwrap(); + + assert_eq!(detached_locations.len(), 3); + for loc in &detached_locations { + assert_eq!(loc.naming_scheme, ManifestNamingScheme::V2); + } + + let mut found_versions: Vec = detached_locations.iter().map(|l| l.version).collect(); + found_versions.sort(); + let mut expected_versions = detached_versions.clone(); + expected_versions.sort(); + assert_eq!(found_versions, expected_versions); + } } diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 6adefc7bf3d..2b1835647c5 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -1776,6 +1776,27 @@ impl Dataset { Ok(versions) } + /// List all detached manifest locations. + /// + /// Detached manifests are versions that are not part of the main version history. + /// They are created by `commit_detached` and can be used for staging changes. + /// + /// To read transaction properties from a detached manifest: + /// ```ignore + /// let detached = dataset.list_detached_manifests().await?; + /// for location in detached { + /// let ds = dataset.checkout_version(location.version).await?; + /// let tx = ds.read_transaction().await?; + /// // Access tx.transaction_properties + /// } + /// ``` + pub async fn list_detached_manifests(&self) -> Result> { + self.commit_handler + .list_detached_manifest_locations(&self.base, &self.object_store) + .try_collect() + .await + } + /// Get the latest version of the dataset /// This is meant to be a fast path for checking if a dataset has changed. This is why /// we don't return the full version struct. diff --git a/rust/lance/src/dataset/tests/dataset_transactions.rs b/rust/lance/src/dataset/tests/dataset_transactions.rs index 16d21528562..1f522800a44 100644 --- a/rust/lance/src/dataset/tests/dataset_transactions.rs +++ b/rust/lance/src/dataset/tests/dataset_transactions.rs @@ -377,3 +377,122 @@ async fn test_inline_transaction() { let read_tx = ds_new.read_transaction().await.unwrap().unwrap(); assert_eq!(read_tx, tx); } + +#[tokio::test] +async fn test_list_detached_manifests() { + let test_uri = TempStrDir::default(); + + // Create initial dataset + let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new( + "id", + DataType::Int32, + false, + )])); + + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![1, 2, 3]))], + ) + .unwrap(); + + let dataset = Arc::new( + Dataset::write( + RecordBatchIterator::new([Ok(batch.clone())], schema.clone()), + &test_uri, + None, + ) + .await + .unwrap(), + ); + + // Initially there should be no detached manifests + let detached = dataset.list_detached_manifests().await.unwrap(); + assert!(detached.is_empty()); + + // Create a detached transaction with properties + let mut properties = HashMap::new(); + properties.insert("detached_key".to_string(), "detached_value".to_string()); + + let batch2 = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![4, 5, 6]))], + ) + .unwrap(); + + // Use execute_uncommitted + CommitBuilder with_detached(true) + let transaction = InsertBuilder::new(dataset.clone()) + .with_params(&WriteParams { + mode: WriteMode::Append, + transaction_properties: Some(Arc::new(properties.clone())), + ..Default::default() + }) + .execute_uncommitted(vec![batch2]) + .await + .unwrap(); + + CommitBuilder::new(dataset.clone()) + .with_detached(true) + .execute(transaction) + .await + .unwrap(); + + // Now there should be one detached manifest + let detached = dataset.list_detached_manifests().await.unwrap(); + assert_eq!(detached.len(), 1); + + // The detached version should have the high bit set + let detached_version = detached[0].version; + assert!(lance_table::format::is_detached_version(detached_version)); + + // We should be able to checkout the detached version and read transaction properties + let checked_out = dataset.checkout_version(detached_version).await.unwrap(); + let tx = checked_out.read_transaction().await.unwrap().unwrap(); + let tx_props = tx.transaction_properties.unwrap(); + assert_eq!( + tx_props.get("detached_key"), + Some(&"detached_value".to_string()) + ); + + // The detached dataset should have more rows + assert_eq!(checked_out.count_rows(None).await.unwrap(), 6); + + // Create another detached transaction + let batch3 = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![7, 8, 9]))], + ) + .unwrap(); + + let mut properties2 = HashMap::new(); + properties2.insert("second_key".to_string(), "second_value".to_string()); + + let transaction2 = InsertBuilder::new(dataset.clone()) + .with_params(&WriteParams { + mode: WriteMode::Append, + transaction_properties: Some(Arc::new(properties2)), + ..Default::default() + }) + .execute_uncommitted(vec![batch3]) + .await + .unwrap(); + + CommitBuilder::new(dataset.clone()) + .with_detached(true) + .execute(transaction2) + .await + .unwrap(); + + // Now there should be two detached manifests + let detached = dataset.list_detached_manifests().await.unwrap(); + assert_eq!(detached.len(), 2); + + // Both should be detached versions + for loc in &detached { + assert!(lance_table::format::is_detached_version(loc.version)); + } + + // Regular versions() should not include detached manifests + let versions = dataset.versions().await.unwrap(); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].version, 1); +} From 63d923bbdb865aa79e2a0bb9601eff21d8e47747 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Fri, 20 Mar 2026 16:54:39 -0700 Subject: [PATCH 056/206] fix: like queries with a prefix should be accelerated by btree and zonemap (#6188) This allows pruning `LIKE "foo%"` through any index that allows pruning a range of string prefixes, this includes btree and zonemap. I ended up adding a `LikePrefix` instead of `StartsWith` because it seems like DataFusion converts `StartsWith` to `LIKE` --- .typos.toml | 1 + rust/lance-index/src/scalar.rs | 72 +++ rust/lance-index/src/scalar/bitmap.rs | 5 + rust/lance-index/src/scalar/btree.rs | 231 +++++++++ rust/lance-index/src/scalar/expression.rs | 558 +++++++++++++++++++++- rust/lance-index/src/scalar/zonemap.rs | 339 ++++++++++++- rust/lance/src/dataset/scanner.rs | 552 +++++++++++++++++++++ 7 files changed, 1752 insertions(+), 6 deletions(-) diff --git a/.typos.toml b/.typos.toml index cec3c147941..0baff74f8ac 100644 --- a/.typos.toml +++ b/.typos.toml @@ -13,6 +13,7 @@ rabit = "rabit" flate = "flate" Ines = "Ines" alph = "alph" +caf = "caf" [default.expect] nprobs = "nprobes" diff --git a/rust/lance-index/src/scalar.rs b/rust/lance-index/src/scalar.rs index 531c650c700..050ea590330 100644 --- a/rust/lance-index/src/scalar.rs +++ b/rust/lance-index/src/scalar.rs @@ -368,6 +368,9 @@ pub enum SargableQuery { FullTextSearch(FullTextSearchQuery), /// Retrieve all row ids where the value is null IsNull(), + /// Retrieve all row ids where the value matches LIKE 'prefix%' pattern + /// This is used for both explicit LIKE expressions and starts_with() function calls + LikePrefix(ScalarValue), } impl AnyQuery for SargableQuery { @@ -416,6 +419,9 @@ impl AnyQuery for SargableQuery { Self::Equals(val) => { format!("{} = {}", col, val) } + Self::LikePrefix(prefix) => { + format!("{} LIKE '{}%'", col, prefix) + } } } @@ -468,6 +474,16 @@ impl AnyQuery for SargableQuery { )), Self::IsNull() => col_expr.is_null(), Self::Equals(value) => col_expr.eq(Expr::Literal(value.clone(), None)), + Self::LikePrefix(prefix) => { + let pattern = match prefix { + ScalarValue::Utf8(Some(s)) => ScalarValue::Utf8(Some(format!("{}%", s))), + ScalarValue::LargeUtf8(Some(s)) => { + ScalarValue::LargeUtf8(Some(format!("{}%", s))) + } + other => other.clone(), + }; + col_expr.like(Expr::Literal(pattern, None)) + } } } @@ -863,6 +879,62 @@ impl UpdateCriteria { } } +/// Compute the lexicographically next prefix by incrementing the last character's code point. +/// Returns None if no valid upper bound exists. +/// +/// This is used for LIKE prefix queries to convert `LIKE 'foo%'` to range `[foo, fop)`. +/// +/// # UTF-8 and Unicode Handling +/// +/// This function operates on Unicode code points (characters), not bytes. Since UTF-8 +/// byte ordering is identical to Unicode code point ordering, incrementing a character's +/// code point produces the correct lexicographic successor for byte-wise string comparison. +/// +/// If incrementing the last character would overflow or land in the surrogate range +/// (U+D800-U+DFFF), we try incrementing the previous character, and so on. +/// +/// Examples: +/// - `"foo"` → `Some("fop")` +/// - `"café"` → `Some("cafê")` (é U+00E9 → ê U+00EA) +/// - `"abc中"` → `Some("abc丮")` (中 U+4E2D → 丮 U+4E2E) +/// - `"cafÿ"` → `Some("cafĀ")` (ÿ U+00FF → Ā U+0100) +pub fn compute_next_prefix(prefix: &str) -> Option { + if prefix.is_empty() { + return None; + } + + let chars: Vec = prefix.chars().collect(); + + // Try incrementing characters from right to left + for i in (0..chars.len()).rev() { + if let Some(next_char) = next_unicode_char(chars[i]) { + let mut result: String = chars[..i].iter().collect(); + result.push(next_char); + return Some(result); + } + // This character cannot be incremented (e.g., U+10FFFF), try previous + } + + // All characters were at maximum value + None +} + +/// Get the next valid Unicode scalar value after the given character. +/// Skips the surrogate range (U+D800-U+DFFF) which is not valid in UTF-8. +fn next_unicode_char(c: char) -> Option { + let cp = c as u32; + let next_cp = cp.checked_add(1)?; + + // Skip surrogate range (U+D800-U+DFFF) + let next_cp = if (0xD800..=0xDFFF).contains(&next_cp) { + 0xE000 + } else { + next_cp + }; + + char::from_u32(next_cp) +} + /// A trait for a scalar index, a structure that can determine row ids that satisfy scalar queries #[async_trait] pub trait ScalarIndex: Send + Sync + std::fmt::Debug + Index + DeepSizeOf { diff --git a/rust/lance-index/src/scalar/bitmap.rs b/rust/lance-index/src/scalar/bitmap.rs index bf2b47aabe9..2f0b1c03760 100644 --- a/rust/lance-index/src/scalar/bitmap.rs +++ b/rust/lance-index/src/scalar/bitmap.rs @@ -537,6 +537,11 @@ impl ScalarIndex for BitmapIndex { "full text search is not supported for bitmap indexes".into(), )); } + SargableQuery::LikePrefix(_) => { + return Err(Error::not_supported_source( + "LIKE prefix queries are not supported for bitmap indexes".into(), + )); + } }; let selection = NullableRowAddrSet::new(row_ids, null_row_ids.unwrap_or_default()); diff --git a/rust/lance-index/src/scalar/btree.rs b/rust/lance-index/src/scalar/btree.rs index 99bee0b6842..17c180dbf4a 100644 --- a/rust/lance-index/src/scalar/btree.rs +++ b/rust/lance-index/src/scalar/btree.rs @@ -13,6 +13,7 @@ use std::{ use super::{ AnyQuery, BuiltinIndexType, IndexReader, IndexStore, IndexWriter, MetricsCollector, OldIndexDataFilter, SargableQuery, ScalarIndex, ScalarIndexParams, SearchResult, + compute_next_prefix, }; use crate::{Index, IndexType}; use crate::{ @@ -1501,6 +1502,39 @@ impl ScalarIndex for BTreeIndex { )); } SargableQuery::IsNull() => self.page_lookup.pages_null(), + SargableQuery::LikePrefix(prefix) => { + // Convert LikePrefix to a range query: [prefix, next_prefix) + match prefix { + ScalarValue::Utf8(Some(s)) => { + let start = Bound::Included(OrderableScalarValue(prefix.clone())); + let end = match compute_next_prefix(s) { + Some(next) => { + Bound::Excluded(OrderableScalarValue(ScalarValue::Utf8(Some(next)))) + } + None => Bound::Unbounded, + }; + self.page_lookup + .pages_between((start.as_ref(), end.as_ref())) + } + ScalarValue::LargeUtf8(Some(s)) => { + let start = Bound::Included(OrderableScalarValue(prefix.clone())); + let end = match compute_next_prefix(s) { + Some(next) => Bound::Excluded(OrderableScalarValue( + ScalarValue::LargeUtf8(Some(next)), + )), + None => Bound::Unbounded, + }; + self.page_lookup + .pages_between((start.as_ref(), end.as_ref())) + } + _ => { + // Conservative: return all pages for non-string types + // This is consistent with ZoneMap behavior + self.page_lookup + .pages_between((Bound::Unbounded, Bound::Unbounded)) + } + } + } }; // For non-IsNull queries, also include null pages so that null row IDs @@ -2813,6 +2847,203 @@ mod tests { assert_eq!(metrics.parts_loaded.load(Ordering::Relaxed), 1); } + #[tokio::test] + async fn test_like_prefix_search() { + use arrow::datatypes::DataType; + use arrow_array::StringArray; + + let tmpdir = TempObjDir::default(); + let test_store = Arc::new(LanceIndexStore::new( + Arc::new(ObjectStore::local()), + tmpdir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + // Create string data with various prefixes + let values = vec![ + "apple", + "app", + "application", + "banana", + "band", + "test_ns$table1", + "test_ns$table2", + "test_ns2$table1", + "test", + "testing", + ]; + let row_ids: Vec = (0..values.len() as u64).collect(); + + let schema = Arc::new(arrow::datatypes::Schema::new(vec![ + arrow::datatypes::Field::new("value", DataType::Utf8, false), + arrow::datatypes::Field::new("_rowid", DataType::UInt64, false), + ])); + + let batch = arrow::record_batch::RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(StringArray::from(values.clone())), + Arc::new(arrow_array::UInt64Array::from(row_ids)), + ], + ) + .unwrap(); + + let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new( + schema, + stream::once(async { Ok(batch) }), + )); + + train_btree_index(stream, test_store.as_ref(), 100, None, None) + .await + .unwrap(); + + let index = BTreeIndex::load(test_store, None, &LanceCache::no_cache()) + .await + .unwrap(); + + // Test LikePrefix for "app" - should match "apple", "app", "application" (row ids 0, 1, 2) + let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("app".to_string()))); + let result = index.search(&query, &NoOpMetricsCollector).await.unwrap(); + + match &result { + SearchResult::Exact(row_ids) => { + let ids: Vec = row_ids + .true_rows() + .row_addrs() + .unwrap() + .map(u64::from) + .collect(); + assert!(ids.contains(&0), "Should contain row 0 (apple)"); + assert!(ids.contains(&1), "Should contain row 1 (app)"); + assert!(ids.contains(&2), "Should contain row 2 (application)"); + assert!(!ids.contains(&3), "Should not contain row 3 (banana)"); + } + _ => panic!("Expected Exact result"), + } + + // Test LikePrefix for "test_ns$" - should match "test_ns$table1", "test_ns$table2" (row ids 5, 6) + let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("test_ns$".to_string()))); + let result = index.search(&query, &NoOpMetricsCollector).await.unwrap(); + + match &result { + SearchResult::Exact(row_ids) => { + let ids: Vec = row_ids + .true_rows() + .row_addrs() + .unwrap() + .map(u64::from) + .collect(); + assert!(ids.contains(&5), "Should contain row 5 (test_ns$table1)"); + assert!(ids.contains(&6), "Should contain row 6 (test_ns$table2)"); + assert!( + !ids.contains(&7), + "Should not contain row 7 (test_ns2$table1)" + ); + } + _ => panic!("Expected Exact result"), + } + + // Test LikePrefix for "test" - should match "test", "testing", "test_ns$table1", "test_ns$table2", "test_ns2$table1" + let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("test".to_string()))); + let result = index.search(&query, &NoOpMetricsCollector).await.unwrap(); + + match &result { + SearchResult::Exact(row_ids) => { + let ids: Vec = row_ids + .true_rows() + .row_addrs() + .unwrap() + .map(u64::from) + .collect(); + assert!( + ids.contains(&5), + "Should contain row 5 (test_ns$table1): {:?}", + ids + ); + assert!( + ids.contains(&6), + "Should contain row 6 (test_ns$table2): {:?}", + ids + ); + assert!( + ids.contains(&7), + "Should contain row 7 (test_ns2$table1): {:?}", + ids + ); + assert!(ids.contains(&8), "Should contain row 8 (test): {:?}", ids); + assert!( + ids.contains(&9), + "Should contain row 9 (testing): {:?}", + ids + ); + } + _ => panic!("Expected Exact result"), + } + } + + #[tokio::test] + async fn test_like_prefix_search_large_utf8() { + use arrow::datatypes::DataType; + use arrow_array::LargeStringArray; + + let tmpdir = TempObjDir::default(); + let test_store = Arc::new(LanceIndexStore::new( + Arc::new(ObjectStore::local()), + tmpdir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + let values = vec!["apple", "app", "application", "banana"]; + let row_ids: Vec = (0..values.len() as u64).collect(); + + let schema = Arc::new(arrow::datatypes::Schema::new(vec![ + arrow::datatypes::Field::new("value", DataType::LargeUtf8, false), + arrow::datatypes::Field::new("_rowid", DataType::UInt64, false), + ])); + + let batch = arrow::record_batch::RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(LargeStringArray::from(values)), + Arc::new(arrow_array::UInt64Array::from(row_ids)), + ], + ) + .unwrap(); + + let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new( + schema, + stream::once(async { Ok(batch) }), + )); + + train_btree_index(stream, test_store.as_ref(), 100, None, None) + .await + .unwrap(); + + let index = BTreeIndex::load(test_store, None, &LanceCache::no_cache()) + .await + .unwrap(); + + // Test LikePrefix with LargeUtf8 + let query = SargableQuery::LikePrefix(ScalarValue::LargeUtf8(Some("app".to_string()))); + let result = index.search(&query, &NoOpMetricsCollector).await.unwrap(); + + match &result { + SearchResult::Exact(row_ids) => { + let ids: Vec = row_ids + .true_rows() + .row_addrs() + .unwrap() + .map(u64::from) + .collect(); + assert!(ids.contains(&0), "Should contain row 0 (apple)"); + assert!(ids.contains(&1), "Should contain row 1 (app)"); + assert!(ids.contains(&2), "Should contain row 2 (application)"); + assert!(!ids.contains(&3), "Should not contain row 3 (banana)"); + } + _ => panic!("Expected Exact result"), + } + } + #[tokio::test] async fn test_fragment_btree_index_consistency() { // Setup stores for both indexes diff --git a/rust/lance-index/src/scalar/expression.rs b/rust/lance-index/src/scalar/expression.rs index f452ee3858e..e6d04f031f0 100644 --- a/rust/lance-index/src/scalar/expression.rs +++ b/rust/lance-index/src/scalar/expression.rs @@ -14,7 +14,7 @@ use async_trait::async_trait; use datafusion_common::ScalarValue; use datafusion_expr::{ Between, BinaryExpr, Expr, Operator, ReturnFieldArgs, ScalarUDF, - expr::{InList, ScalarFunction}, + expr::{InList, Like, ScalarFunction}, }; use tokio::try_join; @@ -112,6 +112,29 @@ pub trait ScalarQueryParser: std::fmt::Debug + Send + Sync { args: &[Expr], ) -> Option; + /// Visit a LIKE expression + /// + /// Returns an IndexedExpression if the index can accelerate LIKE expressions. + /// For prefix patterns (e.g., "foo%"): + /// - ZoneMaps prune zones based on min/max statistics + /// - BTrees use range query conversion `[prefix, next_prefix)` + /// + /// For patterns with wildcards in the middle (e.g., "foo%bar%"), the leading prefix + /// can still be used for pruning, with the full pattern as a refine expression. + /// + /// # Arguments + /// * `column` - The column name + /// * `like` - The full LIKE expression (for constructing refine_expr if needed) + /// * `pattern` - The LIKE pattern as ScalarValue (e.g., "foo%") + fn visit_like( + &self, + _column: &str, + _like: &Like, + _pattern: &ScalarValue, + ) -> Option { + None + } + /// Visits a potential reference to a column /// /// This function is a little different from the other visitors. It is used to test if a potential @@ -212,6 +235,16 @@ impl ScalarQueryParser for MultiQueryParser { .iter() .find_map(|parser| parser.visit_scalar_function(column, data_type, func, args)) } + fn visit_like( + &self, + column: &str, + like: &Like, + pattern: &ScalarValue, + ) -> Option { + self.parsers + .iter() + .find_map(|parser| parser.visit_like(column, like, pattern)) + } /// TODO(low-priority): This is maybe not quite right. We should filter down the list of parsers based /// on those that consider the reference valid. Instead what we are doing is checking all parsers if any one /// parser considers the reference valid. @@ -340,13 +373,201 @@ impl ScalarQueryParser for SargableQueryParser { fn visit_scalar_function( &self, - _: &str, - _: &DataType, - _: &ScalarUDF, - _: &[Expr], + column: &str, + _data_type: &DataType, + func: &ScalarUDF, + args: &[Expr], ) -> Option { + // Handle starts_with(col, 'prefix') -> convert to LikePrefix query + if func.name() == "starts_with" && args.len() == 2 { + // Extract the prefix from the second argument + let prefix = match &args[1] { + Expr::Literal(ScalarValue::Utf8(Some(s)), _) => ScalarValue::Utf8(Some(s.clone())), + Expr::Literal(ScalarValue::LargeUtf8(Some(s)), _) => { + ScalarValue::LargeUtf8(Some(s.clone())) + } + _ => return None, + }; + + let query = SargableQuery::LikePrefix(prefix); + return Some(IndexedExpression::index_query_with_recheck( + column.to_string(), + self.index_name.clone(), + Arc::new(query), + self.needs_recheck, + )); + } + None } + + fn visit_like( + &self, + column: &str, + like: &Like, + pattern: &ScalarValue, + ) -> Option { + // Case-insensitive LIKE (ILIKE) cannot be efficiently pruned with zone maps + if like.case_insensitive { + return None; + } + + // Extract the pattern string + let pattern_str = match pattern { + ScalarValue::Utf8(Some(s)) => s.as_str(), + ScalarValue::LargeUtf8(Some(s)) => s.as_str(), + _ => return None, + }; + + // Try to extract a prefix from the LIKE pattern + let (prefix, needs_refine) = extract_like_leading_prefix(pattern_str, like.escape_char)?; + + // Create the prefix ScalarValue with the same type as the pattern + let prefix_value = match pattern { + ScalarValue::Utf8(_) => ScalarValue::Utf8(Some(prefix)), + ScalarValue::LargeUtf8(_) => ScalarValue::LargeUtf8(Some(prefix)), + _ => return None, + }; + + let query = SargableQuery::LikePrefix(prefix_value); + let scalar_query = Some(ScalarIndexExpr::Query(ScalarIndexSearch { + column: column.to_string(), + index_name: self.index_name.clone(), + query: Arc::new(query), + needs_recheck: self.needs_recheck, + })); + + // If the pattern has wildcards beyond simple prefix, add refine expression + let refine_expr = if needs_refine { + Some(Expr::Like(like.clone())) + } else { + None + }; + + Some(IndexedExpression { + scalar_query, + refine_expr, + }) + } +} + +/// Extract the leading literal prefix from a LIKE pattern. +/// +/// Returns `Some((prefix, needs_refine))` where: +/// - `prefix` is the leading literal portion before any wildcards +/// - `needs_refine` is true if the pattern has wildcards beyond a simple trailing `%` +/// +/// Returns `None` if the pattern starts with a wildcard (no leading literal). +/// +/// Examples: +/// - "foo%" -> Some(("foo", false)) - pure prefix, no recheck needed +/// - "foo%bar%" -> Some(("foo", true)) - can use prefix for pruning, needs recheck +/// - "foo_bar%" -> Some(("foo", true)) - _ is a wildcard, needs recheck +/// - "foo\%bar%" with escape '\' -> Some(("foo%bar", false)) - escaped %, pure prefix +/// - "%foo" -> None - starts with wildcard, cannot prune +/// - "foo" -> None - no wildcard at all, use equality instead +fn extract_like_leading_prefix(pattern: &str, escape_char: Option) -> Option<(String, bool)> { + let chars: Vec = pattern.chars().collect(); + let len = chars.len(); + + if len == 0 { + return None; + } + + // DataFusion's starts_with simplification escapes special characters with backslash + // but doesn't set escape_char. Use backslash as default escape character. + // Pattern: starts_with(col, 'test_ns$') -> col LIKE 'test\_ns$%' (escape_char: None) + // See: https://github.com/apache/datafusion/issues/XXXX + let effective_escape_char = escape_char.or(Some('\\')); + + // Helper to check if a character at position i is escaped + let is_escaped = |i: usize| -> bool { + if let Some(esc) = effective_escape_char { + if i > 0 && chars[i - 1] == esc { + // Check if the escape char itself is escaped + if i >= 2 && chars[i - 2] == esc { + false // Escape was escaped, so this char is NOT escaped + } else { + true // This char is escaped + } + } else { + false + } + } else { + // No escape character defined - nothing can be escaped + false + } + }; + + // Pattern must contain at least one unescaped wildcard + let has_wildcard = chars.iter().enumerate().any(|(i, &c)| { + if c != '%' && c != '_' { + return false; + } + !is_escaped(i) + }); + + if !has_wildcard { + return None; // No wildcards, should use equality + } + + // Check if pattern starts with an unescaped wildcard + if chars[0] == '%' || chars[0] == '_' { + return None; // Starts with wildcard, cannot prune + } + + // Extract the leading literal prefix (everything before first unescaped wildcard) + let mut prefix = String::new(); + let mut i = 0; + let mut found_wildcard = false; + + while i < len { + let c = chars[i]; + + // Check for escape character (using effective escape char which may be inferred) + if let Some(esc) = effective_escape_char + && c == esc + && i + 1 < len + { + let next = chars[i + 1]; + if next == '%' || next == '_' || next == esc { + // Escaped character - add the literal character + prefix.push(next); + i += 2; + continue; + } + } + + // Check for unescaped wildcard + if c == '%' || c == '_' { + found_wildcard = true; + break; + } + + prefix.push(c); + i += 1; + } + + if prefix.is_empty() { + return None; + } + + // Check if pattern is just a simple prefix (ends with single % and nothing after) + let needs_refine = if found_wildcard && i < len { + // Check if we're at a % wildcard + if chars[i] == '%' && i + 1 == len { + // Pattern is "prefix%" - pure prefix match, no refine needed + false + } else { + // Pattern has more after first wildcard, or has _ wildcard + true + } + } else { + // No wildcard found (shouldn't happen due to earlier check) + false + }; + + Some((prefix, needs_refine)) } /// A parser for bloom filter indices that only support equals, is_null, and is_in operations @@ -1660,6 +1881,21 @@ fn visit_scalar_fn( query_parser.visit_scalar_function(&col, &data_type, &scalar_fn.func, &scalar_fn.args) } +fn visit_like_expr( + like: &Like, + index_info: &dyn IndexInformationProvider, +) -> Option { + let (column, _, query_parser) = maybe_indexed_column(&like.expr, index_info)?; + + // Extract the pattern as a ScalarValue + let pattern = match like.pattern.as_ref() { + Expr::Literal(scalar, _) => scalar.clone(), + _ => return None, + }; + + query_parser.visit_like(&column, like, &pattern) +} + fn visit_node( expr: &Expr, index_info: &dyn IndexInformationProvider, @@ -1683,6 +1919,14 @@ fn visit_node( Expr::Not(expr) => visit_not(expr.as_ref(), index_info, depth), Expr::BinaryExpr(binary_expr) => visit_binary_expr(binary_expr, index_info, depth), Expr::ScalarFunction(scalar_fn) => Ok(visit_scalar_fn(scalar_fn, index_info)), + Expr::Like(like) => { + if like.negated { + // NOT LIKE cannot be efficiently pruned with zone maps + Ok(None) + } else { + Ok(visit_like_expr(like, index_info)) + } + } _ => Ok(None), } } @@ -2450,4 +2694,308 @@ mod tests { NullableIndexExprResult::AtLeast(_) )); } + + #[test] + fn test_extract_like_leading_prefix() { + // Simple prefix patterns (no recheck needed) + assert_eq!( + extract_like_leading_prefix("foo%", None), + Some(("foo".to_string(), false)) + ); + assert_eq!( + extract_like_leading_prefix("abc%", None), + Some(("abc".to_string(), false)) + ); + + // Patterns with wildcards in the middle (need recheck) + assert_eq!( + extract_like_leading_prefix("foo%bar%", None), + Some(("foo".to_string(), true)) + ); + assert_eq!( + extract_like_leading_prefix("foo_bar%", None), + Some(("foo".to_string(), true)) + ); + assert_eq!( + extract_like_leading_prefix("foo%bar", None), + Some(("foo".to_string(), true)) + ); + assert_eq!( + extract_like_leading_prefix("foo_", None), + Some(("foo".to_string(), true)) + ); + + // Not prefix patterns (starts with wildcard) + assert_eq!(extract_like_leading_prefix("%foo", None), None); + assert_eq!(extract_like_leading_prefix("_foo%", None), None); + assert_eq!(extract_like_leading_prefix("%", None), None); + + // No wildcard at all (should use equality) + assert_eq!(extract_like_leading_prefix("foo", None), None); + + // With escape character + assert_eq!( + extract_like_leading_prefix(r"foo\%bar%", Some('\\')), + Some(("foo%bar".to_string(), false)) + ); + assert_eq!( + extract_like_leading_prefix(r"foo\_bar%", Some('\\')), + Some(("foo_bar".to_string(), false)) + ); + assert_eq!( + extract_like_leading_prefix(r"foo\\bar%", Some('\\')), + Some(("foo\\bar".to_string(), false)) + ); + + // Escaped trailing % is not a wildcard (no wildcards) + assert_eq!(extract_like_leading_prefix(r"foo\%", Some('\\')), None); + + // With backslash as default escape (for DataFusion starts_with compatibility): + // "foo\%" means escaped %, no wildcard -> None (should use equality) + assert_eq!(extract_like_leading_prefix(r"foo\%", None), None); + // "foo\bar%" - \b is not a valid escape sequence, so \ and b are literals, % is wildcard + assert_eq!( + extract_like_leading_prefix(r"foo\bar%", None), + Some(("foo\\bar".to_string(), false)) + ); + + // Empty pattern + assert_eq!(extract_like_leading_prefix("", None), None); + + // Mixed escaped and unescaped + assert_eq!( + extract_like_leading_prefix(r"foo\%bar%baz%", Some('\\')), + Some(("foo%bar".to_string(), true)) + ); + } + + #[test] + fn test_like_expression_parsing() { + // Test that LIKE expressions are parsed correctly with refine_expr for complex patterns + + let index_info = MockIndexInfoProvider::new(vec![( + "color", + ColInfo::new( + DataType::Utf8, + Box::new(SargableQueryParser::new("color_idx".to_string(), false)), + ), + )]); + + // Simple prefix pattern: LIKE 'foo%' -> LikePrefix("foo"), no refine_expr + let schema = Schema::new(vec![Field::new("color", DataType::Utf8, false)]); + let df_schema: DFSchema = schema.try_into().unwrap(); + let ctx = get_session_context(&LanceExecutionOptions::default()); + let state = ctx.state(); + + let expr = state + .create_logical_expr("color LIKE 'foo%'", &df_schema) + .unwrap(); + let result = apply_scalar_indices(expr, &index_info).unwrap(); + + assert!(result.scalar_query.is_some(), "Should have scalar_query"); + assert!( + result.refine_expr.is_none(), + "Simple prefix should not need refine_expr" + ); + + // Extract the query and verify it's LikePrefix + if let Some(ScalarIndexExpr::Query(search)) = &result.scalar_query { + let query = search.query.as_any().downcast_ref::(); + assert!(query.is_some(), "Query should be SargableQuery"); + match query.unwrap() { + SargableQuery::LikePrefix(prefix) => { + assert_eq!(prefix, &ScalarValue::Utf8(Some("foo".to_string()))); + } + _ => panic!("Expected LikePrefix query"), + } + } else { + panic!("Expected Query variant"); + } + + // Complex pattern: LIKE 'foo%bar%' -> LikePrefix("foo"), with refine_expr + let expr = state + .create_logical_expr("color LIKE 'foo%bar%'", &df_schema) + .unwrap(); + let result = apply_scalar_indices(expr, &index_info).unwrap(); + + assert!(result.scalar_query.is_some(), "Should have scalar_query"); + assert!( + result.refine_expr.is_some(), + "Complex pattern should have refine_expr" + ); + + // Verify the query is still LikePrefix("foo") + if let Some(ScalarIndexExpr::Query(search)) = &result.scalar_query { + let query = search.query.as_any().downcast_ref::(); + assert!(query.is_some(), "Query should be SargableQuery"); + match query.unwrap() { + SargableQuery::LikePrefix(prefix) => { + assert_eq!(prefix, &ScalarValue::Utf8(Some("foo".to_string()))); + } + _ => panic!("Expected LikePrefix query"), + } + } + + // Verify the refine_expr is the original LIKE expression + let refine = result.refine_expr.unwrap(); + match refine { + Expr::Like(like) => { + assert!(!like.negated); + assert!(!like.case_insensitive); + if let Expr::Literal(ScalarValue::Utf8(Some(pattern)), _) = like.pattern.as_ref() { + assert_eq!(pattern, "foo%bar%"); + } else { + panic!("Expected Utf8 literal pattern"); + } + } + _ => panic!("Expected Like expression in refine_expr"), + } + + // Pattern starting with wildcard: LIKE '%foo' -> no index, only refine + let expr = state + .create_logical_expr("color LIKE '%foo'", &df_schema) + .unwrap(); + let result = apply_scalar_indices(expr, &index_info).unwrap(); + + assert!( + result.scalar_query.is_none(), + "Pattern starting with wildcard should not use index" + ); + assert!(result.refine_expr.is_some(), "Should fall back to refine"); + } + + #[test] + fn test_starts_with_with_underscore_after_optimization() { + // Test that starts_with with underscore in prefix works correctly after DataFusion optimization + // DataFusion simplifies starts_with(col, 'test_ns$') to col LIKE 'test_ns$%' + // The underscore in the prefix should NOT be treated as a wildcard! + let index_info = MockIndexInfoProvider::new(vec![( + "object_id", + ColInfo::new( + DataType::Utf8, + Box::new(SargableQueryParser::new("object_id_idx".to_string(), false)), + ), + )]); + + let schema = Schema::new(vec![Field::new("object_id", DataType::Utf8, false)]); + let df_schema: DFSchema = schema.try_into().unwrap(); + let ctx = get_session_context(&LanceExecutionOptions::default()); + let state = ctx.state(); + + // Create the expression with starts_with containing underscore + let expr = state + .create_logical_expr("starts_with(object_id, 'test_ns$')", &df_schema) + .unwrap(); + + // Apply DataFusion simplification (this may convert starts_with to LIKE) + let props = ExecutionProps::new().with_query_execution_start_time(Utc::now()); + let simplify_context = SimplifyContext::new(&props).with_schema(Arc::new(df_schema)); + let simplifier = + datafusion::optimizer::simplify_expressions::ExprSimplifier::new(simplify_context); + let simplified_expr = simplifier.simplify(expr).unwrap(); + + // Apply scalar indices + let result = apply_scalar_indices(simplified_expr, &index_info).unwrap(); + + // The prefix should be "test_ns$", NOT "test" + // This test documents the current (potentially broken) behavior + if let Some(ScalarIndexExpr::Query(search)) = &result.scalar_query { + let query = search + .query + .as_any() + .downcast_ref::() + .unwrap(); + match query { + SargableQuery::LikePrefix(prefix) => { + let prefix_str = match prefix { + ScalarValue::Utf8(Some(s)) => s.clone(), + _ => panic!("Expected Utf8 prefix"), + }; + // Verify the prefix is correctly extracted with underscore as literal + assert_eq!( + prefix_str, "test_ns$", + "Prefix should be 'test_ns$', not 'test' (underscore should not be a wildcard)" + ); + } + _ => panic!("Expected LikePrefix query"), + } + } else { + // If no scalar query, it means the pattern was not recognized + panic!("Expected scalar_query to be present"); + } + } + + #[test] + fn test_starts_with_to_like_conversion() { + // Test that starts_with(col, 'prefix') is converted to LikePrefix query + let index_info = MockIndexInfoProvider::new(vec![( + "color", + ColInfo::new( + DataType::Utf8, + Box::new(SargableQueryParser::new("color_idx".to_string(), false)), + ), + )]); + + let schema = Schema::new(vec![Field::new("color", DataType::Utf8, false)]); + let df_schema: DFSchema = schema.try_into().unwrap(); + let ctx = get_session_context(&LanceExecutionOptions::default()); + let state = ctx.state(); + + // starts_with(color, 'foo') should be converted to LikePrefix("foo") + let expr = state + .create_logical_expr("starts_with(color, 'foo')", &df_schema) + .unwrap(); + let result = apply_scalar_indices(expr, &index_info).unwrap(); + + assert!( + result.scalar_query.is_some(), + "starts_with should use index" + ); + assert!( + result.refine_expr.is_none(), + "Pure prefix starts_with should not need refine_expr" + ); + + // Extract the query and verify it's LikePrefix + if let Some(ScalarIndexExpr::Query(search)) = &result.scalar_query { + let query = search.query.as_any().downcast_ref::(); + assert!(query.is_some(), "Query should be SargableQuery"); + match query.unwrap() { + SargableQuery::LikePrefix(prefix) => { + assert_eq!(prefix, &ScalarValue::Utf8(Some("foo".to_string()))); + } + _ => panic!("Expected LikePrefix query"), + } + } else { + panic!("Expected Query variant"); + } + + // Both starts_with and LIKE 'prefix%' should produce the same LikePrefix query + let like_expr = state + .create_logical_expr("color LIKE 'foo%'", &df_schema) + .unwrap(); + let like_result = apply_scalar_indices(like_expr, &index_info).unwrap(); + + // Compare the queries - both should be LikePrefix("foo") + if let ( + Some(ScalarIndexExpr::Query(starts_with_search)), + Some(ScalarIndexExpr::Query(like_search)), + ) = (&result.scalar_query, &like_result.scalar_query) + { + let sw_query = starts_with_search + .query + .as_any() + .downcast_ref::() + .unwrap(); + let like_query = like_search + .query + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!( + sw_query, like_query, + "starts_with and LIKE 'prefix%' should produce identical queries" + ); + } + } } diff --git a/rust/lance-index/src/scalar/zonemap.rs b/rust/lance-index/src/scalar/zonemap.rs index a8ea616a31b..9156322b1dc 100644 --- a/rust/lance-index/src/scalar/zonemap.rs +++ b/rust/lance-index/src/scalar/zonemap.rs @@ -20,6 +20,7 @@ use crate::scalar::registry::{ }; use crate::scalar::{ BuiltinIndexType, CreatedIndex, SargableQuery, ScalarIndexParams, UpdateCriteria, + compute_next_prefix, }; use datafusion::functions_aggregate::min_max::{MaxAccumulator, MinAccumulator}; use datafusion_expr::Accumulator; @@ -325,6 +326,53 @@ impl ZoneMapIndex { SargableQuery::FullTextSearch(_) => Err(Error::not_supported_source( "full text search is not supported for zonemap indexes".into(), )), + SargableQuery::LikePrefix(prefix) => { + // For prefix matching, a zone can match if: + // - zone.max >= prefix (there could be values >= prefix) + // - zone.min < next_prefix (there could be values < next_prefix) + // + // For example, prefix "foo": + // - Zone [aaa, azz]: max="azz" < "foo", so no match + // - Zone [fa, foz]: min="fa" < "fop", max="foz" >= "foo", so potential match + // - Zone [fop, fzz]: min="fop" >= "fop", so no match + + let prefix_str = match prefix { + ScalarValue::Utf8(Some(s)) => s.as_str(), + ScalarValue::LargeUtf8(Some(s)) => s.as_str(), + _ => return Ok(true), // Conservative: include zone if not a string prefix + }; + + // Empty prefix matches everything + if prefix_str.is_empty() { + return Ok(true); + } + + // Check zone.max >= prefix + let max_check = &zone.max >= prefix; + if !max_check { + return Ok(false); + } + + // Compute next_prefix by incrementing the last byte + // If the prefix ends with 0xFF bytes, we need to handle overflow + let next_prefix = compute_next_prefix(prefix_str); + + match next_prefix { + Some(next) => { + // Check zone.min < next_prefix + let next_scalar = match prefix { + ScalarValue::Utf8(_) => ScalarValue::Utf8(Some(next)), + ScalarValue::LargeUtf8(_) => ScalarValue::LargeUtf8(Some(next)), + _ => return Ok(true), + }; + Ok(zone.min < next_scalar) + } + None => { + // No upper bound (prefix is all 0xFF), so any zone with max >= prefix matches + Ok(true) + } + } + } } } @@ -923,7 +971,11 @@ mod tests { use futures::{StreamExt, TryStreamExt, stream}; use lance_core::utils::mask::NullableRowAddrSet; use lance_core::utils::tempfile::TempObjDir; - use lance_core::{ROW_ADDR, cache::LanceCache, utils::mask::RowAddrTreeMap}; + use lance_core::{ + ROW_ADDR, + cache::{LanceCache, WeakLanceCache}, + utils::mask::RowAddrTreeMap, + }; use lance_datafusion::datagen::DatafusionDatagenExt; use lance_datagen::ArrayGeneratorExt; use lance_datagen::{BatchCount, RowCount, array}; @@ -2199,4 +2251,289 @@ mod tests { &[4294967296, 4294967297, 4294967298, 4294967299, 4294967300] ); } + + #[tokio::test] + async fn test_like_prefix_query() { + let tmpdir = TempObjDir::default(); + let test_store = Arc::new(LanceIndexStore::new( + Arc::new(ObjectStore::local()), + tmpdir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + // Create zones with different string ranges + // Zone 0: ["aaa", "azz"] - should NOT match "foo%" + // Zone 1: ["bar", "baz"] - should NOT match "foo%" + // Zone 2: ["fa", "foz"] - should match "foo%" (contains potential matches) + // Zone 3: ["fop", "fzz"] - should NOT match "foo%" (all values >= "fop") + // Zone 4: ["foo", "foobar"] - should match "foo%" + // Zone 5: ["gaa", "gzz"] - should NOT match "foo%" + + let zones = vec![ + ZoneMapStatistics { + min: ScalarValue::Utf8(Some("aaa".to_string())), + max: ScalarValue::Utf8(Some("azz".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 0, + start: 0, + length: 100, + }, + }, + ZoneMapStatistics { + min: ScalarValue::Utf8(Some("bar".to_string())), + max: ScalarValue::Utf8(Some("baz".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 1, + start: 0, + length: 100, + }, + }, + ZoneMapStatistics { + min: ScalarValue::Utf8(Some("fa".to_string())), + max: ScalarValue::Utf8(Some("foz".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 2, + start: 0, + length: 100, + }, + }, + ZoneMapStatistics { + min: ScalarValue::Utf8(Some("fop".to_string())), + max: ScalarValue::Utf8(Some("fzz".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 3, + start: 0, + length: 100, + }, + }, + ZoneMapStatistics { + min: ScalarValue::Utf8(Some("foo".to_string())), + max: ScalarValue::Utf8(Some("foobar".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 4, + start: 0, + length: 100, + }, + }, + ZoneMapStatistics { + min: ScalarValue::Utf8(Some("gaa".to_string())), + max: ScalarValue::Utf8(Some("gzz".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 5, + start: 0, + length: 100, + }, + }, + ]; + + let index = ZoneMapIndex { + zones, + data_type: DataType::Utf8, + rows_per_zone: ROWS_PER_ZONE_DEFAULT, + store: test_store, + fri: None, + index_cache: WeakLanceCache::from(&LanceCache::no_cache()), + }; + + // Test LikePrefix query for "foo" + let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("foo".to_string()))); + let result = index.search(&query, &NoOpMetricsCollector).await.unwrap(); + + // Should match zones 2 and 4 only + let mut expected = RowAddrTreeMap::new(); + // Zone 2: fragment 2 + expected.insert_range((2u64 << 32)..((2u64 << 32) + 100)); + // Zone 4: fragment 4 + expected.insert_range((4u64 << 32)..((4u64 << 32) + 100)); + + assert_eq!(result, SearchResult::at_most(expected)); + } + + #[tokio::test] + async fn test_like_prefix_edge_cases() { + let tmpdir = TempObjDir::default(); + let test_store = Arc::new(LanceIndexStore::new( + Arc::new(ObjectStore::local()), + tmpdir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + // Test edge cases for LIKE prefix + let zones = vec![ + // Zone with values that contain the prefix exactly + ZoneMapStatistics { + min: ScalarValue::Utf8(Some("test".to_string())), + max: ScalarValue::Utf8(Some("test".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 0, + start: 0, + length: 100, + }, + }, + // Zone with values that span across the prefix boundary + ZoneMapStatistics { + min: ScalarValue::Utf8(Some("te".to_string())), + max: ScalarValue::Utf8(Some("tf".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 1, + start: 0, + length: 100, + }, + }, + // Zone completely before prefix + ZoneMapStatistics { + min: ScalarValue::Utf8(Some("abc".to_string())), + max: ScalarValue::Utf8(Some("def".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 2, + start: 0, + length: 100, + }, + }, + ]; + + let index = ZoneMapIndex { + zones, + data_type: DataType::Utf8, + rows_per_zone: ROWS_PER_ZONE_DEFAULT, + store: test_store, + fri: None, + index_cache: WeakLanceCache::from(&LanceCache::no_cache()), + }; + + // Test LikePrefix "test" + let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("test".to_string()))); + let result = index.search(&query, &NoOpMetricsCollector).await.unwrap(); + + // Should match zones 0 and 1 + let mut expected = RowAddrTreeMap::new(); + expected.insert_range(0..100); // Zone 0: fragment 0 + expected.insert_range((1u64 << 32)..((1u64 << 32) + 100)); + + assert_eq!(result, SearchResult::at_most(expected)); + + // Test empty prefix - should match all zones + let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("".to_string()))); + let result = index.search(&query, &NoOpMetricsCollector).await.unwrap(); + + let mut expected = RowAddrTreeMap::new(); + expected.insert_range(0..100); // Zone 0: fragment 0 + expected.insert_range((1u64 << 32)..((1u64 << 32) + 100)); + expected.insert_range((2u64 << 32)..((2u64 << 32) + 100)); + + assert_eq!(result, SearchResult::at_most(expected)); + } + + #[tokio::test] + async fn test_like_prefix_large_utf8() { + let tmpdir = TempObjDir::default(); + let test_store = Arc::new(LanceIndexStore::new( + Arc::new(ObjectStore::local()), + tmpdir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + // Test with LargeUtf8 type + let zones = vec![ + ZoneMapStatistics { + min: ScalarValue::LargeUtf8(Some("aaa".to_string())), + max: ScalarValue::LargeUtf8(Some("azz".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 0, + start: 0, + length: 100, + }, + }, + ZoneMapStatistics { + min: ScalarValue::LargeUtf8(Some("foo".to_string())), + max: ScalarValue::LargeUtf8(Some("foobar".to_string())), + null_count: 0, + nan_count: 0, + bound: ZoneBound { + fragment_id: 1, + start: 0, + length: 100, + }, + }, + ]; + + let index = ZoneMapIndex { + zones, + data_type: DataType::LargeUtf8, + rows_per_zone: ROWS_PER_ZONE_DEFAULT, + store: test_store, + fri: None, + index_cache: WeakLanceCache::from(&LanceCache::no_cache()), + }; + + // Test LikePrefix with LargeUtf8 + let query = SargableQuery::LikePrefix(ScalarValue::LargeUtf8(Some("foo".to_string()))); + let result = index.search(&query, &NoOpMetricsCollector).await.unwrap(); + + // Should match only zone 1 + let mut expected = RowAddrTreeMap::new(); + expected.insert_range((1u64 << 32)..((1u64 << 32) + 100)); + + assert_eq!(result, SearchResult::at_most(expected)); + } + + #[test] + fn test_compute_next_prefix() { + use super::compute_next_prefix; + + // Basic cases + assert_eq!(compute_next_prefix("foo"), Some("fop".to_string())); + assert_eq!(compute_next_prefix("abc"), Some("abd".to_string())); + assert_eq!(compute_next_prefix("a"), Some("b".to_string())); + assert_eq!(compute_next_prefix("z"), Some("{".to_string())); // 'z' + 1 = '{' + + // Edge case: prefix with 'z' at the end + assert_eq!(compute_next_prefix("abz"), Some("ab{".to_string())); + + // Edge case with tilde (~) which is 0x7E + assert_eq!(compute_next_prefix("ab~"), Some("ab\x7f".to_string())); + + // Empty prefix + assert_eq!(compute_next_prefix(""), None); + + // Non-ASCII: works correctly by incrementing Unicode code points + // é (U+00E9) -> ê (U+00EA) + assert_eq!(compute_next_prefix("café"), Some("cafê".to_string())); + // 中 (U+4E2D) -> 丮 (U+4E2E) + assert_eq!(compute_next_prefix("abc中"), Some("abc丮".to_string())); + // ÿ (U+00FF) -> Ā (U+0100) - crosses byte boundary but works + assert_eq!(compute_next_prefix("cafÿ"), Some("cafĀ".to_string())); + + // Edge case: character just before surrogate range + // U+D7FF -> U+E000 (skips surrogate range U+D800-U+DFFF) + assert_eq!( + compute_next_prefix("a\u{D7FF}"), + Some("a\u{E000}".to_string()) + ); + + // Edge case: max Unicode character U+10FFFF, falls back to previous char + assert_eq!(compute_next_prefix("ab\u{10FFFF}"), Some("ac".to_string())); + // All max characters + assert_eq!(compute_next_prefix("\u{10FFFF}\u{10FFFF}"), None); + } } diff --git a/rust/lance/src/dataset/scanner.rs b/rust/lance/src/dataset/scanner.rs index 325bb87af66..63bd7884879 100644 --- a/rust/lance/src/dataset/scanner.rs +++ b/rust/lance/src/dataset/scanner.rs @@ -7514,6 +7514,558 @@ mod test { .unwrap(); } + #[tokio::test] + async fn test_like_prefix_with_btree_index() { + // Create dataset with string data that has various prefixes + // Avoid LIKE special characters (%, _) in data to keep tests simple + let data = gen_batch() + .col( + "name", + array::cycle_utf8_literals(&[ + "apple", + "application", + "app", + "banana", + "band", + "testns1", + "testns2", + "test", + "testing", + "zoo", + ]), + ) + .col("id", array::step::()) + .into_reader_rows(RowCount::from(100), BatchCount::from(1)); + + let mut dataset = Dataset::write(data, "memory://test_like", None) + .await + .unwrap(); + + // Create BTree index on string column + dataset + .create_index( + &["name"], + IndexType::BTree, + None, + &ScalarIndexParams::default(), + true, + ) + .await + .unwrap(); + + // Test 1: Verify LIKE 'app%' uses scalar index and returns correct results + assert_plan_equals( + &dataset, + |scanner| scanner.filter("name LIKE 'app%'"), + "LanceRead: uri=..., projection=[name, id], num_fragments=1, \ + range_before=None, range_after=None, row_id=false, row_addr=false, \ + full_filter=name LIKE Utf8(\"app%\"), refine_filter=-- + ScalarIndexQuery: query=[name LIKE 'app%']@name_idx", + ) + .await + .unwrap(); + + // Verify correct results for LIKE 'app%' + let results = dataset + .scan() + .filter("name LIKE 'app%'") + .unwrap() + .try_into_batch() + .await + .unwrap(); + let names: Vec<&str> = results + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap()) + .collect(); + // Should match: apple, application, app (repeated in cycle) + assert!(names.iter().all(|n| n.starts_with("app"))); + assert!(!names.is_empty()); + + // Test 2: Verify starts_with() uses scalar index (simple prefix without special chars) + // Note: DataFusion optimizes starts_with() to LIKE before our index planning + assert_plan_equals( + &dataset, + |scanner| scanner.filter("starts_with(name, 'ban')"), + "LanceRead: uri=..., projection=[name, id], num_fragments=1, \ + range_before=None, range_after=None, row_id=false, row_addr=false, \ + full_filter=name LIKE Utf8(\"ban%\"), refine_filter=-- + ScalarIndexQuery: query=[name LIKE 'ban%']@name_idx", + ) + .await + .unwrap(); + + // Verify correct results for starts_with + let results = dataset + .scan() + .filter("starts_with(name, 'ban')") + .unwrap() + .try_into_batch() + .await + .unwrap(); + let names: Vec<&str> = results + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap()) + .collect(); + // Should match: banana, band + assert!(names.iter().all(|n| n.starts_with("ban"))); + assert!(!names.is_empty()); + + // Test 3: LIKE with pattern requiring refine (e.g., 'test%2') + assert_plan_equals( + &dataset, + |scanner| scanner.filter("name LIKE 'test%2'"), + "ProjectionExec: expr=[name@0 as name, id@1 as id] + LanceRead: uri=..., projection=[name, id], num_fragments=1, \ +range_before=None, range_after=None, row_id=true, row_addr=false, \ +full_filter=name LIKE Utf8(\"test%2\"), refine_filter=name LIKE Utf8(\"test%2\") + ScalarIndexQuery: query=[name LIKE 'test%']@name_idx", + ) + .await + .unwrap(); + + // Verify correct results for LIKE 'test%2' (needs refine) + let results = dataset + .scan() + .filter("name LIKE 'test%2'") + .unwrap() + .try_into_batch() + .await + .unwrap(); + let names: Vec<&str> = results + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap()) + .collect(); + // Should match: testns2 (ends with '2') + assert!( + names + .iter() + .all(|n| n.starts_with("test") && n.ends_with("2")) + ); + + // Test 4: LIKE starting with wildcard should NOT use scalar index for pruning + // Verify by checking the plan does NOT have ScalarIndexQuery + let mut scanner = dataset.scan(); + scanner.filter("name LIKE '%app%'").unwrap(); + let plan = scanner.create_plan().await.unwrap(); + let plan_str = format!("{:?}", plan); + assert!( + !plan_str.contains("ScalarIndexQuery"), + "LIKE '%app%' should not use scalar index, but got: {}", + plan_str + ); + + // Verify correct results for LIKE '%app%' + let results = dataset + .scan() + .filter("name LIKE '%app%'") + .unwrap() + .try_into_batch() + .await + .unwrap(); + let names: Vec<&str> = results + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap()) + .collect(); + // Should match: apple, application, app (contain 'app') + assert!(names.iter().all(|n| n.contains("app"))); + + // Test 5: NOT LIKE should NOT use scalar index + let mut scanner = dataset.scan(); + scanner.filter("name NOT LIKE 'app%'").unwrap(); + let plan = scanner.create_plan().await.unwrap(); + let plan_str = format!("{:?}", plan); + assert!( + !plan_str.contains("ScalarIndexQuery"), + "NOT LIKE should not use scalar index, but got: {}", + plan_str + ); + } + + #[tokio::test] + async fn test_like_prefix_correctness_with_btree_index() { + // Create dataset with deterministic string data for exact result verification + let names: Vec<&str> = vec![ + "alpha", "alphabet", "beta", "gamma", "delta", "epsilon", "eta", "theta", "iota", + "kappa", + ]; + let data = RecordBatch::try_new( + Arc::new(ArrowSchema::new(vec![ + ArrowField::new("name", DataType::Utf8, false), + ArrowField::new("id", DataType::Int32, false), + ])), + vec![ + Arc::new(StringArray::from(names.clone())), + Arc::new(Int32Array::from_iter_values(0..10)), + ], + ) + .unwrap(); + + let reader = RecordBatchIterator::new( + vec![Ok(data)], + Arc::new(ArrowSchema::new(vec![ + ArrowField::new("name", DataType::Utf8, false), + ArrowField::new("id", DataType::Int32, false), + ])), + ); + + let mut dataset = Dataset::write(reader, "memory://test_like_correctness", None) + .await + .unwrap(); + + // Create BTree index + dataset + .create_index( + &["name"], + IndexType::BTree, + None, + &ScalarIndexParams::default(), + true, + ) + .await + .unwrap(); + + // Test with index + let with_index = dataset + .scan() + .filter("name LIKE 'alpha%'") + .unwrap() + .try_into_batch() + .await + .unwrap(); + + // Test without index (for comparison) + let without_index = dataset + .scan() + .use_scalar_index(false) + .filter("name LIKE 'alpha%'") + .unwrap() + .try_into_batch() + .await + .unwrap(); + + // Both should return same results: alpha, alphabet + assert_eq!(with_index.num_rows(), without_index.num_rows()); + assert_eq!(with_index.num_rows(), 2); + + let with_index_names: BTreeSet = with_index + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap().to_string()) + .collect(); + + let without_index_names: BTreeSet = without_index + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap().to_string()) + .collect(); + + assert_eq!(with_index_names, without_index_names); + assert_eq!( + with_index_names, + BTreeSet::from(["alpha".to_string(), "alphabet".to_string()]) + ); + + // Test starts_with correctness + let starts_with_result = dataset + .scan() + .filter("starts_with(name, 'e')") + .unwrap() + .try_into_batch() + .await + .unwrap(); + + let starts_with_names: BTreeSet = starts_with_result + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap().to_string()) + .collect(); + + // Should match: epsilon, eta + assert_eq!( + starts_with_names, + BTreeSet::from(["epsilon".to_string(), "eta".to_string()]) + ); + } + + #[tokio::test] + async fn test_like_prefix_with_zone_map() { + use lance_index::scalar::BuiltinIndexType; + + // Create dataset with string data that has various prefixes + let data = gen_batch() + .col( + "name", + array::cycle_utf8_literals(&[ + "apple", + "application", + "app", + "banana", + "band", + "testns1", + "testns2", + "test", + "testing", + "zoo", + ]), + ) + .col("id", array::step::()) + .into_reader_rows(RowCount::from(100), BatchCount::from(1)); + + let mut dataset = Dataset::write(data, "memory://test_like_zonemap", None) + .await + .unwrap(); + + // Create ZoneMap index on string column + let params = ScalarIndexParams::for_builtin(BuiltinIndexType::ZoneMap); + dataset + .create_index( + &["name"], + IndexType::Scalar, + Some("name_zonemap".to_string()), + ¶ms, + true, + ) + .await + .unwrap(); + + // Test 1: Verify LIKE 'app%' uses zone map index + let mut scanner = dataset.scan(); + scanner.filter("name LIKE 'app%'").unwrap(); + let plan = scanner.create_plan().await.unwrap(); + let plan_str = format!("{:?}", plan); + // Zone map uses ScalarIndexExec with LikePrefix query + assert!( + plan_str.contains("ScalarIndexExec") && plan_str.contains("LikePrefix"), + "LIKE 'app%' should use zone map index with LikePrefix, but got: {}", + plan_str + ); + + // Verify correct results for LIKE 'app%' + let results = dataset + .scan() + .filter("name LIKE 'app%'") + .unwrap() + .try_into_batch() + .await + .unwrap(); + let names: Vec<&str> = results + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap()) + .collect(); + assert!(names.iter().all(|n| n.starts_with("app"))); + assert!(!names.is_empty()); + + // Test 2: Verify starts_with() uses zone map index + let mut scanner = dataset.scan(); + scanner.filter("starts_with(name, 'ban')").unwrap(); + let plan = scanner.create_plan().await.unwrap(); + let plan_str = format!("{:?}", plan); + assert!( + plan_str.contains("ScalarIndexExec") && plan_str.contains("LikePrefix"), + "starts_with should use zone map index with LikePrefix, but got: {}", + plan_str + ); + + // Verify correct results + let results = dataset + .scan() + .filter("starts_with(name, 'ban')") + .unwrap() + .try_into_batch() + .await + .unwrap(); + let names: Vec<&str> = results + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap()) + .collect(); + assert!(names.iter().all(|n| n.starts_with("ban"))); + + // Test 3: LIKE with refine pattern still uses zone map for prefix pruning + let mut scanner = dataset.scan(); + scanner.filter("name LIKE 'test%2'").unwrap(); + let plan = scanner.create_plan().await.unwrap(); + let plan_str = format!("{:?}", plan); + assert!( + plan_str.contains("ScalarIndexExec") && plan_str.contains("LikePrefix"), + "LIKE 'test%2' should use zone map index for prefix, but got: {}", + plan_str + ); + + // Test 4: LIKE starting with wildcard should NOT use zone map + let mut scanner = dataset.scan(); + scanner.filter("name LIKE '%app%'").unwrap(); + let plan = scanner.create_plan().await.unwrap(); + let plan_str = format!("{:?}", plan); + assert!( + !plan_str.contains("LikePrefix"), + "LIKE '%app%' should not use LikePrefix index, but got: {}", + plan_str + ); + } + + #[tokio::test] + async fn test_like_prefix_correctness_with_zone_map() { + use lance_index::scalar::BuiltinIndexType; + + // Create dataset with deterministic string data for exact result verification + let names: Vec<&str> = vec![ + "alpha", "alphabet", "beta", "gamma", "delta", "epsilon", "eta", "theta", "iota", + "kappa", + ]; + let data = RecordBatch::try_new( + Arc::new(ArrowSchema::new(vec![ + ArrowField::new("name", DataType::Utf8, false), + ArrowField::new("id", DataType::Int32, false), + ])), + vec![ + Arc::new(StringArray::from(names.clone())), + Arc::new(Int32Array::from_iter_values(0..10)), + ], + ) + .unwrap(); + + let reader = RecordBatchIterator::new( + vec![Ok(data)], + Arc::new(ArrowSchema::new(vec![ + ArrowField::new("name", DataType::Utf8, false), + ArrowField::new("id", DataType::Int32, false), + ])), + ); + + let mut dataset = Dataset::write(reader, "memory://test_like_correctness_zonemap", None) + .await + .unwrap(); + + // Create ZoneMap index + let params = ScalarIndexParams::for_builtin(BuiltinIndexType::ZoneMap); + dataset + .create_index( + &["name"], + IndexType::Scalar, + Some("name_zonemap".to_string()), + ¶ms, + true, + ) + .await + .unwrap(); + + // Test with zone map index + let with_index = dataset + .scan() + .filter("name LIKE 'alpha%'") + .unwrap() + .try_into_batch() + .await + .unwrap(); + + // Test without index (for comparison) + let without_index = dataset + .scan() + .use_scalar_index(false) + .filter("name LIKE 'alpha%'") + .unwrap() + .try_into_batch() + .await + .unwrap(); + + // Both should return same results: alpha, alphabet + assert_eq!(with_index.num_rows(), without_index.num_rows()); + assert_eq!(with_index.num_rows(), 2); + + let with_index_names: BTreeSet = with_index + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap().to_string()) + .collect(); + + let without_index_names: BTreeSet = without_index + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap().to_string()) + .collect(); + + assert_eq!(with_index_names, without_index_names); + assert_eq!( + with_index_names, + BTreeSet::from(["alpha".to_string(), "alphabet".to_string()]) + ); + + // Test starts_with correctness with zone map + let starts_with_result = dataset + .scan() + .filter("starts_with(name, 'e')") + .unwrap() + .try_into_batch() + .await + .unwrap(); + + let starts_with_names: BTreeSet = starts_with_result + .column_by_name("name") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .iter() + .map(|s| s.unwrap().to_string()) + .collect(); + + // Should match: epsilon, eta + assert_eq!( + starts_with_names, + BTreeSet::from(["epsilon".to_string(), "eta".to_string()]) + ); + } + #[rstest] #[tokio::test] async fn test_late_materialization( From e143cbaafd623fdb3c5a3ec640a32a9e97d92cb3 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Fri, 20 Mar 2026 19:04:57 -0700 Subject: [PATCH 057/206] feat: allow setting transaction properties in various operations (#6246) Allow callers to be able to set transaction properties in various operations including index optimization, creation and data compaction. --------- Co-authored-by: Claude Co-authored-by: Happy --- rust/lance-index/src/optimize.rs | 17 +++++++++++++++++ rust/lance/src/dataset/optimize.rs | 25 +++++++++++++++++++++---- rust/lance/src/index.rs | 9 +++++---- rust/lance/src/index/create.rs | 24 +++++++++++++++++++----- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/rust/lance-index/src/optimize.rs b/rust/lance-index/src/optimize.rs index 68092c28ea5..5b6269a1a99 100644 --- a/rust/lance-index/src/optimize.rs +++ b/rust/lance-index/src/optimize.rs @@ -1,6 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::collections::HashMap; +use std::sync::Arc; + /// Options for optimizing all indices. #[non_exhaustive] #[derive(Debug, Clone, Default)] @@ -33,6 +36,13 @@ pub struct OptimizeOptions { /// /// NOTE: this option is only supported for v3 vector indices. pub retrain: bool, + + /// Transaction properties to store with this commit. + /// + /// These key-value pairs are stored in the transaction file + /// and can be read later to identify the source of the commit + /// (e.g., job_id for tracking completed index jobs). + pub transaction_properties: Option>>, } impl OptimizeOptions { @@ -61,6 +71,7 @@ impl OptimizeOptions { num_indices_to_merge: None, index_names: None, retrain: true, + ..Default::default() } } @@ -73,4 +84,10 @@ impl OptimizeOptions { self.index_names = Some(names); self } + + /// Set transaction properties to store in the commit manifest. + pub fn transaction_properties(mut self, properties: HashMap) -> Self { + self.transaction_properties = Some(Arc::new(properties)); + self + } } diff --git a/rust/lance/src/dataset/optimize.rs b/rust/lance/src/dataset/optimize.rs index e051dfa0e52..7cec534e171 100644 --- a/rust/lance/src/dataset/optimize.rs +++ b/rust/lance/src/dataset/optimize.rs @@ -89,7 +89,9 @@ use std::sync::Arc; use super::fragment::FileFragment; use super::index::DatasetIndexRemapperOptions; use super::rowids::load_row_id_sequences; -use super::transaction::{Operation, RewriteGroup, RewrittenIndex, Transaction}; +use super::transaction::{ + Operation, RewriteGroup, RewrittenIndex, Transaction, TransactionBuilder, +}; use super::utils::make_rowid_capture_stream; use super::{WriteMode, WriteParams, write_fragments_internal}; use crate::Dataset; @@ -207,6 +209,13 @@ pub struct CompactionOptions { /// fragments at a time). /// Defaults to `None` (no limit, all eligible fragments are compacted). pub max_source_fragments: Option, + /// Transaction properties to store with this commit. + /// + /// These key-value pairs are stored in the transaction file + /// and can be read later to identify the source of the commit + /// (e.g., job_id for tracking completed compaction jobs). + #[serde(skip)] + pub transaction_properties: Option>>, } #[allow(deprecated)] @@ -227,6 +236,7 @@ impl Default for CompactionOptions { enable_binary_copy_force: false, binary_copy_read_batch_bytes: Some(16 * 1024 * 1024), max_source_fragments: None, + transaction_properties: None, } } } @@ -378,6 +388,12 @@ impl CompactionOptions { _ => CompactionMode::Reencode, } } + + /// Set transaction properties to store in the commit manifest. + pub fn transaction_properties(mut self, properties: HashMap) -> Self { + self.transaction_properties = Some(Arc::new(properties)); + self + } } /// Determine if page-level binary copy can safely merge the provided fragments. @@ -1569,15 +1585,16 @@ pub async fn commit_compaction( None }; - let transaction = Transaction::new( + let transaction = TransactionBuilder::new( dataset.manifest.version, Operation::Rewrite { groups: rewrite_groups, rewritten_indices, frag_reuse_index, }, - None, - ); + ) + .transaction_properties(options.transaction_properties.clone()) + .build(); dataset .apply_commit(transaction, &Default::default(), &Default::default()) diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index fdc91760a2e..6baffc25382 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -78,7 +78,7 @@ use self::vector::remap_vector_index; use crate::dataset::index::LanceIndexStoreExt; use crate::dataset::optimize::RemappedIndex; use crate::dataset::optimize::remapping::RemapResult; -use crate::dataset::transaction::{Operation, Transaction}; +use crate::dataset::transaction::{Operation, Transaction, TransactionBuilder}; use crate::index::frag_reuse::{load_frag_reuse_index_details, open_frag_reuse_index}; use crate::index::mem_wal::open_mem_wal_index; pub use crate::index::prefilter::{FilterLoader, PreFilter}; @@ -950,14 +950,15 @@ impl DatasetIndexExt for Dataset { return Ok(()); } - let transaction = Transaction::new( + let transaction = TransactionBuilder::new( self.manifest.version, Operation::CreateIndex { new_indices, removed_indices, }, - None, - ); + ) + .transaction_properties(options.transaction_properties.clone()) + .build(); self.apply_commit(transaction, &Default::default(), &Default::default()) .await?; diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index 2678cb22471..5ca3375e243 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -5,7 +5,7 @@ use crate::{ Error, Result, dataset::{ Dataset, - transaction::{Operation, Transaction}, + transaction::{Operation, TransactionBuilder}, }, index::{ DatasetIndexExt, DatasetIndexInternalExt, @@ -26,7 +26,7 @@ use lance_index::{ scalar::{LANCE_SCALAR_INDEX, ScalarIndexParams, inverted::tokenizer::InvertedIndexParams}, }; use lance_table::format::{IndexMetadata, list_index_files_with_sizes}; -use std::{future::IntoFuture, sync::Arc}; +use std::{collections::HashMap, future::IntoFuture, sync::Arc}; use tracing::instrument; use uuid::Uuid; @@ -56,6 +56,8 @@ pub struct CreateIndexBuilder<'a> { index_uuid: Option, preprocessed_data: Option>, progress: Arc, + /// Transaction properties to store with this commit. + transaction_properties: Option>>, } impl<'a> CreateIndexBuilder<'a> { @@ -77,6 +79,7 @@ impl<'a> CreateIndexBuilder<'a> { index_uuid: None, preprocessed_data: None, progress: Arc::new(NoopIndexBuildProgress), + transaction_properties: None, } } @@ -118,6 +121,16 @@ impl<'a> CreateIndexBuilder<'a> { self } + /// Set transaction properties to store with this commit. + /// + /// These key-value pairs are stored in the transaction file + /// and can be read later to identify the source of the commit + /// (e.g., job_id for tracking completed index jobs). + pub fn transaction_properties(mut self, properties: HashMap) -> Self { + self.transaction_properties = Some(Arc::new(properties)); + self + } + #[instrument(skip_all)] pub async fn execute_uncommitted(&mut self) -> Result { if self.columns.len() != 1 { @@ -456,14 +469,15 @@ impl<'a> CreateIndexBuilder<'a> { } else { vec![] }; - let transaction = Transaction::new( + let transaction = TransactionBuilder::new( new_idx.dataset_version, Operation::CreateIndex { new_indices: vec![new_idx], removed_indices, }, - None, - ); + ) + .transaction_properties(self.transaction_properties.clone()) + .build(); self.dataset .apply_commit(transaction, &Default::default(), &Default::default()) From 13a929c04a3377d664432e0fd22d8c74904ea7e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 00:48:18 +0800 Subject: [PATCH 058/206] chore(deps): bump tar from 0.4.44 to 0.4.45 (#6244) Bumps [tar](https://github.com/alexcrichton/tar-rs) from 0.4.44 to 0.4.45.

Commits
  • 096e3d1 Bump to 0.4.45 (#443)
  • 17b1fd8 archive: Prevent symlink-directory collision chmod attack (#442)
  • de1a587 archive: Unconditionally honor PAX size (#441)
  • 6071cbe ci: Consolidate workflows (#439)
  • ad1fde9 build-sys: Promote unused_code to an error
  • c8cb250 tests: Squash a warning
  • 638c495 ci: Add xtask infra + reverse dependency testing (#435)
  • 32a9bbb tests: Add RandomReader to exercise partial-read resilience (#436)
  • 9c5df0b Fix GNU long-name extension stream corruption on validation error (#434)
  • 88b1e3b Fix docs typo in header.rs (#431)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tar&package-manager=cargo&previous-version=0.4.44&new-version=0.4.45)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/lance-format/lance/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 906d615db25..d6cf6cea254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,7 +174,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -185,7 +185,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3002,7 +3002,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3241,7 +3241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4549,7 +4549,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4644,7 +4644,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6100,7 +6100,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7771,7 +7771,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8302,7 +8302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8752,9 +8752,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -8771,7 +8771,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9745,7 +9745,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] From 630dbfa8ea8bce6dda87c34c405d6ff0cf8a2f78 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Sun, 22 Mar 2026 05:30:17 +0800 Subject: [PATCH 059/206] build: remove legacy rustls-webpki 0.101.7 and bump 0.103.10 (#6249) --- Cargo.lock | 370 ++++---------------------- Cargo.toml | 4 +- python/Cargo.lock | 182 +++---------- rust/lance-namespace-impls/Cargo.toml | 2 +- rust/lance-table/Cargo.toml | 2 +- rust/lance/Cargo.toml | 4 +- 6 files changed, 85 insertions(+), 479 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d6cf6cea254..46c328d8d3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,9 +205,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -852,20 +852,15 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "bytes", - "crypto-bigint 0.5.5", "form_urlencoded", "hex", "hmac", "http 0.2.12", "http 1.4.0", - "p256", "percent-encoding", - "ring", "sha2", - "subtle", "time", "tracing", - "zeroize", ] [[package]] @@ -942,23 +937,17 @@ dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.13", - "http 0.2.12", + "h2", "http 1.4.0", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper 1.8.1", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-util", "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.37", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower", "tracing", ] @@ -1095,7 +1084,7 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "itoa", "matchit", @@ -1270,12 +1259,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base16ct" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" - [[package]] name = "base64" version = "0.13.1" @@ -1971,28 +1954,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -2852,16 +2813,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "der" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" -dependencies = [ - "const-oid", - "zeroize", -] - [[package]] name = "der" version = "0.7.10" @@ -3059,44 +3010,12 @@ dependencies = [ "num-traits", ] -[[package]] -name = "ecdsa" -version = "0.14.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" -dependencies = [ - "der 0.6.1", - "elliptic-curve", - "rfc6979", - "signature 1.6.4", -] - [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "elliptic-curve" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" -dependencies = [ - "base16ct", - "crypto-bigint 0.4.9", - "der 0.6.1", - "digest", - "ff", - "generic-array", - "group", - "pkcs8 0.9.0", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "encode_unicode" version = "1.0.0" @@ -3313,16 +3232,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "ff" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "filetime" version = "0.2.27" @@ -3831,7 +3740,7 @@ dependencies = [ "derive_builder 0.20.2", "http 1.4.0", "reqwest", - "rustls 0.23.37", + "rustls", "rustls-pemfile", "serde", "serde_json", @@ -3840,36 +3749,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "group" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.13" @@ -4107,30 +3986,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.8.1" @@ -4141,7 +3996,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -4154,21 +4009,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.7" @@ -4176,13 +4016,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper 1.8.1", + "hyper", "hyper-util", - "rustls 0.23.37", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", "webpki-roots 1.0.6", ] @@ -4195,7 +4035,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "native-tls", "tokio", @@ -4215,12 +4055,12 @@ dependencies = [ "futures-util", "http 1.4.0", "http-body 1.0.1", - "hyper 1.8.1", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "system-configuration", "tokio", "tower-service", @@ -4605,9 +4445,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jieba-macros" @@ -6275,7 +6115,7 @@ dependencies = [ "http-body-util", "httparse", "humantime", - "hyper 1.8.1", + "hyper", "itertools 0.14.0", "md-5", "parking_lot", @@ -6482,17 +6322,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "p256" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" -dependencies = [ - "ecdsa", - "elliptic-curve", - "sha2", -] - [[package]] name = "parking" version = "2.2.1" @@ -6773,9 +6602,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", + "der", + "pkcs8", + "spki", ] [[package]] @@ -6786,21 +6615,11 @@ checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" dependencies = [ "aes", "cbc", - "der 0.7.10", + "der", "pbkdf2", "scrypt", "sha2", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" -dependencies = [ - "der 0.6.1", - "spki 0.6.0", + "spki", ] [[package]] @@ -6809,10 +6628,10 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", + "der", "pkcs5", "rand_core 0.6.4", - "spki 0.7.3", + "spki", ] [[package]] @@ -7153,8 +6972,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.37", - "socket2 0.6.3", + "rustls", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -7173,7 +6992,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -7191,7 +7010,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -7551,12 +7370,12 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", @@ -7567,7 +7386,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", + "rustls", "rustls-native-certs", "rustls-pki-types", "serde", @@ -7576,7 +7395,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http 0.6.8", @@ -7589,17 +7408,6 @@ dependencies = [ "webpki-roots 1.0.6", ] -[[package]] -name = "rfc6979" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" -dependencies = [ - "crypto-bigint 0.4.9", - "hmac", - "zeroize", -] - [[package]] name = "rgb" version = "0.8.53" @@ -7657,11 +7465,11 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "sha2", - "signature 2.2.0", - "spki 0.7.3", + "signature", + "spki", "subtle", "zeroize", ] @@ -7774,18 +7582,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.37" @@ -7797,7 +7593,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki", "subtle", "zeroize", ] @@ -7835,19 +7631,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.101.7" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -7953,30 +7739,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "sec1" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" -dependencies = [ - "base16ct", - "der 0.6.1", - "generic-array", - "pkcs8 0.9.0", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -8187,16 +7949,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "signature" version = "2.2.0" @@ -8285,16 +8037,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -8343,16 +8085,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" -dependencies = [ - "base64ct", - "der 0.6.1", -] - [[package]] name = "spki" version = "0.7.3" @@ -8360,7 +8092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", + "der", ] [[package]] @@ -8994,7 +8726,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -9020,23 +8752,13 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.37", + "rustls", "tokio", ] @@ -9427,7 +9149,7 @@ dependencies = [ "log", "native-tls", "once_cell", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -10076,7 +9798,7 @@ dependencies = [ "futures", "http 1.4.0", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "log", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 6b5f1c88fa3..7f88edf3eda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,8 +89,8 @@ async-trait = "0.1" axum = "0.7" aws-config = "1.2.0" aws-credential-types = "1.2.0" -aws-sdk-dynamodb = "1.38.0" -aws-sdk-s3 = "1.38.0" +aws-sdk-dynamodb = { version = "1.38.0", default-features = false } +aws-sdk-s3 = { version = "1.38.0", default-features = false } half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", diff --git a/python/Cargo.lock b/python/Cargo.lock index 32f0565a1c4..ba200e2bdf7 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -202,9 +202,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -815,23 +815,17 @@ dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.13", - "http 0.2.12", + "h2", "http 1.4.0", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper 1.8.1", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-util", "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.37", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower", "tracing", ] @@ -968,7 +962,7 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "itoa", "matchit", @@ -3147,25 +3141,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.13" @@ -3359,30 +3334,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.8.1" @@ -3393,7 +3344,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -3406,21 +3357,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.7" @@ -3428,13 +3364,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper 1.8.1", + "hyper", "hyper-util", - "rustls 0.23.37", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", "webpki-roots", ] @@ -3451,12 +3387,12 @@ dependencies = [ "futures-util", "http 1.4.0", "http-body 1.0.1", - "hyper 1.8.1", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "tokio", "tower-service", "tracing", @@ -3773,9 +3709,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jieba-macros" @@ -5117,7 +5053,7 @@ dependencies = [ "http-body-util", "httparse", "humantime", - "hyper 1.8.1", + "hyper", "itertools 0.14.0", "md-5", "parking_lot", @@ -5876,8 +5812,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.37", - "socket2 0.6.3", + "rustls", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -5896,7 +5832,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -5914,7 +5850,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -6225,12 +6161,12 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", @@ -6239,7 +6175,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", + "rustls", "rustls-native-certs", "rustls-pki-types", "serde", @@ -6247,7 +6183,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http 0.6.8", @@ -6389,18 +6325,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.37" @@ -6411,7 +6335,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki", "subtle", "zeroize", ] @@ -6449,19 +6373,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.101.7" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -6555,16 +6469,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -6852,16 +6756,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -7235,9 +7129,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -7409,7 +7303,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -7425,23 +7319,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.37", + "rustls", "tokio", ] diff --git a/rust/lance-namespace-impls/Cargo.toml b/rust/lance-namespace-impls/Cargo.toml index 8c84e1bbe8b..258b09b90de 100644 --- a/rust/lance-namespace-impls/Cargo.toml +++ b/rust/lance-namespace-impls/Cargo.toml @@ -68,7 +68,7 @@ rand.workspace = true chrono.workspace = true # AWS credential vending dependencies (optional, enabled by "credential-vendor-aws" feature) -aws-sdk-sts = { version = "1.38.0", optional = true } +aws-sdk-sts = { version = "1.38.0", optional = true, default-features = false, features = ["default-https-client", "rt-tokio"] } aws-config = { workspace = true, optional = true } aws-credential-types = { workspace = true, optional = true } sha2 = { version = "0.10", optional = true } diff --git a/rust/lance-table/Cargo.toml b/rust/lance-table/Cargo.toml index 961e11d45c3..8f44e75f364 100644 --- a/rust/lance-table/Cargo.toml +++ b/rust/lance-table/Cargo.toml @@ -23,7 +23,7 @@ arrow-ipc.workspace = true arrow-schema.workspace = true async-trait.workspace = true aws-credential-types = { workspace = true, optional = true } -aws-sdk-dynamodb = { workspace = true, optional = true } +aws-sdk-dynamodb = { workspace = true, optional = true, default-features = false, features = ["default-https-client", "rt-tokio"] } byteorder.workspace = true bytes.workspace = true chrono.workspace = true diff --git a/rust/lance/Cargo.toml b/rust/lance/Cargo.toml index a979764c69e..8b9e88d12c7 100644 --- a/rust/lance/Cargo.toml +++ b/rust/lance/Cargo.toml @@ -76,7 +76,7 @@ serde = { workspace = true } moka.workspace = true permutation = { version = "0.4.0" } tantivy.workspace = true -aws-sdk-dynamodb = { workspace = true, optional = true } +aws-sdk-dynamodb = { workspace = true, optional = true, default-features = false, features = ["default-https-client", "rt-tokio"] } tracing.workspace = true humantime = { workspace = true } async_cell = "0.2.2" @@ -113,7 +113,7 @@ tracking-allocator = { version = "0.4", features = ["tracing-compat"] } paste = "1.0" # For S3 / DynamoDB tests aws-config = { workspace = true } -aws-sdk-s3 = { workspace = true } +aws-sdk-s3 = { workspace = true, default-features = false, features = ["default-https-client", "http-1x", "rt-tokio"] } geoarrow-array = { workspace = true } geoarrow-schema = { workspace = true } geo-types = { workspace = true } From b80fbb3231cf58dd50e5670f9c56d309999bbd73 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Sun, 22 Mar 2026 06:28:11 +0800 Subject: [PATCH 060/206] refactor: distributed vector segment build (#6220) This refactors distributed vector indexing into a staged segment-build pipeline and exposes the public APIs needed to integrate an external distributed build workflow with Lance. It defines the storage-level model for partial shards, staged planning, built segments, and segment commit, and documents the current distributed indexing flow. This PR builds on the segment commit API from https://github.com/lance-format/lance/pull/6209. The main changes are organized into five commits: - [test: cover distributed vector segment build](https://github.com/lance-format/lance/pull/6220/commits/a86274da2) - [refactor: internalize distributed vector segment build](https://github.com/lance-format/lance/pull/6220/commits/1e5f0e15b) - [feat: add public vector segment builder API](https://github.com/lance-format/lance/pull/6220/commits/691cecb9a) - [feat: add Python vector segment builder API](https://github.com/lance-format/lance/pull/6220/commits/a07ef6144) - [docs: document distributed vector segment build](https://github.com/lance-format/lance/pull/6220/commits/0a3f230d7) Follow-up fixes: - [fix: expose python create_index_uncommitted](https://github.com/lance-format/lance/pull/6220/commits/c1d3b1666) - [fix: format python bindings](https://github.com/lance-format/lance/pull/6220/commits/b86f91c4d) - [fix: format python segment builder bindings](https://github.com/lance-format/lance/pull/6220/commits/bfc9e63a0) Please review accordingly. --- Guide: https://github.com/lance-format/lance/blob/xuanwo/vector-staging-merge-internal/docs/src/guide/distributed_indexing.md --- docs/src/guide/.pages | 3 +- docs/src/guide/distributed_indexing.md | 155 +++++ .../distributed_vector_segment_build.svg | 121 ++++ python/python/lance/__init__.py | 2 + python/python/lance/dataset.py | 559 ++++++++++------ python/python/lance/indices/__init__.py | 20 +- python/python/lance/lance/__init__.pyi | 17 + .../python/lance/lance/indices/__init__.pyi | 17 + python/python/tests/test_vector_index.py | 47 ++ python/src/dataset.rs | 115 +++- python/src/indices.rs | 93 ++- python/src/lib.rs | 5 +- rust/lance-index/src/lib.rs | 2 +- rust/lance-index/src/traits.rs | 23 + rust/lance-index/src/types.rs | 57 ++ .../src/vector/distributed/index_merger.rs | 105 ++- rust/lance-index/src/vector/sq/storage.rs | 2 +- rust/lance/src/dataset.rs | 59 +- .../src/dataset/mem_wal/memtable/flush.rs | 13 +- rust/lance/src/index.rs | 8 + rust/lance/src/index/create.rs | 419 +++++++++++- rust/lance/src/index/vector.rs | 182 +++--- rust/lance/src/index/vector/ivf.rs | 600 +++++++++++++----- rust/lance/src/index/vector/ivf/v2.rs | 581 ++++++++++++++++- 24 files changed, 2642 insertions(+), 563 deletions(-) create mode 100644 docs/src/guide/distributed_indexing.md create mode 100644 docs/src/images/distributed_vector_segment_build.svg diff --git a/docs/src/guide/.pages b/docs/src/guide/.pages index 0c9a93c6920..46ddd475799 100644 --- a/docs/src/guide/.pages +++ b/docs/src/guide/.pages @@ -7,7 +7,8 @@ nav: - Tags and Branches: tags_and_branches.md - Object Store Configuration: object_store.md - Distributed Write: distributed_write.md + - Distributed Indexing: distributed_indexing.md - Migration Guide: migration.md - Performance Guide: performance.md - Tokenizer: tokenizer.md - - Extension Arrays: arrays.md \ No newline at end of file + - Extension Arrays: arrays.md diff --git a/docs/src/guide/distributed_indexing.md b/docs/src/guide/distributed_indexing.md new file mode 100644 index 00000000000..e6aa241feee --- /dev/null +++ b/docs/src/guide/distributed_indexing.md @@ -0,0 +1,155 @@ +# Distributed Indexing + +!!! warning + Lance exposes public APIs that can be integrated into an external + distributed index build workflow, but Lance itself does not provide a full + distributed scheduler or end-to-end orchestration layer. + + This page describes the current model, terminology, and execution flow so + that callers can integrate these APIs correctly. + +## Overview + +Distributed index build in Lance follows the same high-level pattern as distributed +write: + +1. multiple workers build index data in parallel +2. the caller invokes Lance segment build APIs for one distributed build +3. Lance discovers the relevant worker outputs, then plans and builds index artifacts +4. the built artifacts are committed into the dataset manifest + +For vector indices, the worker outputs are temporary shard directories under a +shared UUID. Internally, Lance can turn these shard outputs into one or more +built physical segments. + +![Distributed Vector Segment Build](../images/distributed_vector_segment_build.svg) + +## Terminology + +This guide uses the following terms consistently: + +- **Staging root**: the shared UUID directory used during distributed index build +- **Partial shard**: one worker output written under the staging root as + `partial_/` +- **Built segment**: one physical index segment produced during segment build and + ready to be committed into the manifest +- **Logical index**: the user-visible index identified by name; a logical index + may contain one or more built segments + +For example, a distributed vector build may create a layout like: + +```text +indices// +├── partial_/ +│ ├── index.idx +│ └── auxiliary.idx +├── partial_/ +│ ├── index.idx +│ └── auxiliary.idx +└── partial_/ + ├── index.idx + └── auxiliary.idx +``` + +After segment build, Lance produces one or more segment directories: + +```text +indices// +├── index.idx +└── auxiliary.idx + +indices// +├── index.idx +└── auxiliary.idx +``` + +These physical segments are then committed together as one logical index. + +## Roles + +There are two parties involved in distributed indexing: + +- **Workers** build partial shards +- **The caller** launches workers, chooses when a distributed build should be + turned into built segments, provides any additional inputs requested by the + segment build APIs, and + commits the final result + +Lance does not provide a distributed scheduler. The caller is responsible for +launching workers and driving the overall workflow. + +## Current Model + +The current model for distributed vector indexing has two layers of parallelism. + +### Shard Build + +First, multiple workers build partial shards in parallel: + +1. on each worker, call an uncommitted shard-build API such as + `create_index_builder(...).fragments(...).index_uuid(staging_index_uuid).execute_uncommitted()` + or Python `create_index_uncommitted(..., fragment_ids=..., index_uuid=...)` +2. each worker writes one `partial_/` under the shared staging root + +### Segment Build + +Then the caller turns that staging root into one or more built segments: + +1. open the staging root with `create_index_segment_builder(staging_index_uuid)` +2. provide partial index metadata with `with_partial_indices(...)` +3. optionally choose a grouping policy with `with_target_segment_bytes(...)` +4. call `plan()` to get `Vec` + +At that point the caller has two execution choices: + +- call `build(plan)` for each plan and run those builds in parallel +- call `build_all()` to let Lance build every planned segment on the current node + +After the segments are built, publish them with +`commit_existing_index_segments(...)`. + +## Internal Segmented Finalize Model + +Internally, Lance models distributed vector segment build as: + +1. **plan** which partial shards should become each built segment +2. **build** each segment from its selected partial shards +3. **commit** the resulting physical segments as one logical index + +The plan step is driven by the staging root and any additional shard metadata +required by the segment build APIs. + +This is intentionally a storage-level model: + +- partial shards are temporary worker outputs +- built segments are durable physical artifacts +- the logical index identity is attached only at commit time + +## Segment Grouping + +When Lance builds segments from a staging root, it may either: + +- keep shard boundaries, so each partial shard becomes one built segment +- group multiple partial shards into a larger built segment + +The grouping decision is separate from shard build. Workers only build partial +shards; Lance applies the segment build policy when it plans built segments. + +## Responsibility Boundaries + +The caller is expected to know: + +- which distributed build is ready for segment build +- any additional shard metadata requested by the segment build APIs +- how the resulting built segments should be published + +Lance is responsible for: + +- writing partial shard artifacts +- discovering partial shards under the staging root +- planning built segments from the discovered shard set +- merging shard storage into built segment artifacts +- committing built segments into the manifest + +This split keeps distributed scheduling outside the storage engine while still +letting Lance own the on-disk index format. diff --git a/docs/src/images/distributed_vector_segment_build.svg b/docs/src/images/distributed_vector_segment_build.svg new file mode 100644 index 00000000000..d36f2726010 --- /dev/null +++ b/docs/src/images/distributed_vector_segment_build.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + Caller + + + + +launch workers + + + + + Parallel index build (per worker) + + + + + + Worker 1 + execute_uncommitted() + + + + + Worker 2 + execute_uncommitted() + + + + + Worker N + execute_uncommitted() + + + + + + partial_<u1>/ + + + + + + partial_<u2>/ + + + + + + partial_<uN>/ + + + + +indices/<staging_uuid>/partial_<u*>/ + + + + + + + + Segment planner + create_index_segment_builder → plan() + + + + +Vec<IndexSegmentPlan> + + + + + Parallel segment build + + + + + + build(plan[0]) + → IndexSegment 0 + + + + + build(plan[1]) + → IndexSegment 1 + + + + + build(plan[N]) + → IndexSegment N + + + + + + + + + commit_existing_index_segments(...) + + + + + + + + + Logical index + + diff --git a/python/python/lance/__init__.py b/python/python/lance/__init__.py index 0b905457a50..95bacfc3091 100644 --- a/python/python/lance/__init__.py +++ b/python/python/lance/__init__.py @@ -29,6 +29,7 @@ from .lance import ( DatasetBasePath, FFILanceTableProvider, + IndexSegmentBuilder, ScanStatistics, bytes_read_counter, iops_counter, @@ -64,6 +65,7 @@ "FragmentMetadata", "Index", "IndexFile", + "IndexSegmentBuilder", "LanceDataset", "LanceFragment", "LanceOperation", diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index c73a7b5a724..e8d632b0ecb 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -47,7 +47,7 @@ from .dependencies import numpy as np from .dependencies import pandas as pd from .fragment import DataFile, FragmentMetadata, LanceFragment -from .indices import IndexConfig, SupportedDistributedIndices +from .indices import IndexConfig, IndexSegment, SupportedDistributedIndices from .lance import ( CleanupStats, Compaction, @@ -2785,7 +2785,7 @@ def create_scalar_index( self._ds.create_index([column], index_type, name, replace, train, None, kwargs) - def create_index( + def _create_index_impl( self, column: Union[str, List[str]], index_type: str, @@ -2804,198 +2804,25 @@ def create_index( index_cache_size: Optional[int] = None, shuffle_partition_batches: Optional[int] = None, shuffle_partition_concurrency: Optional[int] = None, - # experimental parameters ivf_centroids_file: Optional[str] = None, precomputed_partition_dataset: Optional[str] = None, storage_options: Optional[Dict[str, str]] = None, filter_nan: bool = True, train: bool = True, - # distributed indexing parameters fragment_ids: Optional[List[int]] = None, index_uuid: Optional[str] = None, *, target_partition_size: Optional[int] = None, skip_transpose: bool = False, + require_commit: bool = True, **kwargs, - ) -> LanceDataset: - """Create index on column. - - **Experimental API** - - Parameters - ---------- - column : str - The column to be indexed. - index_type : str - The type of the index. - ``"IVF_PQ, IVF_HNSW_PQ and IVF_HNSW_SQ"`` are supported now. - name : str, optional - The index name. If not provided, it will be generated from the - column name. - metric : str - The distance metric type, i.e., "L2" (alias to "euclidean"), "cosine" - or "dot" (dot product). Default is "L2". - replace : bool - Replace the existing index if it exists. - num_partitions : int, optional - The number of partitions of IVF (Inverted File Index). - Deprecated. Use target_partition_size instead. - ivf_centroids : optional - It can be either :py:class:`np.ndarray`, - :py:class:`pyarrow.FixedSizeListArray` or - :py:class:`pyarrow.FixedShapeTensorArray`. - A ``num_partitions x dimension`` array of existing K-mean centroids - for IVF clustering. If not provided, a new KMeans model will be trained. - pq_codebook : optional, - It can be :py:class:`np.ndarray`, :py:class:`pyarrow.FixedSizeListArray`, - or :py:class:`pyarrow.FixedShapeTensorArray`. - A ``num_sub_vectors x (2 ^ nbits * dimensions // num_sub_vectors)`` - array of K-mean centroids for PQ codebook. - - Note: ``nbits`` is always 8 for now. - If not provided, a new PQ model will be trained. - num_sub_vectors : int, optional - The number of sub-vectors for PQ (Product Quantization). - accelerator : str or ``torch.Device``, optional - If set, use an accelerator to speed up the training process. - Accepted accelerator: "cuda" (Nvidia GPU) and "mps" (Apple Silicon GPU). - If not set, use the CPU. - index_cache_size : int, optional - The size of the index cache in number of entries. Default value is 256. - shuffle_partition_batches : int, optional - The number of batches, using the row group size of the dataset, to include - in each shuffle partition. Default value is 10240. - - Assuming the row group size is 1024, each shuffle partition will hold - 10240 * 1024 = 10,485,760 rows. By making this value smaller, this shuffle - will consume less memory but will take longer to complete, and vice versa. - shuffle_partition_concurrency : int, optional - The number of shuffle partitions to process concurrently. Default value is 2 - - By making this value smaller, this shuffle will consume less memory but will - take longer to complete, and vice versa. - storage_options : optional, dict - Extra options that make sense for a particular storage connection. This is - used to store connection parameters like credentials, endpoint, etc. - filter_nan: bool - Defaults to True. False is UNSAFE, and will cause a crash if any null/nan - values are present (and otherwise will not). Disables the null filter used - for nullable columns. Obtains a small speed boost. - train : bool, default True - If True, the index will be trained on the data (e.g., compute IVF - centroids, PQ codebooks). If False, an empty index structure will be - created without training, which can be populated later. - fragment_ids : List[int], optional - If provided, the index will be created only on the specified fragments. - This enables distributed/fragment-level indexing. When provided, the - method creates temporary index metadata but does not commit the index - to the dataset. The index can be committed later using - merge_index_metadata(index_uuid, "VECTOR", column=..., index_name=...). - index_uuid : str, optional - A UUID to use for fragment-level distributed indexing. Multiple - fragment-level indices need to share UUID for later merging. - If not provided, a new UUID will be generated. - target_partition_size: int, optional - The target partition size. If set, the number of partitions will be computed - based on the target partition size. - Otherwise, the target partition size will be set by index type. - kwargs : - Parameters passed to the index building process. - - - - The SQ (Scalar Quantization) is available for only ``IVF_HNSW_SQ`` index type, - this quantization method is used to reduce the memory usage of the index, - it maps the float vectors to integer vectors, each integer is of ``num_bits``, - now only 8 bits are supported. - - If ``index_type`` is "IVF_*", then the following parameters are required: - num_partitions - - If ``index_type`` is with "PQ", then the following parameters are required: - num_sub_vectors - - Optional parameters for `IVF_PQ`: - - - ivf_centroids - Existing K-mean centroids for IVF clustering. - - num_bits - The number of bits for PQ (Product Quantization). Default is 8. - Only 4, 8 are supported. - - index_file_version - The version of the index file. Default is "V3". - - Optional parameters for `IVF_RQ`: - - - num_bits - The number of bits for RQ (Rabit Quantization). Default is 1. - - Optional parameters for `IVF_HNSW_*`: - max_level - Int, the maximum number of levels in the graph. - m - Int, the number of edges per node in the graph. - ef_construction - Int, the number of nodes to examine during the construction. - - Examples - -------- - - .. code-block:: python - - import lance - - dataset = lance.dataset("/tmp/sift.lance") - dataset.create_index( - "vector", - "IVF_PQ", - num_partitions=256, - num_sub_vectors=16 - ) - - .. code-block:: python - - import lance - - dataset = lance.dataset("/tmp/sift.lance") - dataset.create_index( - "vector", - "IVF_HNSW_SQ", - num_partitions=256, - ) - - Experimental Accelerator (GPU) support: - - - *accelerate*: use GPU to train IVF partitions. - Only supports CUDA (Nvidia) or MPS (Apple) currently. - Requires PyTorch being installed. - - .. code-block:: python - - import lance - - dataset = lance.dataset("/tmp/sift.lance") - dataset.create_index( - "vector", - "IVF_PQ", - num_partitions=256, - num_sub_vectors=16, - accelerator="cuda" + ) -> Index: + if not require_commit and fragment_ids is None: + raise ValueError( + "create_index_uncommitted requires fragment_ids " + "for distributed index build" ) - Note: GPU acceleration is currently supported only for the ``IVF_PQ`` index - type. Providing an accelerator for other index types will fall back to CPU - index building. - - References - ---------- - * `Faiss Index `_ - * IVF introduced in `Video Google: a text retrieval approach to object matching - in videos `_ - * `Product quantization for nearest neighbor search - `_ - - """ # Only support building index for 1 column from the API aspect, however # the internal implementation might support building multi-column index later. if isinstance(column, str): @@ -3103,13 +2930,21 @@ def create_index( pass if torch_detected: - if fragment_ids is not None or index_uuid is not None: - LOGGER.info( - "Torch detected; " - "enforce single-node indexing (distributed is CPU-only)." - ) - fragment_ids = None - index_uuid = None + if require_commit: + if fragment_ids is not None or index_uuid is not None: + LOGGER.info( + "Torch detected; " + "enforce single-node indexing (distributed is CPU-only)." + ) + fragment_ids = None + index_uuid = None + else: + if index_uuid is not None: + LOGGER.info( + "Torch detected; " + "enforce single-node indexing (distributed is CPU-only)." + ) + index_uuid = None if accelerator is not None: from .vector import ( @@ -3302,7 +3137,7 @@ def create_index( kwargs["index_uuid"] = index_uuid timers["final_create_index:start"] = time.time() - self._ds.create_index( + index = self._ds.create_index( column, index_type, name, replace, train, storage_options, kwargs ) timers["final_create_index:end"] = time.time() @@ -3318,8 +3153,334 @@ def create_index( "Temporary shuffle buffers stored at %s, you may want to delete it.", kwargs["precomputed_shuffle_buffers_path"], ) + return index + + def create_index( + self, + column: Union[str, List[str]], + index_type: str, + name: Optional[str] = None, + metric: str = "L2", + replace: bool = False, + num_partitions: Optional[int] = None, + ivf_centroids: Optional[ + Union[np.ndarray, pa.FixedSizeListArray, pa.FixedShapeTensorArray] + ] = None, + pq_codebook: Optional[ + Union[np.ndarray, pa.FixedSizeListArray, pa.FixedShapeTensorArray] + ] = None, + num_sub_vectors: Optional[int] = None, + accelerator: Optional[Union[str, "torch.Device"]] = None, + index_cache_size: Optional[int] = None, + shuffle_partition_batches: Optional[int] = None, + shuffle_partition_concurrency: Optional[int] = None, + # experimental parameters + ivf_centroids_file: Optional[str] = None, + precomputed_partition_dataset: Optional[str] = None, + storage_options: Optional[Dict[str, str]] = None, + filter_nan: bool = True, + train: bool = True, + # distributed indexing parameters + fragment_ids: Optional[List[int]] = None, + index_uuid: Optional[str] = None, + *, + target_partition_size: Optional[int] = None, + skip_transpose: bool = False, + **kwargs, + ) -> LanceDataset: + """Create index on column. + + **Experimental API** + + Parameters + ---------- + column : str + The column to be indexed. + index_type : str + The type of the index. + ``"IVF_PQ, IVF_HNSW_PQ and IVF_HNSW_SQ"`` are supported now. + name : str, optional + The index name. If not provided, it will be generated from the + column name. + metric : str + The distance metric type, i.e., "L2" (alias to "euclidean"), "cosine" + or "dot" (dot product). Default is "L2". + replace : bool + Replace the existing index if it exists. + num_partitions : int, optional + The number of partitions of IVF (Inverted File Index). + Deprecated. Use target_partition_size instead. + ivf_centroids : optional + It can be either :py:class:`np.ndarray`, + :py:class:`pyarrow.FixedSizeListArray` or + :py:class:`pyarrow.FixedShapeTensorArray`. + A ``num_partitions x dimension`` array of existing K-mean centroids + for IVF clustering. If not provided, a new KMeans model will be trained. + pq_codebook : optional, + It can be :py:class:`np.ndarray`, :py:class:`pyarrow.FixedSizeListArray`, + or :py:class:`pyarrow.FixedShapeTensorArray`. + A ``num_sub_vectors x (2 ^ nbits * dimensions // num_sub_vectors)`` + array of K-mean centroids for PQ codebook. + + Note: ``nbits`` is always 8 for now. + If not provided, a new PQ model will be trained. + num_sub_vectors : int, optional + The number of sub-vectors for PQ (Product Quantization). + accelerator : str or ``torch.Device``, optional + If set, use an accelerator to speed up the training process. + Accepted accelerator: "cuda" (Nvidia GPU) and "mps" (Apple Silicon GPU). + If not set, use the CPU. + index_cache_size : int, optional + The size of the index cache in number of entries. Default value is 256. + shuffle_partition_batches : int, optional + The number of batches, using the row group size of the dataset, to include + in each shuffle partition. Default value is 10240. + + Assuming the row group size is 1024, each shuffle partition will hold + 10240 * 1024 = 10,485,760 rows. By making this value smaller, this shuffle + will consume less memory but will take longer to complete, and vice versa. + shuffle_partition_concurrency : int, optional + The number of shuffle partitions to process concurrently. Default value is 2 + + By making this value smaller, this shuffle will consume less memory but will + take longer to complete, and vice versa. + storage_options : optional, dict + Extra options that make sense for a particular storage connection. This is + used to store connection parameters like credentials, endpoint, etc. + filter_nan: bool + Defaults to True. False is UNSAFE, and will cause a crash if any null/nan + values are present (and otherwise will not). Disables the null filter used + for nullable columns. Obtains a small speed boost. + train : bool, default True + If True, the index will be trained on the data (e.g., compute IVF + centroids, PQ codebooks). If False, an empty index structure will be + created without training, which can be populated later. + fragment_ids : List[int], optional + If provided, the index will be created only on the specified fragments. + This enables distributed/fragment-level indexing. When provided, the + method creates temporary index metadata but does not commit the index + to the dataset. The index can be committed later using + merge_index_metadata(index_uuid, "VECTOR", column=..., index_name=...). + index_uuid : str, optional + A UUID to use for fragment-level distributed indexing. Multiple + fragment-level indices need to share UUID for later merging. + If not provided, a new UUID will be generated. + target_partition_size: int, optional + The target partition size. If set, the number of partitions will be computed + based on the target partition size. + Otherwise, the target partition size will be set by index type. + kwargs : + Parameters passed to the index building process. + + + + The SQ (Scalar Quantization) is available for only ``IVF_HNSW_SQ`` index type, + this quantization method is used to reduce the memory usage of the index, + it maps the float vectors to integer vectors, each integer is of ``num_bits``, + now only 8 bits are supported. + + If ``index_type`` is "IVF_*", then the following parameters are required: + num_partitions + + If ``index_type`` is with "PQ", then the following parameters are required: + num_sub_vectors + + Optional parameters for `IVF_PQ`: + + - ivf_centroids + Existing K-mean centroids for IVF clustering. + - num_bits + The number of bits for PQ (Product Quantization). Default is 8. + Only 4, 8 are supported. + - index_file_version + The version of the index file. Default is "V3". + + Optional parameters for `IVF_RQ`: + + - num_bits + The number of bits for RQ (Rabit Quantization). Default is 1. + + Optional parameters for `IVF_HNSW_*`: + max_level + Int, the maximum number of levels in the graph. + m + Int, the number of edges per node in the graph. + ef_construction + Int, the number of nodes to examine during the construction. + + Examples + -------- + + .. code-block:: python + + import lance + + dataset = lance.dataset("/tmp/sift.lance") + dataset.create_index( + "vector", + "IVF_PQ", + num_partitions=256, + num_sub_vectors=16 + ) + + .. code-block:: python + + import lance + + dataset = lance.dataset("/tmp/sift.lance") + dataset.create_index( + "vector", + "IVF_HNSW_SQ", + num_partitions=256, + ) + + Experimental Accelerator (GPU) support: + + - *accelerate*: use GPU to train IVF partitions. + Only supports CUDA (Nvidia) or MPS (Apple) currently. + Requires PyTorch being installed. + + .. code-block:: python + + import lance + + dataset = lance.dataset("/tmp/sift.lance") + dataset.create_index( + "vector", + "IVF_PQ", + num_partitions=256, + num_sub_vectors=16, + accelerator="cuda" + ) + + Note: GPU acceleration is currently supported only for the ``IVF_PQ`` index + type. Providing an accelerator for other index types will fall back to CPU + index building. + + References + ---------- + * `Faiss Index `_ + * IVF introduced in `Video Google: a text retrieval approach to object matching + in videos `_ + * `Product quantization for nearest neighbor search + `_ + + """ + self._create_index_impl( + column, + index_type, + name=name, + metric=metric, + replace=replace, + num_partitions=num_partitions, + ivf_centroids=ivf_centroids, + pq_codebook=pq_codebook, + num_sub_vectors=num_sub_vectors, + accelerator=accelerator, + index_cache_size=index_cache_size, + shuffle_partition_batches=shuffle_partition_batches, + shuffle_partition_concurrency=shuffle_partition_concurrency, + ivf_centroids_file=ivf_centroids_file, + precomputed_partition_dataset=precomputed_partition_dataset, + storage_options=storage_options, + filter_nan=filter_nan, + train=train, + fragment_ids=fragment_ids, + index_uuid=index_uuid, + target_partition_size=target_partition_size, + skip_transpose=skip_transpose, + require_commit=True, + **kwargs, + ) return self + def create_index_uncommitted( + self, + column: Union[str, List[str]], + index_type: str, + name: Optional[str] = None, + metric: str = "L2", + replace: bool = False, + num_partitions: Optional[int] = None, + ivf_centroids: Optional[ + Union[np.ndarray, pa.FixedSizeListArray, pa.FixedShapeTensorArray] + ] = None, + pq_codebook: Optional[ + Union[np.ndarray, pa.FixedSizeListArray, pa.FixedShapeTensorArray] + ] = None, + num_sub_vectors: Optional[int] = None, + accelerator: Optional[Union[str, "torch.Device"]] = None, + index_cache_size: Optional[int] = None, + shuffle_partition_batches: Optional[int] = None, + shuffle_partition_concurrency: Optional[int] = None, + ivf_centroids_file: Optional[str] = None, + precomputed_partition_dataset: Optional[str] = None, + storage_options: Optional[Dict[str, str]] = None, + filter_nan: bool = True, + train: bool = True, + fragment_ids: Optional[List[int]] = None, + index_uuid: Optional[str] = None, + *, + target_partition_size: Optional[int] = None, + skip_transpose: bool = False, + **kwargs, + ) -> Index: + """ + Create one uncommitted partial index and return its metadata. + + This is the public shard-build API for distributed index construction. + Unlike :meth:`create_index`, this method does not publish the index into + the dataset manifest. Instead, it writes one partial index under the + staging UUID and returns the resulting :class:`Index` metadata. + + Callers should: + + 1. run :meth:`create_index_uncommitted` on each worker with the worker's + assigned ``fragment_ids`` and a shared ``index_uuid`` + 2. collect the returned :class:`Index` objects + 3. pass them to :meth:`IndexSegmentBuilder.with_partial_indices` + 4. build one or more segments and commit them with + :meth:`commit_existing_index_segments` + + Parameters are the same as :meth:`create_index`, with two additional + requirements for distributed shard build: + + - ``fragment_ids`` must be provided + - workers that belong to the same distributed build must share the same + ``index_uuid`` + + Returns + ------- + Index + Metadata for the partial index that was written by this call. + """ + return self._create_index_impl( + column, + index_type, + name=name, + metric=metric, + replace=replace, + num_partitions=num_partitions, + ivf_centroids=ivf_centroids, + pq_codebook=pq_codebook, + num_sub_vectors=num_sub_vectors, + accelerator=accelerator, + index_cache_size=index_cache_size, + shuffle_partition_batches=shuffle_partition_batches, + shuffle_partition_concurrency=shuffle_partition_concurrency, + ivf_centroids_file=ivf_centroids_file, + precomputed_partition_dataset=precomputed_partition_dataset, + storage_options=storage_options, + filter_nan=filter_nan, + train=train, + fragment_ids=fragment_ids, + index_uuid=index_uuid, + target_partition_size=target_partition_size, + skip_transpose=skip_transpose, + require_commit=False, + **kwargs, + ) + def drop_index(self, name: str): """ Drops an index from the dataset @@ -3391,6 +3552,26 @@ def merge_index_metadata( self._ds.merge_index_metadata(index_uuid, t, batch_readhead) return None + def create_index_segment_builder(self, staging_index_uuid: str): + """ + Create a builder for turning partial index outputs into committed segments. + + The caller should pass the shared index UUID used during + :meth:`create_index` with ``fragment_ids=...`` and ``index_uuid=...``. + Then provide the returned partial index metadata through + :meth:`IndexSegmentBuilder.with_partial_indices`. + """ + return self._ds.create_index_segment_builder(staging_index_uuid) + + def commit_existing_index_segments( + self, index_name: str, column: str, segments: List[IndexSegment] + ) -> LanceDataset: + """ + Commit built index segments as one logical index. + """ + self._ds.commit_existing_index_segments(index_name, column, segments) + return self + def session(self) -> Session: """ Return the dataset session, which holds the dataset's state. diff --git a/python/python/lance/indices/__init__.py b/python/python/lance/indices/__init__.py index ac586876da0..085ff66e252 100644 --- a/python/python/lance/indices/__init__.py +++ b/python/python/lance/indices/__init__.py @@ -3,11 +3,23 @@ from enum import Enum -from lance.indices.builder import IndexConfig, IndicesBuilder -from lance.indices.ivf import IvfModel -from lance.indices.pq import PqModel +from .. import lance as _lance +from .builder import IndexConfig, IndicesBuilder +from .ivf import IvfModel +from .pq import PqModel -__all__ = ["IndicesBuilder", "IndexConfig", "PqModel", "IvfModel", "IndexFileVersion"] +IndexSegment = _lance.indices.IndexSegment +IndexSegmentPlan = _lance.indices.IndexSegmentPlan + +__all__ = [ + "IndicesBuilder", + "IndexConfig", + "PqModel", + "IvfModel", + "IndexFileVersion", + "IndexSegment", + "IndexSegmentPlan", +] class IndexFileVersion(str, Enum): diff --git a/python/python/lance/lance/__init__.pyi b/python/python/lance/lance/__init__.pyi index 41db5678b0b..e2f70a853a1 100644 --- a/python/python/lance/lance/__init__.pyi +++ b/python/python/lance/lance/__init__.pyi @@ -61,6 +61,8 @@ from .fragment import ( RowIdMeta as RowIdMeta, ) from .indices import IndexDescription as IndexDescription +from .indices import IndexSegment as IndexSegment +from .indices import IndexSegmentPlan as IndexSegmentPlan from .lance import PySearchFilter from .optimize import ( Compaction as Compaction, @@ -185,6 +187,15 @@ class LanceColumnStatistics: class _Session: def size_bytes(self) -> int: ... +class IndexSegmentBuilder: + @property + def staging_index_uuid(self) -> str: ... + def with_partial_indices(self, partial_indices: List[Index]) -> Self: ... + def with_target_segment_bytes(self, bytes: int) -> Self: ... + def plan(self) -> List[IndexSegmentPlan]: ... + def build(self, plan: IndexSegmentPlan) -> IndexSegment: ... + def build_all(self) -> List[IndexSegment]: ... + class LanceBlobFile: def close(self): ... def is_closed(self) -> bool: ... @@ -360,6 +371,12 @@ class _Dataset: def merge_index_metadata( self, index_uuid: str, index_type: str, batch_readhead: Optional[int] = None ): ... + def create_index_segment_builder( + self, staging_index_uuid: str + ) -> IndexSegmentBuilder: ... + def commit_existing_index_segments( + self, index_name: str, column: str, segments: List[IndexSegment] + ) -> None: ... def count_fragments(self) -> int: ... def num_small_files(self, max_rows_per_group: int) -> int: ... def get_fragments(self) -> List[_Fragment]: ... diff --git a/python/python/lance/lance/indices/__init__.pyi b/python/python/lance/lance/indices/__init__.pyi index 38e34218399..e1282b675a6 100644 --- a/python/python/lance/lance/indices/__init__.pyi +++ b/python/python/lance/lance/indices/__init__.pyi @@ -17,10 +17,27 @@ from typing import Optional import pyarrow as pa +from ...dataset import Index + class IndexConfig: index_type: str config: str +class IndexSegment: + uuid: str + fragment_ids: set[int] + index_version: int + + def __repr__(self) -> str: ... + +class IndexSegmentPlan: + staging_index_uuid: str + segment: IndexSegment + partial_indices: list[Index] + estimated_bytes: int + + def __repr__(self) -> str: ... + def train_ivf_model( dataset, column: str, diff --git a/python/python/tests/test_vector_index.py b/python/python/tests/test_vector_index.py index cf3f38158b8..973264c475c 100644 --- a/python/python/tests/test_vector_index.py +++ b/python/python/tests/test_vector_index.py @@ -2750,6 +2750,53 @@ def test_merge_two_shards_parameterized(tmp_path, index_type, num_sub_vectors): assert 0 < len(results) <= 5 +def test_index_segment_builder_builds_vector_segments(tmp_path): + ds = _make_sample_dataset_base(tmp_path, "segment_builder_ds", 2000, 128) + frags = ds.get_fragments() + assert len(frags) >= 2 + shared_uuid = str(uuid.uuid4()) + + builder = IndicesBuilder(ds, "vector") + preprocessed = builder.prepare_global_ivf_pq( + num_partitions=4, + num_subvectors=4, + distance_type="l2", + sample_rate=7, + max_iters=20, + ) + + partial_indices = [ + ds.create_index_uncommitted( + "vector", + "IVF_FLAT", + name="vector_idx", + train=True, + fragment_ids=[fragment.fragment_id], + index_uuid=shared_uuid, + num_partitions=4, + num_sub_vectors=128, + ivf_centroids=preprocessed["ivf_centroids"], + pq_codebook=preprocessed["pq_codebook"], + ) + for fragment in frags[:2] + ] + + segment_builder = ds.create_index_segment_builder(shared_uuid).with_partial_indices( + partial_indices + ) + plans = segment_builder.plan() + assert len(plans) == 2 + assert all(len(plan.partial_indices) == 1 for plan in plans) + + segments = segment_builder.build_all() + assert len(segments) == 2 + ds = ds.commit_existing_index_segments("vector_idx", "vector", segments) + + q = np.random.rand(128).astype(np.float32) + results = ds.to_table(nearest={"column": "vector", "q": q, "k": 5}) + assert 0 < len(results) <= 5 + + def test_distributed_ivf_pq_order_invariance(tmp_path: Path): """Ensure distributed IVF_PQ build is invariant to shard build order.""" ds = _make_sample_dataset_base(tmp_path, "dist_ds", 2000, 128) diff --git a/python/src/dataset.rs b/python/src/dataset.rs index d1dcfe5c515..3b76f3ce043 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -87,7 +87,7 @@ use lance_table::io::commit::external_manifest::ExternalManifestCommitHandler; use crate::error::PythonErrorExt; use crate::file::object_store_from_uri_or_path; use crate::fragment::FileFragment; -use crate::indices::{PyIndexConfig, PyIndexDescription}; +use crate::indices::{PyIndexConfig, PyIndexDescription, PyIndexSegment, PyIndexSegmentPlan}; use crate::namespace::extract_namespace_arc; use crate::rt; use crate::scanner::ScanStatistics; @@ -323,6 +323,87 @@ impl MergeInsertBuilder { } } +#[pyclass(name = "IndexSegmentBuilder", module = "lance", subclass)] +#[derive(Clone)] +pub struct PyIndexSegmentBuilder { + dataset: Arc, + staging_index_uuid: String, + partial_indices: Vec, + target_segment_bytes: Option, +} + +#[pymethods] +impl PyIndexSegmentBuilder { + #[getter] + fn staging_index_uuid(&self) -> String { + self.staging_index_uuid.clone() + } + + fn with_partial_indices<'a>( + mut slf: PyRefMut<'a, Self>, + partial_indices: &Bound<'_, PyAny>, + ) -> PyResult> { + let mut indices = Vec::new(); + for item in partial_indices.try_iter()? { + indices.push(item?.extract::>()?.0); + } + slf.partial_indices = indices; + Ok(slf) + } + + fn with_target_segment_bytes<'a>( + mut slf: PyRefMut<'a, Self>, + bytes: u64, + ) -> PyResult> { + slf.target_segment_bytes = Some(bytes); + Ok(slf) + } + + fn plan(&self, py: Python<'_>) -> PyResult>> { + let mut builder = self + .dataset + .create_index_segment_builder(self.staging_index_uuid.clone()) + .with_partial_indices(self.partial_indices.clone()); + if let Some(target_segment_bytes) = self.target_segment_bytes { + builder = builder.with_target_segment_bytes(target_segment_bytes); + } + let plans = rt().block_on(Some(py), builder.plan())?.infer_error()?; + plans + .into_iter() + .map(|plan| Py::new(py, PyIndexSegmentPlan::from_inner(plan))) + .collect() + } + + fn build(&self, py: Python<'_>, plan: &Bound<'_, PyAny>) -> PyResult> { + let plan = plan.extract::>()?; + let builder = self + .dataset + .create_index_segment_builder(self.staging_index_uuid.clone()) + .with_partial_indices(self.partial_indices.clone()); + let segment = rt() + .block_on(Some(py), builder.build(&plan.inner))? + .infer_error()?; + Py::new(py, PyIndexSegment::from_inner(segment)) + } + + fn build_all(&self, py: Python<'_>) -> PyResult>> { + let mut builder = self + .dataset + .create_index_segment_builder(self.staging_index_uuid.clone()) + .with_partial_indices(self.partial_indices.clone()); + if let Some(target_segment_bytes) = self.target_segment_bytes { + builder = builder.with_target_segment_bytes(target_segment_bytes); + } + let segments = rt() + .block_on(Some(py), builder.build_all())? + .infer_error()?; + segments + .into_iter() + .map(|segment| Py::new(py, PyIndexSegment::from_inner(segment))) + .collect() + } +} + impl MergeInsertBuilder { fn build_stats<'a>(stats: &MergeStats, py: Python<'a>) -> PyResult> { let dict = PyDict::new(py); @@ -2019,6 +2100,38 @@ impl Dataset { Ok(PyLance(index_metadata)) } + fn create_index_segment_builder( + &self, + staging_index_uuid: String, + ) -> PyResult { + Ok(PyIndexSegmentBuilder { + dataset: self.ds.clone(), + staging_index_uuid, + partial_indices: Vec::new(), + target_segment_bytes: None, + }) + } + + fn commit_existing_index_segments( + &mut self, + index_name: &str, + column: &str, + segments: Vec>, + ) -> PyResult<()> { + let mut new_self = self.ds.as_ref().clone(); + let segments = segments + .into_iter() + .map(|segment| segment.inner.clone()) + .collect(); + rt().block_on( + None, + new_self.commit_existing_index_segments(index_name, column, segments), + )? + .infer_error()?; + self.ds = Arc::new(new_self); + Ok(()) + } + fn drop_index(&mut self, name: &str) -> PyResult<()> { let mut new_self = self.ds.as_ref().clone(); rt().block_on(None, new_self.drop_index(name))? diff --git a/python/src/indices.rs b/python/src/indices.rs index 805c84ec6b9..9589b78ff36 100644 --- a/python/src/indices.rs +++ b/python/src/indices.rs @@ -32,12 +32,12 @@ use pyo3::{ use lance::index::DatasetIndexInternalExt; use crate::fragment::FileFragment; -use crate::utils::PyJson; +use crate::utils::{PyJson, PyLance}; use crate::{ dataset::Dataset, error::PythonErrorExt, file::object_store_from_uri_or_path_no_options, rt, }; use lance::index::vector::ivf::write_ivf_pq_file_from_existing_index; -use lance_index::{DatasetIndexExt, IndexDescription, IndexSegment, IndexType}; +use lance_index::{DatasetIndexExt, IndexDescription, IndexSegment, IndexSegmentPlan, IndexType}; use uuid::Uuid; #[pyclass(name = "IndexConfig", module = "lance.indices", get_all)] @@ -58,6 +58,93 @@ impl PyIndexConfig { } } +#[pyclass(name = "IndexSegment", module = "lance.indices")] +#[derive(Debug, Clone)] +pub struct PyIndexSegment { + pub(crate) inner: IndexSegment, +} + +impl PyIndexSegment { + pub(crate) fn from_inner(inner: IndexSegment) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyIndexSegment { + #[getter] + fn uuid(&self) -> String { + self.inner.uuid().to_string() + } + + #[getter] + fn fragment_ids(&self) -> HashSet { + self.inner.fragment_bitmap().iter().collect() + } + + #[getter] + fn index_version(&self) -> i32 { + self.inner.index_version() + } + + fn __repr__(&self) -> String { + format!( + "IndexSegment(uuid={}, fragment_ids={:?}, index_version={})", + self.uuid(), + self.fragment_ids(), + self.index_version() + ) + } +} + +#[pyclass(name = "IndexSegmentPlan", module = "lance.indices")] +#[derive(Debug, Clone)] +pub struct PyIndexSegmentPlan { + pub(crate) inner: IndexSegmentPlan, +} + +impl PyIndexSegmentPlan { + pub(crate) fn from_inner(inner: IndexSegmentPlan) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyIndexSegmentPlan { + #[getter] + fn staging_index_uuid(&self) -> String { + self.inner.staging_index_uuid().to_string() + } + + #[getter] + fn segment(&self) -> PyIndexSegment { + PyIndexSegment::from_inner(self.inner.segment().clone()) + } + + #[getter] + fn partial_indices(&self) -> Vec> { + self.inner + .partial_indices() + .iter() + .cloned() + .map(PyLance) + .collect() + } + + #[getter] + fn estimated_bytes(&self) -> u64 { + self.inner.estimated_bytes() + } + fn __repr__(&self) -> String { + format!( + "IndexSegmentPlan(staging_index_uuid={}, partial_indices={}, estimated_bytes={})", + self.staging_index_uuid(), + self.inner.partial_indices().len(), + self.estimated_bytes() + ) + } +} + #[pyclass(name = "IvfModel", module = "lance.indices")] #[derive(Debug, Clone)] pub struct PyIvfModel { @@ -619,6 +706,8 @@ pub fn register_indices(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { indices.add_wrapped(wrap_pyfunction!(load_shuffled_vectors))?; indices.add_class::()?; indices.add_class::()?; + indices.add_class::()?; + indices.add_class::()?; indices.add_class::()?; indices.add_class::()?; indices.add_wrapped(wrap_pyfunction!(get_ivf_model))?; diff --git a/python/src/lib.rs b/python/src/lib.rs index 8c6f7c186ed..f7d233cf117 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -44,7 +44,9 @@ use dataset::io_stats::IoStats; use dataset::optimize::{ PyCompaction, PyCompactionMetrics, PyCompactionPlan, PyCompactionTask, PyRewriteResult, }; -use dataset::{DatasetBasePath, MergeInsertBuilder, PyFullTextQuery, PySearchFilter}; +use dataset::{ + DatasetBasePath, MergeInsertBuilder, PyFullTextQuery, PyIndexSegmentBuilder, PySearchFilter, +}; use env_logger::{Builder, Env}; use file::{ LanceBufferDescriptor, LanceColumnMetadata, LanceFileMetadata, LanceFileReader, @@ -252,6 +254,7 @@ fn lance(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/rust/lance-index/src/lib.rs b/rust/lance-index/src/lib.rs index 5f32a73675c..bb00a716173 100644 --- a/rust/lance-index/src/lib.rs +++ b/rust/lance-index/src/lib.rs @@ -33,7 +33,7 @@ pub mod types; pub mod vector; pub use crate::traits::*; -pub use crate::types::IndexSegment; +pub use crate::types::{IndexSegment, IndexSegmentPlan}; pub const INDEX_FILE_NAME: &str = "index.idx"; /// The name of the auxiliary index file. diff --git a/rust/lance-index/src/traits.rs b/rust/lance-index/src/traits.rs index 5ad13861ddc..1c5923e4050 100644 --- a/rust/lance-index/src/traits.rs +++ b/rust/lance-index/src/traits.rs @@ -126,6 +126,9 @@ pub trait IndexDescription: Send + Sync { #[async_trait] pub trait DatasetIndexExt { type IndexBuilder<'a> + where + Self: 'a; + type IndexSegmentBuilder<'a> where Self: 'a; @@ -145,6 +148,21 @@ pub trait DatasetIndexExt { params: &'a dyn IndexParams, ) -> Self::IndexBuilder<'a>; + /// Create a builder for building index segments from partial index outputs. + /// + /// The staging UUID identifies a directory containing previously-built shard + /// outputs. The caller supplies the partial index metadata returned by + /// `execute_uncommitted()` so the builder can plan segment grouping without + /// rediscovering shard coverage. + /// + /// This is the canonical entry point for distributed vector segment build. + /// After building the physical segments, publish them as a + /// logical index with [`Self::commit_existing_index_segments`]. + fn create_index_segment_builder<'a>( + &'a self, + staging_index_uuid: String, + ) -> Self::IndexSegmentBuilder<'a>; + /// Create indices on columns. /// /// Upon finish, a new dataset version is generated. @@ -275,6 +293,11 @@ pub trait DatasetIndexExt { async fn index_statistics(&self, index_name: &str) -> Result; /// Commit one or more existing physical index segments as a logical index. + /// + /// This publishes already-built physical segments. It does not build + /// or merge index data; callers should first build segments with + /// [`Self::create_index_segment_builder`] or another index-specific build + /// path and then pass the resulting segments here. async fn commit_existing_index_segments( &mut self, index_name: &str, diff --git a/rust/lance-index/src/types.rs b/rust/lance-index/src/types.rs index 6a991be9932..6520878991b 100644 --- a/rust/lance-index/src/types.rs +++ b/rust/lance-index/src/types.rs @@ -3,6 +3,8 @@ use std::sync::Arc; +use crate::IndexType; +use lance_table::format::IndexMetadata; use roaring::RoaringBitmap; use uuid::Uuid; @@ -73,3 +75,58 @@ impl IndexSegment { ) } } + +/// A plan for building one physical segment from one or more vector index +/// partial indices. +#[derive(Debug, Clone, PartialEq)] +pub struct IndexSegmentPlan { + staging_index_uuid: Uuid, + segment: IndexSegment, + partial_indices: Vec, + estimated_bytes: u64, + requested_index_type: Option, +} + +impl IndexSegmentPlan { + /// Create a plan for one built segment. + pub fn new( + staging_index_uuid: Uuid, + segment: IndexSegment, + partial_indices: Vec, + estimated_bytes: u64, + requested_index_type: Option, + ) -> Self { + Self { + staging_index_uuid, + segment, + partial_indices, + estimated_bytes, + requested_index_type, + } + } + + /// Return the staging index UUID that owns the partial shard outputs. + pub fn staging_index_uuid(&self) -> Uuid { + self.staging_index_uuid + } + + /// Return the segment metadata that should be committed after this plan is built. + pub fn segment(&self) -> &IndexSegment { + &self.segment + } + + /// Return the uncommitted partial index metadata that should be combined into the segment. + pub fn partial_indices(&self) -> &[IndexMetadata] { + &self.partial_indices + } + + /// Return the estimated number of bytes covered by this plan. + pub fn estimated_bytes(&self) -> u64 { + self.estimated_bytes + } + + /// Return the requested logical index type, if one was supplied to the planner. + pub fn requested_index_type(&self) -> Option { + self.requested_index_type + } +} diff --git a/rust/lance-index/src/vector/distributed/index_merger.rs b/rust/lance-index/src/vector/distributed/index_merger.rs index 0632a693342..2edcf3e0707 100755 --- a/rust/lance-index/src/vector/distributed/index_merger.rs +++ b/rust/lance-index/src/vector/distributed/index_merger.rs @@ -602,53 +602,26 @@ async fn read_shard_window_partitions( Ok(per_partition_batches) } -/// Merge all partial_* vector index auxiliary files under `index_dir/{uuid}/partial_*/auxiliary.idx` -/// into `index_dir/{uuid}/auxiliary.idx`. +/// Merge the selected partial-shard auxiliary files into `target_dir`. /// -/// Supports IVF_FLAT, IVF_PQ, IVF_SQ, IVF_HNSW_FLAT, IVF_HNSW_PQ, IVF_HNSW_SQ storage types. -/// For PQ and SQ, this assumes all partial indices share the same quantizer/codebook -/// and distance type; it will reuse the first encountered metadata. +/// This is the storage merge kernel for vector staged segment build. Callers +/// choose which partial shards belong to one built segment and pass the corresponding +/// auxiliary files here. The merge writes one unified `auxiliary.idx` into +/// `target_dir`. +/// +/// Supports IVF_FLAT, IVF_PQ, IVF_SQ, IVF_HNSW_FLAT, IVF_HNSW_PQ, and +/// IVF_HNSW_SQ storage types. For PQ and SQ, this assumes all selected partial +/// shards share the same quantizer/codebook and distance type; it reuses the +/// first encountered metadata. pub async fn merge_partial_vector_auxiliary_files( object_store: &lance_io::object_store::ObjectStore, - index_dir: &object_store::path::Path, + aux_paths: &[object_store::path::Path], + target_dir: &object_store::path::Path, ) -> Result<()> { - let mut aux_paths: Vec = Vec::new(); - let mut stream = object_store.list(Some(index_dir.clone())); - while let Some(item) = stream.next().await { - if let Ok(meta) = item - && let Some(fname) = meta.location.filename() - && fname == INDEX_AUXILIARY_FILE_NAME - { - // Check parent dir name starts with partial_ - let parts: Vec<_> = meta.location.parts().collect(); - if parts.len() >= 2 { - let pname = parts[parts.len() - 2].as_ref(); - if pname.starts_with("partial_") { - aux_paths.push(meta.location.clone()); - } - } - } - } - if aux_paths.is_empty() { - // If a unified auxiliary file already exists at the root, no merge is required. - let aux_out = index_dir.child(INDEX_AUXILIARY_FILE_NAME); - if object_store.exists(&aux_out).await.unwrap_or(false) { - log::warn!( - "No partial_* auxiliary files found under index dir: {}, but unified auxiliary file already exists; skipping merge", - index_dir - ); - return Ok(()); - } - // For certain index types (e.g., FLAT/HNSW-only) the merge may be a no-op in distributed setups - // where shards were committed directly. In such cases, proceed without error to avoid blocking - // index manifest merge. PQ/SQ variants still require merging artifacts and will be handled by - // downstream open logic if missing. - log::warn!( - "No partial_* auxiliary files found under index dir: {}; proceeding without merge for index types that do not require auxiliary shards", - index_dir - ); - return Ok(()); + return Err(Error::index( + "No partial auxiliary files were selected for merge".to_string(), + )); } // Prepare IVF model and storage metadata aggregation @@ -661,7 +634,7 @@ pub async fn merge_partial_vector_auxiliary_files( let mut format_version: Option = None; // Prepare output path; we'll create writer once when we know schema - let aux_out = index_dir.child(INDEX_AUXILIARY_FILE_NAME); + let aux_out = target_dir.child(INDEX_AUXILIARY_FILE_NAME); // We'll delay creating the V2 writer until we know the vector schema (dim and quantizer type) let mut v2w_opt: Option = None; @@ -682,7 +655,7 @@ pub async fn merge_partial_vector_auxiliary_files( let mut shard_infos: Vec = Vec::new(); // Iterate over each shard auxiliary file and merge its metadata and collect lengths - for aux in &aux_paths { + for aux in aux_paths { let fh = sched.open_file(aux, &CachedFileSize::unknown()).await?; let reader = V2Reader::try_open( fh, @@ -1417,9 +1390,13 @@ mod tests { .await .unwrap(); - merge_partial_vector_auxiliary_files(&object_store, &index_dir) - .await - .unwrap(); + merge_partial_vector_auxiliary_files( + &object_store, + &[aux0.clone(), aux1.clone()], + &index_dir, + ) + .await + .unwrap(); let aux_out = index_dir.child(INDEX_AUXILIARY_FILE_NAME); assert!(object_store.exists(&aux_out).await.unwrap()); @@ -1515,7 +1492,12 @@ mod tests { .await .unwrap(); - let res = merge_partial_vector_auxiliary_files(&object_store, &index_dir).await; + let res = merge_partial_vector_auxiliary_files( + &object_store, + &[aux0.clone(), aux1.clone()], + &index_dir, + ) + .await; match res { Err(Error::Index { message, .. }) => { assert!( @@ -1690,9 +1672,13 @@ mod tests { .unwrap(); // Merge PQ auxiliary files. - merge_partial_vector_auxiliary_files(&object_store, &index_dir) - .await - .unwrap(); + merge_partial_vector_auxiliary_files( + &object_store, + &[aux0.clone(), aux1.clone()], + &index_dir, + ) + .await + .unwrap(); // 3) Unified auxiliary file exists. let aux_out = index_dir.child(INDEX_AUXILIARY_FILE_NAME); @@ -1818,7 +1804,12 @@ mod tests { .await .unwrap(); - let res = merge_partial_vector_auxiliary_files(&object_store, &index_dir).await; + let res = merge_partial_vector_auxiliary_files( + &object_store, + &[aux0.clone(), aux1.clone()], + &index_dir, + ) + .await; match res { Err(Error::Index { message, .. }) => { assert!( @@ -1893,9 +1884,13 @@ mod tests { .unwrap(); // Merge must succeed and produce a unified auxiliary file. - merge_partial_vector_auxiliary_files(&object_store, &index_dir) - .await - .unwrap(); + merge_partial_vector_auxiliary_files( + &object_store, + &[aux_a.clone(), aux_b.clone()], + &index_dir, + ) + .await + .unwrap(); let aux_out = index_dir.child(INDEX_AUXILIARY_FILE_NAME); assert!(object_store.exists(&aux_out).await.unwrap()); diff --git a/rust/lance-index/src/vector/sq/storage.rs b/rust/lance-index/src/vector/sq/storage.rs index 8f6bcea6c1a..8311c20acaa 100644 --- a/rust/lance-index/src/vector/sq/storage.rs +++ b/rust/lance-index/src/vector/sq/storage.rs @@ -36,7 +36,7 @@ use crate::{ pub const SQ_METADATA_KEY: &str = "lance:sq"; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ScalarQuantizationMetadata { pub dim: usize, pub num_bits: u16, diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 2b1835647c5..cf7888d97f7 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -1838,6 +1838,11 @@ impl Dataset { .collect() } + /// Iterate over manifest fragments without allocating [`FileFragment`] wrappers. + pub fn iter_fragments(&self) -> impl Iterator { + self.manifest.fragments.iter() + } + pub fn get_fragment(&self, fragment_id: usize) -> Option { let dataset = Arc::new(self.clone()); let fragment = self @@ -2679,6 +2684,7 @@ impl Dataset { self.merge_impl(stream, left_on, right_on).await } + /// Merge a staged distributed index into a single root artifact. pub async fn merge_index_metadata( &self, index_uuid: &str, @@ -2709,14 +2715,59 @@ impl Dataset { } // Precise vector index types: IVF_FLAT, IVF_PQ, IVF_SQ IndexType::IvfFlat | IndexType::IvfPq | IndexType::IvfSq | IndexType::Vector => { - // Merge distributed vector index partials and finalize root index via Lance IVF helper - crate::index::vector::ivf::finalize_distributed_merge( - self.object_store(), + let mut partial_indices = self + .object_store() + .read_dir(index_dir.clone()) + .await? + .into_iter() + .filter(|name| name.starts_with("partial_")) + .map(|name| { + name.strip_prefix("partial_") + .ok_or_else(|| { + Error::index(format!( + "Distributed vector shard '{}' does not start with 'partial_'", + name + )) + }) + .and_then(|shard_uuid| { + uuid::Uuid::parse_str(shard_uuid).map_err(|err| { + Error::index(format!( + "Distributed vector shard '{}' does not end with a valid UUID: {}", + name, err + )) + }) + }) + .map(|shard_uuid| IndexMetadata { + uuid: shard_uuid, + name: String::new(), + fields: Vec::new(), + dataset_version: self.manifest.version, + fragment_bitmap: Some(RoaringBitmap::new()), + index_details: None, + index_version: index_type.version(), + created_at: None, + base_id: None, + files: Some(Vec::new()), + }) + }) + .collect::>>()?; + partial_indices.sort_by_key(|index| index.uuid); + let segment_plans = crate::index::vector::ivf::plan_staging_segments( &index_dir, + &partial_indices, Some(index_type), + None, ) .await?; - Ok(()) + let merged_plan = + crate::index::vector::ivf::collapse_segment_plans(&segment_plans)?; + crate::index::vector::ivf::build_staging_segment( + self.object_store(), + &self.indices_dir(), + &merged_plan, + ) + .await + .map(|_| ()) } _ => Err(Error::invalid_input_source(Box::new(std::io::Error::new( std::io::ErrorKind::InvalidInput, diff --git a/rust/lance/src/dataset/mem_wal/memtable/flush.rs b/rust/lance/src/dataset/mem_wal/memtable/flush.rs index ba2344fafaf..c3b955fc265 100644 --- a/rust/lance/src/dataset/mem_wal/memtable/flush.rs +++ b/rust/lance/src/dataset/mem_wal/memtable/flush.rs @@ -247,11 +247,8 @@ impl MemTableFlusher { index_meta.fields = vec![field_idx]; index_meta.dataset_version = dataset.version().version; // Calculate fragment_bitmap from dataset fragments - let fragment_ids: roaring::RoaringBitmap = dataset - .get_fragments() - .iter() - .map(|f| f.id() as u32) - .collect(); + let fragment_ids: roaring::RoaringBitmap = + dataset.fragment_bitmap.as_ref().clone(); index_meta.fragment_bitmap = Some(fragment_ids); // Commit the index to the dataset @@ -467,11 +464,7 @@ impl MemTableFlusher { let schema = dataset.schema(); let field_idx = schema.field(&fts_cfg.column).map(|f| f.id).unwrap_or(0); - let fragment_ids: roaring::RoaringBitmap = dataset - .get_fragments() - .iter() - .map(|f| f.id() as u32) - .collect(); + let fragment_ids: roaring::RoaringBitmap = dataset.fragment_bitmap.as_ref().clone(); let index_meta = IndexMetadata { uuid: index_uuid, diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index 6baffc25382..5ae8ee91b05 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -582,6 +582,7 @@ impl IndexDescription for IndexDescriptionImpl { #[async_trait] impl DatasetIndexExt for Dataset { type IndexBuilder<'a> = CreateIndexBuilder<'a>; + type IndexSegmentBuilder<'a> = create::IndexSegmentBuilder<'a>; /// Create a builder for creating an index on columns. /// @@ -627,6 +628,13 @@ impl DatasetIndexExt for Dataset { CreateIndexBuilder::new(self, columns, index_type, params) } + fn create_index_segment_builder<'a>( + &'a self, + staging_index_uuid: String, + ) -> create::IndexSegmentBuilder<'a> { + create::IndexSegmentBuilder::new(self, staging_index_uuid) + } + #[instrument(skip_all)] async fn create_index( &mut self, diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index 5ca3375e243..41c44994e69 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -17,10 +17,10 @@ use crate::{ vector_index_details, }, }; -use futures::future::BoxFuture; +use futures::future::{BoxFuture, try_join_all}; use lance_core::datatypes::format_field_path; use lance_index::progress::{IndexBuildProgress, NoopIndexBuildProgress}; -use lance_index::{IndexParams, IndexType, scalar::CreatedIndex}; +use lance_index::{IndexParams, IndexSegment, IndexSegmentPlan, IndexType, scalar::CreatedIndex}; use lance_index::{ metrics::NoOpMetricsCollector, scalar::{LANCE_SCALAR_INDEX, ScalarIndexParams, inverted::tokenizer::InvertedIndexParams}, @@ -209,6 +209,7 @@ impl<'a> CreateIndexBuilder<'a> { .map_err(|e| Error::index(format!("Invalid UUID string provided: {}", e)))?, None => Uuid::new_v4(), }; + let mut output_index_uuid = index_id; let created_index = match (self.index_type, self.params.index_name()) { ( IndexType::Bitmap @@ -336,7 +337,7 @@ impl<'a> CreateIndexBuilder<'a> { if let Some(fragments) = &self.fragments { // For distributed indexing, build only on specified fragments // This creates temporary index metadata without committing - Box::pin(build_distributed_vector_index( + let shard_uuid = Box::pin(build_distributed_vector_index( self.dataset, column, &index_name, @@ -347,6 +348,7 @@ impl<'a> CreateIndexBuilder<'a> { self.progress.clone(), )) .await?; + output_index_uuid = shard_uuid; } else { // Standard full dataset indexing Box::pin(build_vector_index( @@ -372,7 +374,14 @@ impl<'a> CreateIndexBuilder<'a> { .await?; } // Capture file sizes after vector index creation - let index_dir = self.dataset.indices_dir().child(index_id.to_string()); + let index_dir = if self.fragments.is_some() { + self.dataset + .indices_dir() + .child(index_id.to_string()) + .child(format!("partial_{}", output_index_uuid)) + } else { + self.dataset.indices_dir().child(index_id.to_string()) + }; let files = list_index_files_with_sizes(&self.dataset.object_store, &index_dir).await?; CreatedIndex { @@ -433,7 +442,7 @@ impl<'a> CreateIndexBuilder<'a> { }; Ok(IndexMetadata { - uuid: index_id, + uuid: output_index_uuid, name: index_name, fields: vec![field.id], dataset_version: self.dataset.manifest.version, @@ -508,21 +517,113 @@ impl<'a> IntoFuture for CreateIndexBuilder<'a> { } } +/// Build physical index segments from previously-written partial index outputs. +/// +/// Use [`DatasetIndexExt::create_index_segment_builder`] to open a staging root +/// and then either: +/// +/// - call [`Self::plan`] and orchestrate individual segment builds externally, or +/// - call [`Self::build_all`] to build all segments on the current node. +/// +/// This builder only builds physical segments. Publishing those segments as +/// a logical index still requires [`DatasetIndexExt::commit_existing_index_segments`]. +/// Together these two APIs form the canonical distributed vector segment build workflow. +#[derive(Clone)] +pub struct IndexSegmentBuilder<'a> { + dataset: &'a Dataset, + staging_index_uuid: String, + partial_indices: Vec, + target_segment_bytes: Option, +} + +impl<'a> IndexSegmentBuilder<'a> { + pub(crate) fn new(dataset: &'a Dataset, staging_index_uuid: String) -> Self { + Self { + dataset, + staging_index_uuid, + partial_indices: Vec::new(), + target_segment_bytes: None, + } + } + + /// Provide the partial index metadata returned by `execute_uncommitted()` + /// for this staging root. + pub fn with_partial_indices(mut self, partial_indices: Vec) -> Self { + self.partial_indices = partial_indices; + self + } + + /// Set the target size, in bytes, for merged built segments. + /// + /// When set, shard outputs will be grouped into larger built segments up to + /// approximately this size. When unset, each shard output becomes one built + /// segment. + pub fn with_target_segment_bytes(mut self, bytes: u64) -> Self { + self.target_segment_bytes = Some(bytes); + self + } + + /// Plan how partial indices should be grouped into built segments. + pub async fn plan(&self) -> Result> { + if self.partial_indices.is_empty() { + return Err(Error::invalid_input( + "IndexSegmentBuilder requires at least one partial index; \ + call with_partial_indices(...) with execute_uncommitted() outputs" + .to_string(), + )); + } + + crate::index::vector::ivf::plan_staging_segments( + &self + .dataset + .indices_dir() + .child(self.staging_index_uuid.as_str()), + &self.partial_indices, + None, + self.target_segment_bytes, + ) + .await + } + + /// Build one segment from a previously-generated plan. + pub async fn build(&self, plan: &IndexSegmentPlan) -> Result { + crate::index::vector::ivf::build_staging_segment( + self.dataset.object_store(), + &self.dataset.indices_dir(), + plan, + ) + .await + } + + /// Plan and build all segments from this staging root. + pub async fn build_all(&self) -> Result> { + let plans = self.plan().await?; + try_join_all(plans.iter().map(|plan| self.build(plan))).await + } +} + #[cfg(test)] mod tests { use super::*; use crate::dataset::{WriteMode, WriteParams}; + use crate::index::DatasetIndexExt; use crate::utils::test::{DatagenExt, FragmentCount, FragmentRowCount}; use arrow::datatypes::{Float32Type, Int32Type}; - use arrow_array::RecordBatchIterator; + use arrow_array::cast::AsArray; + use arrow_array::{FixedSizeListArray, RecordBatchIterator}; use arrow_array::{Int32Array, RecordBatch, StringArray}; use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; + use lance_arrow::FixedSizeListArrayExt; use lance_core::utils::tempfile::TempStrDir; use lance_datagen::{self, gen_batch}; use lance_index::optimize::OptimizeOptions; use lance_index::scalar::inverted::tokenizer::InvertedIndexParams; - use lance_linalg::distance::MetricType; + use lance_index::vector::hnsw::builder::HnswBuildParams; + use lance_index::vector::ivf::IvfBuildParams; + use lance_index::vector::kmeans::{KMeansParams, train_kmeans}; + use lance_linalg::distance::{DistanceType, MetricType}; use std::sync::Arc; + use uuid::Uuid; #[test] fn test_inverted_training_params_include_build_only_fields() { @@ -773,6 +874,39 @@ mod tests { .unwrap() } + async fn prepare_vector_ivf(dataset: &Dataset, vector_column: &str) -> IvfBuildParams { + let batch = dataset + .scan() + .project(&[vector_column.to_string()]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let vectors = batch + .column_by_name(vector_column) + .expect("vector column should exist") + .as_fixed_size_list(); + let dim = vectors.value_length() as usize; + let values = vectors.values().as_primitive::(); + + let kmeans = train_kmeans::( + values, + KMeansParams::new(None, 10, 1, DistanceType::L2), + dim, + 4, + 3, + ) + .unwrap(); + let centroids = Arc::new( + FixedSizeListArray::try_new_from_values( + kmeans.centroids.as_primitive::().clone(), + dim as i32, + ) + .unwrap(), + ); + IvfBuildParams::try_with_centroids(4, centroids).unwrap() + } + #[tokio::test] async fn test_execute_uncommitted() { // Test the complete workflow that covers the user's specified code pattern: @@ -936,6 +1070,277 @@ mod tests { assert_eq!(all_covered_fragments, expected_fragments); } + #[tokio::test] + async fn test_merge_index_metadata_vector_preserves_shared_uuid_commit_workflow() { + let tmpdir = TempStrDir::default(); + let dataset_uri = format!("file://{}", tmpdir.as_str()); + + let reader = gen_batch() + .col("id", lance_datagen::array::step::()) + .col( + "vector", + lance_datagen::array::rand_vec::(lance_datagen::Dimension::from(16)), + ) + .into_reader_rows( + lance_datagen::RowCount::from(256), + lance_datagen::BatchCount::from(4), + ); + let mut dataset = Dataset::write( + reader, + &dataset_uri, + Some(WriteParams { + max_rows_per_file: 64, + mode: WriteMode::Overwrite, + ..Default::default() + }), + ) + .await + .unwrap(); + + let fragments = dataset.get_fragments(); + assert!(fragments.len() >= 2); + let shared_uuid = Uuid::new_v4(); + let params = VectorIndexParams::with_ivf_flat_params( + DistanceType::L2, + prepare_vector_ivf(&dataset, "vector").await, + ); + + for fragment in &fragments { + let mut builder = + CreateIndexBuilder::new(&mut dataset, &["vector"], IndexType::Vector, ¶ms) + .name("vector_idx".to_string()) + .fragments(vec![fragment.id() as u32]) + .index_uuid(shared_uuid.to_string()); + builder.execute_uncommitted().await.unwrap(); + } + + dataset + .merge_index_metadata(&shared_uuid.to_string(), IndexType::IvfFlat, None) + .await + .unwrap(); + + let merged_root = dataset + .indices_dir() + .child(shared_uuid.to_string()) + .child(crate::index::INDEX_FILE_NAME); + assert!(dataset.object_store().exists(&merged_root).await.unwrap()); + + dataset + .commit_existing_index_segments( + "vector_idx", + "vector", + vec![IndexSegment::new( + shared_uuid, + fragments.iter().map(|fragment| fragment.id() as u32), + Arc::new(vector_index_details()), + IndexType::IvfFlat.version(), + )], + ) + .await + .unwrap(); + + let indices = dataset.load_indices_by_name("vector_idx").await.unwrap(); + assert_eq!(indices.len(), 1); + assert_eq!(indices[0].uuid, shared_uuid); + let committed_fragments: Vec = indices[0] + .fragment_bitmap + .as_ref() + .unwrap() + .iter() + .collect(); + assert_eq!( + committed_fragments, + fragments + .iter() + .map(|fragment| fragment.id() as u32) + .collect::>() + ); + + let query_batch = dataset + .scan() + .project(&["vector"] as &[&str]) + .unwrap() + .limit(Some(4), None) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let q = query_batch["vector"].as_fixed_size_list().value(0); + let result = dataset + .scan() + .project(&["_rowid"] as &[&str]) + .unwrap() + .nearest("vector", q.as_ref(), 5) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert!(result.num_rows() > 0); + } + + #[tokio::test] + async fn test_index_segment_builder_vector_commits_multi_segment_logical_index() { + let tmpdir = TempStrDir::default(); + let dataset_uri = format!("file://{}", tmpdir.as_str()); + + let reader = gen_batch() + .col("id", lance_datagen::array::step::()) + .col( + "vector", + lance_datagen::array::rand_vec::(lance_datagen::Dimension::from(16)), + ) + .into_reader_rows( + lance_datagen::RowCount::from(256), + lance_datagen::BatchCount::from(4), + ); + let mut dataset = Dataset::write( + reader, + &dataset_uri, + Some(WriteParams { + max_rows_per_file: 64, + mode: WriteMode::Overwrite, + ..Default::default() + }), + ) + .await + .unwrap(); + + let fragments = dataset.get_fragments(); + assert!(fragments.len() >= 2); + let shared_uuid = Uuid::new_v4(); + let params = VectorIndexParams::with_ivf_flat_params( + DistanceType::L2, + prepare_vector_ivf(&dataset, "vector").await, + ); + let mut partial_indices = Vec::new(); + + for fragment in fragments.iter().take(2) { + let index_metadata = + CreateIndexBuilder::new(&mut dataset, &["vector"], IndexType::Vector, ¶ms) + .name("vector_idx".to_string()) + .fragments(vec![fragment.id() as u32]) + .index_uuid(shared_uuid.to_string()) + .execute_uncommitted() + .await + .unwrap(); + partial_indices.push(index_metadata); + } + + let segments = dataset + .create_index_segment_builder(shared_uuid.to_string()) + .with_partial_indices(partial_indices) + .build_all() + .await + .unwrap(); + assert_eq!(segments.len(), 2); + + dataset + .commit_existing_index_segments("vector_idx", "vector", segments) + .await + .unwrap(); + + let indices = dataset.load_indices_by_name("vector_idx").await.unwrap(); + assert_eq!(indices.len(), 2); + let mut committed_fragment_sets = indices + .iter() + .map(|metadata| { + metadata + .fragment_bitmap + .as_ref() + .unwrap() + .iter() + .collect::>() + }) + .collect::>(); + committed_fragment_sets.sort(); + assert_eq!(committed_fragment_sets, vec![vec![0], vec![1]]); + + let query_batch = dataset + .scan() + .project(&["vector"] as &[&str]) + .unwrap() + .limit(Some(4), None) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let q = query_batch["vector"].as_fixed_size_list().value(0); + let result = dataset + .scan() + .project(&["_rowid"] as &[&str]) + .unwrap() + .nearest("vector", q.as_ref(), 5) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert!(result.num_rows() > 0); + } + + #[tokio::test] + async fn test_commit_existing_index_supports_local_hnsw_segments() { + let tmpdir = TempStrDir::default(); + let dataset_uri = format!("file://{}", tmpdir.as_str()); + + let reader = gen_batch() + .col("id", lance_datagen::array::step::()) + .col( + "vector", + lance_datagen::array::rand_vec::(lance_datagen::Dimension::from(16)), + ) + .into_reader_rows( + lance_datagen::RowCount::from(128), + lance_datagen::BatchCount::from(2), + ); + let mut dataset = Dataset::write( + reader, + &dataset_uri, + Some(WriteParams { + max_rows_per_file: 64, + mode: WriteMode::Overwrite, + ..Default::default() + }), + ) + .await + .unwrap(); + + let uuid = Uuid::new_v4(); + let params = VectorIndexParams::ivf_hnsw( + DistanceType::L2, + prepare_vector_ivf(&dataset, "vector").await, + HnswBuildParams::default(), + ); + + CreateIndexBuilder::new(&mut dataset, &["vector"], IndexType::Vector, ¶ms) + .name("vector_idx".to_string()) + .index_uuid(uuid.to_string()) + .execute_uncommitted() + .await + .unwrap(); + + dataset + .commit_existing_index_segments( + "vector_idx", + "vector", + vec![IndexSegment::new( + uuid, + dataset.fragment_bitmap.as_ref().clone(), + Arc::new(vector_index_details()), + IndexType::IvfHnswFlat.version(), + )], + ) + .await + .unwrap(); + + let indices = dataset.load_indices_by_name("vector_idx").await.unwrap(); + assert_eq!(indices.len(), 1); + assert_eq!(indices[0].uuid, uuid); + assert_eq!( + indices[0].fragment_bitmap.as_ref().unwrap(), + dataset.fragment_bitmap.as_ref() + ); + } + #[tokio::test] async fn test_optimize_should_not_removes_delta_indices() { let tmpdir = TempStrDir::default(); diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index 0d0ad64993a..c0c957a7fcf 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -35,7 +35,7 @@ use object_store::path::Path; use lance_arrow::FixedSizeListArrayExt; use lance_index::vector::pq::ProductQuantizer; use lance_index::vector::quantizer::QuantizationType; -use lance_index::vector::v3::shuffler::create_ivf_shuffler; +use lance_index::vector::v3::shuffler::{Shuffler, create_ivf_shuffler}; use lance_index::vector::v3::subindex::SubIndexType; use lance_index::vector::{ VectorIndex, @@ -329,56 +329,51 @@ impl IndexParams for VectorIndexParams { } } -/// Build a Distributed Vector Index for specific fragments -#[allow(clippy::too_many_arguments)] -#[instrument(level = "debug", skip(dataset))] -pub(crate) async fn build_distributed_vector_index( +/// Prepare the shared build inputs used by both direct local builds and +/// staged shard builds. +/// +/// These paths emit different file layouts, but they follow the same rules for +/// validating the vector column, deriving the effective index type, sizing IVF +/// partitions, and constructing the shuffler. +async fn prepare_vector_segment_build( dataset: &Dataset, column: &str, - _name: &str, - uuid: &str, params: &VectorIndexParams, - frag_reuse_index: Option>, - fragment_ids: &[u32], progress: Arc, -) -> Result<()> { + mode: &str, + require_precomputed_ivf: bool, +) -> Result<(DataType, IndexType, IvfBuildParams, Box)> { let stages = ¶ms.stages; if stages.is_empty() { - return Err(Error::index( - "Build Distributed Vector Index: must have at least 1 stage".to_string(), - )); - }; + return Err(Error::index(format!("{mode}: must have at least 1 stage"))); + } let StageParams::Ivf(ivf_params0) = &stages[0] else { return Err(Error::index(format!( - "Build Distributed Vector Index: invalid stages: {:?}", + "{mode}: invalid stages: {:?}", stages ))); }; - if ivf_params0.centroids.is_none() { - return Err(Error::index( - "Build Distributed Vector Index: missing precomputed IVF centroids; \ - please provide IvfBuildParams.centroids \ - for concurrent distributed create_index" - .to_string(), - )); + if require_precomputed_ivf && ivf_params0.centroids.is_none() { + return Err(Error::index(format!( + "{mode}: missing precomputed IVF centroids; please provide \ + IvfBuildParams.centroids for distributed segment build" + ))); } let (vector_type, element_type) = get_vector_type(dataset.schema(), column)?; if let DataType::List(_) = vector_type && params.metric_type != DistanceType::Cosine { - return Err(Error::index( - "Build Distributed Vector Index: multivector type supports only cosine distance" - .to_string(), - )); + return Err(Error::index(format!( + "{mode}: multivector type supports only cosine distance" + ))); } let num_rows = dataset.count_rows(None).await?; let index_type = params.index_type(); - let num_partitions = ivf_params0.num_partitions.unwrap_or_else(|| { recommended_num_partitions( num_rows, @@ -387,37 +382,58 @@ pub(crate) async fn build_distributed_vector_index( .unwrap_or(index_type.target_partition_size()), ) }); - let mut ivf_params = ivf_params0.clone(); ivf_params.num_partitions = Some(num_partitions); - let ivf_centroids = ivf_params - .centroids - .as_ref() - .expect("precomputed IVF centroids required for distributed indexing; checked above") - .as_ref() - .clone(); - let format_version = dataset_format_version(dataset); - let temp_dir = TempStdDir::default(); let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; let shuffler = create_ivf_shuffler( temp_dir_path, num_partitions, format_version, - Some(progress.clone()), + Some(progress), ); + Ok((element_type, index_type, ivf_params, shuffler)) +} + +/// Build a Distributed Vector Index for specific fragments +#[allow(clippy::too_many_arguments)] +#[instrument(level = "debug", skip(dataset))] +pub(crate) async fn build_distributed_vector_index( + dataset: &Dataset, + column: &str, + _name: &str, + uuid: &str, + params: &VectorIndexParams, + frag_reuse_index: Option>, + fragment_ids: &[u32], + progress: Arc, +) -> Result { + let (element_type, index_type, ivf_params, shuffler) = prepare_vector_segment_build( + dataset, + column, + params, + progress.clone(), + "Build Distributed Vector Index", + true, + ) + .await?; + let stages = ¶ms.stages; + + let ivf_centroids = ivf_params + .centroids + .as_ref() + .expect("precomputed IVF centroids required for distributed indexing; checked above") + .as_ref() + .clone(); + let filtered_dataset = dataset.clone(); let out_base = dataset.indices_dir().child(uuid); - - let make_partial_index_dir = |out_base: &Path| -> Path { - let shard_uuid = Uuid::new_v4(); - out_base.child(format!("partial_{}", shard_uuid)) - }; - let new_index_dir = || make_partial_index_dir(&out_base); + let shard_uuid = Uuid::new_v4(); + let index_dir = out_base.child(format!("partial_{}", shard_uuid)); let fragment_filter = fragment_ids.to_vec(); @@ -458,13 +474,12 @@ pub(crate) async fn build_distributed_vector_index( match index_type { IndexType::IvfFlat => match element_type { DataType::Float16 | DataType::Float32 | DataType::Float64 => { - let index_dir = new_index_dir(); let ivf_model = make_ivf_model(); IvfIndexBuilder::::new( filtered_dataset, column.to_owned(), - index_dir, + index_dir.clone(), params.metric_type, shuffler, Some(ivf_params), @@ -479,13 +494,12 @@ pub(crate) async fn build_distributed_vector_index( .await?; } DataType::UInt8 => { - let index_dir = new_index_dir(); let ivf_model = make_ivf_model(); IvfIndexBuilder::::new( filtered_dataset, column.to_owned(), - index_dir, + index_dir.clone(), params.metric_type, shuffler, Some(ivf_params), @@ -523,14 +537,13 @@ pub(crate) async fn build_distributed_vector_index( )); } IndexFileVersion::V3 => { - let index_dir = new_index_dir(); let ivf_model = make_ivf_model(); let global_pq = make_global_pq(pq_params)?; IvfIndexBuilder::::new( filtered_dataset, column.to_owned(), - index_dir, + index_dir.clone(), params.metric_type, shuffler, Some(ivf_params), @@ -559,12 +572,10 @@ pub(crate) async fn build_distributed_vector_index( ))); }; - let index_dir = new_index_dir(); - IvfIndexBuilder::::new( filtered_dataset, column.to_owned(), - index_dir, + index_dir.clone(), params.metric_type, shuffler, Some(ivf_params), @@ -586,12 +597,10 @@ pub(crate) async fn build_distributed_vector_index( ))); }; - let index_dir = new_index_dir(); - IvfIndexBuilder::::new( filtered_dataset, column.to_owned(), - index_dir, + index_dir.clone(), params.metric_type, shuffler, Some(ivf_params), @@ -619,14 +628,13 @@ pub(crate) async fn build_distributed_vector_index( ))); }; - let index_dir = new_index_dir(); let ivf_model = make_ivf_model(); let global_pq = make_global_pq(pq_params)?; IvfIndexBuilder::::new( filtered_dataset, column.to_owned(), - index_dir, + index_dir.clone(), params.metric_type, shuffler, Some(ivf_params), @@ -659,12 +667,10 @@ pub(crate) async fn build_distributed_vector_index( ))); }; - let index_dir = new_index_dir(); - IvfIndexBuilder::::new( filtered_dataset, column.to_owned(), - index_dir, + index_dir.clone(), params.metric_type, shuffler, Some(ivf_params), @@ -694,7 +700,7 @@ pub(crate) async fn build_distributed_vector_index( } }; - Ok(()) + Ok(shard_uuid) } /// Build a Vector Index @@ -708,53 +714,17 @@ pub(crate) async fn build_vector_index( frag_reuse_index: Option>, progress: Arc, ) -> Result<()> { + let (element_type, index_type, ivf_params, shuffler) = prepare_vector_segment_build( + dataset, + column, + params, + progress.clone(), + "Build Vector Index", + false, + ) + .await?; let stages = ¶ms.stages; - if stages.is_empty() { - return Err(Error::index( - "Build Vector Index: must have at least 1 stage".to_string(), - )); - }; - - let StageParams::Ivf(ivf_params) = &stages[0] else { - return Err(Error::index(format!( - "Build Vector Index: invalid stages: {:?}", - stages - ))); - }; - - let (vector_type, element_type) = get_vector_type(dataset.schema(), column)?; - if let DataType::List(_) = vector_type - && params.metric_type != DistanceType::Cosine - { - return Err(Error::index( - "Build Vector Index: multivector type supports only cosine distance".to_string(), - )); - } - - let num_rows = dataset.count_rows(None).await?; - let index_type = params.index_type(); - let num_partitions = ivf_params.num_partitions.unwrap_or_else(|| { - recommended_num_partitions( - num_rows, - ivf_params - .target_partition_size - .unwrap_or(index_type.target_partition_size()), - ) - }); - let mut ivf_params = ivf_params.clone(); - ivf_params.num_partitions = Some(num_partitions); - - let format_version = dataset_format_version(dataset); - - let temp_dir = TempStdDir::default(); - let temp_dir_path = Path::from_filesystem_path(&temp_dir)?; - let shuffler = create_ivf_shuffler( - temp_dir_path, - num_partitions, - format_version, - Some(progress.clone()), - ); match index_type { IndexType::IvfFlat => match element_type { DataType::Float16 | DataType::Float32 | DataType::Float64 => { diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 621773c60c0..b693de2e0a0 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -56,14 +56,16 @@ use lance_index::vector::bq::builder::RabitQuantizer; use lance_index::vector::flat::index::{FlatBinQuantizer, FlatIndex, FlatQuantizer}; use lance_index::vector::hnsw::HnswMetadata; use lance_index::vector::hnsw::builder::HNSW_METADATA_KEY; -use lance_index::vector::ivf::storage::{IVF_METADATA_KEY, IvfModel}; +use lance_index::vector::ivf::storage::IVF_METADATA_KEY; +use lance_index::vector::ivf::storage::IvfModel; use lance_index::vector::kmeans::KMeansParams; use lance_index::vector::pq::storage::transpose; use lance_index::vector::quantizer::QuantizationType; use lance_index::vector::v3::shuffler::create_ivf_shuffler; use lance_index::vector::v3::subindex::{IvfSubIndex, SubIndexType}; use lance_index::{ - INDEX_AUXILIARY_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, Index, IndexMetadata, IndexType, + INDEX_AUXILIARY_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, Index, IndexMetadata, IndexSegment, + IndexSegmentPlan, IndexType, optimize::OptimizeOptions, vector::{ Query, VectorIndex, @@ -88,6 +90,7 @@ use lance_io::{ }; use lance_linalg::distance::{DistanceType, Dot, L2, MetricType}; use lance_linalg::{distance::Normalize, kernels::normalize_fsl_owned}; +use lance_table::format::IndexMetadata as TableIndexMetadata; use log::{info, warn}; use object_store::path::Path; use prost::Message; @@ -1857,29 +1860,437 @@ async fn write_ivf_hnsw_file( Ok(()) } -/// Finalize distributed merge for IVF-based vector indices. +/// Distributed vector segment build uses three storage-level concepts: /// -/// This helper merges partial auxiliary index files produced by distributed -/// jobs into a unified `auxiliary.idx` and then creates a root `index.idx` -/// using the v2 index format so that `open_vector_index_v2` can load it. +/// - A **staging root** is the shared UUID directory used during distributed +/// shard build. Each worker writes one `partial_/` directory under this +/// root by calling `execute_uncommitted()` with the same `index_uuid`. +/// - A **partial shard** is one such worker output. The caller provides the +/// `IndexMetadata` returned by `execute_uncommitted()` so the planner knows +/// shard UUIDs, fragment coverage, and approximate shard sizes. +/// - A **built segment** is a physical index segment that can be committed into +/// the manifest with `commit_existing_index_segments(...)`. /// -/// The caller must pass `index_dir` pointing at the index UUID directory -/// (e.g. `/indices/`). `requested_index_type` is only used as -/// a fallback when the unified auxiliary file does not contain index -/// metadata. -pub async fn finalize_distributed_merge( - object_store: &ObjectStore, - index_dir: &object_store::path::Path, +/// The staged segment-build path is therefore: +/// +/// 1. workers build `partial_*` shards under one staging root +/// 2. the caller groups those shards into one or more built segments +/// 3. each segment is built from its selected shards +/// 4. the resulting physical segments are committed as one logical index +/// +/// A single merge work item produced from one staging root. +/// +/// Each plan says: +/// - which staging root it belongs to +/// - which partial shards should be consumed together +/// - what the built segment metadata should look like +/// +/// The planner returns a `Vec` so callers can decide +/// whether to execute the work serially or fan it out externally. +/// Plan how one staging root should be turned into built physical segments. +/// +/// This function does not touch storage. It only: +/// - validates that the caller-supplied shard contract is self-consistent +/// - enforces that shard fragment coverage is disjoint +/// - groups shards into built segments according to `target_segment_bytes` +/// +/// The grouping rule is intentionally simple: +/// - `target_segment_bytes = None`: keep the shard boundary, so each shard becomes one segment +/// - `target_segment_bytes = Some(limit)`: greedily pack consecutive shards until the next shard +/// would exceed `limit` +/// +/// Callers that want one built segment for the entire staging root should pass a +/// sufficiently large `target_segment_bytes`. +pub(crate) async fn plan_staging_segments( + index_dir: &Path, + partial_indices: &[TableIndexMetadata], requested_index_type: Option, + target_segment_bytes: Option, +) -> Result> { + if let Some(index_type) = requested_index_type + && !matches!( + index_type, + IndexType::IvfFlat + | IndexType::IvfPq + | IndexType::IvfSq + | IndexType::IvfHnswFlat + | IndexType::IvfHnswPq + | IndexType::IvfHnswSq + | IndexType::Vector + ) + { + return Err(Error::invalid_input(format!( + "Unsupported distributed vector segment build type: {}", + index_type + ))); + } + + if let Some(0) = target_segment_bytes { + return Err(Error::invalid_input( + "target_segment_bytes must be greater than zero".to_string(), + )); + } + + if partial_indices.is_empty() { + return Err(Error::index(format!( + "No partial index metadata was provided for '{}'", + index_dir + ))); + } + + let mut sorted_partial_indices = partial_indices.to_vec(); + sorted_partial_indices.sort_by_key(|index| index.uuid); + let mut expected_shard_ids = HashSet::with_capacity(sorted_partial_indices.len()); + for partial_index in &sorted_partial_indices { + if !expected_shard_ids.insert(partial_index.uuid) { + return Err(Error::index(format!( + "Distributed vector partial shard '{}' was provided more than once", + partial_index.uuid + ))); + } + } + + let staging_index_uuid = index_dir + .filename() + .ok_or_else(|| Error::index(format!("Index directory '{}' has no filename", index_dir))) + .and_then(|name| { + Uuid::parse_str(name).map_err(|err| { + Error::index(format!( + "Index directory '{}' does not end with a valid UUID: {}", + index_dir, err + )) + }) + })?; + + let mut covered_fragments = RoaringBitmap::new(); + for partial_index in &sorted_partial_indices { + let fragment_bitmap = partial_index.fragment_bitmap.as_ref().ok_or_else(|| { + Error::index(format!( + "Partial index '{}' is missing fragment coverage", + partial_index.uuid + )) + })?; + if covered_fragments.intersection_len(fragment_bitmap) > 0 { + return Err(Error::index( + "Distributed vector shards have overlapping fragment coverage".to_string(), + )); + } + covered_fragments |= fragment_bitmap.clone(); + } + + if target_segment_bytes.is_none() { + return sorted_partial_indices + .into_iter() + .map(|partial_index| { + build_segment_plan( + staging_index_uuid, + vec![partial_index], + requested_index_type, + ) + }) + .collect(); + } + + let target_segment_bytes = target_segment_bytes.unwrap(); + let mut plans = Vec::new(); + let mut current_group = Vec::new(); + let mut current_bytes = 0_u64; + + for partial_index in sorted_partial_indices { + let partial_bytes = estimate_partial_index_bytes(&partial_index); + if !current_group.is_empty() + && current_bytes.saturating_add(partial_bytes) > target_segment_bytes + { + plans.push(build_segment_plan( + staging_index_uuid, + std::mem::take(&mut current_group), + requested_index_type, + )?); + current_bytes = 0; + } + current_bytes = current_bytes.saturating_add(partial_bytes); + current_group.push(partial_index); + } + + if !current_group.is_empty() { + plans.push(build_segment_plan( + staging_index_uuid, + current_group, + requested_index_type, + )?); + } + + Ok(plans) +} + +/// Build one planned segment into its output directory. +/// +/// Most plans write directly to `indices//`. If the target +/// directory is also the staging root, we first write into a temporary +/// directory and then swap the final files back into place. +/// +/// This is similar in shape to a compaction step: several temporary shard +/// outputs are consumed and replaced by a new built physical segment. The +/// difference is that this operates on index shard outputs instead of data +/// fragments. +pub(crate) async fn build_staging_segment( + object_store: &ObjectStore, + indices_dir: &Path, + segment_plan: &IndexSegmentPlan, +) -> Result { + let built_segment = segment_plan.segment().clone(); + let final_dir = indices_dir.child(built_segment.uuid().to_string()); + let staging_dir = indices_dir.child(segment_plan.staging_index_uuid().to_string()); + if final_dir == staging_dir { + let temp_dir = indices_dir.child(Uuid::new_v4().to_string()); + build_staging_segment_to_dir(object_store, indices_dir, &temp_dir, segment_plan, false) + .await?; + + // Re-materializing back into the staging root is not atomic: we delete + // and rewrite the root files one by one because object stores do not + // offer a directory rename primitive. We only clean up the source + // `partial_*` shards after these copies succeed, so a crash here can + // leave a partial root artifact but should not destroy the source data. + for file_name in [INDEX_FILE_NAME, INDEX_AUXILIARY_FILE_NAME] { + let target_file = final_dir.child(file_name); + // ObjectStore::copy is additive. Remove any previous root artifact first so + // re-materialization back into the staging root does not leave stale files + // behind. + if object_store.exists(&target_file).await? { + object_store.delete(&target_file).await?; + } + let source_file = temp_dir.child(file_name); + if object_store.exists(&source_file).await? { + object_store.copy(&source_file, &target_file).await?; + } + } + + cleanup_consumed_partial_shards(object_store, indices_dir, segment_plan).await?; + reset_final_segment_dir(object_store, &temp_dir).await?; + } else { + build_staging_segment_to_dir(object_store, indices_dir, &final_dir, segment_plan, true) + .await?; + } + + Ok(built_segment) +} + +/// Write one built segment into `final_dir`. +/// +/// For a single-shard plan this is just a file copy. For a multi-shard plan we +/// read the selected `partial_*` shards directly from the staging root and +/// write the merged auxiliary/index files into `final_dir`. +async fn build_staging_segment_to_dir( + object_store: &ObjectStore, + indices_dir: &Path, + final_dir: &Path, + segment_plan: &IndexSegmentPlan, + cleanup_source_shards: bool, ) -> Result<()> { - // Merge per-shard auxiliary files into a unified auxiliary.idx. + reset_final_segment_dir(object_store, final_dir).await?; + + let partial_indices = segment_plan.partial_indices(); + if partial_indices.len() == 1 { + let source_dir = indices_dir + .child(segment_plan.staging_index_uuid().to_string()) + .child(format!("partial_{}", partial_indices[0].uuid)); + copy_partial_segment_contents(object_store, &source_dir, final_dir).await?; + if cleanup_source_shards { + cleanup_consumed_partial_shards(object_store, indices_dir, segment_plan).await?; + } + return Ok(()); + } + + let staging_root = indices_dir.child(segment_plan.staging_index_uuid().to_string()); + let aux_paths = partial_indices + .iter() + .map(|partial_index| { + staging_root + .child(format!("partial_{}", partial_index.uuid)) + .child(INDEX_AUXILIARY_FILE_NAME) + }) + .collect::>(); + let partial_index_paths = partial_indices + .iter() + .map(|partial_index| { + staging_root + .child(format!("partial_{}", partial_index.uuid)) + .child(INDEX_FILE_NAME) + }) + .collect::>(); + lance_index::vector::distributed::index_merger::merge_partial_vector_auxiliary_files( object_store, - index_dir, + &aux_paths, + final_dir, + ) + .await?; + write_root_vector_index_from_auxiliary( + object_store, + final_dir, + segment_plan.requested_index_type(), + &partial_index_paths, ) .await?; + if cleanup_source_shards { + cleanup_consumed_partial_shards(object_store, indices_dir, segment_plan).await?; + } + + Ok(()) +} + +/// Collapse one group of staging shards into a single built-segment plan. +fn build_segment_plan( + staging_index_uuid: Uuid, + group: Vec, + requested_index_type: Option, +) -> Result { + debug_assert!(!group.is_empty()); + let first = &group[0]; + let mut fragment_bitmap = RoaringBitmap::new(); + let mut estimated_bytes = 0_u64; + let mut partial_indices = Vec::with_capacity(group.len()); + + for partial_index in &group { + let partial_fragment_bitmap = partial_index.fragment_bitmap.as_ref().ok_or_else(|| { + Error::index(format!( + "Partial index '{}' is missing fragment coverage", + partial_index.uuid + )) + })?; + fragment_bitmap |= partial_fragment_bitmap.clone(); + estimated_bytes = + estimated_bytes.saturating_add(estimate_partial_index_bytes(partial_index)); + partial_indices.push(partial_index.clone()); + } + + let final_uuid = if group.len() == 1 { + first.uuid + } else { + Uuid::new_v4() + }; + let index_type = requested_index_type.unwrap_or(IndexType::Vector); + let segment = IndexSegment::new( + final_uuid, + fragment_bitmap, + Arc::new(crate::index::vector_index_details()), + index_type.version(), + ); + + Ok(IndexSegmentPlan::new( + staging_index_uuid, + segment, + partial_indices, + estimated_bytes, + requested_index_type, + )) +} + +/// Collapse an entire staging root into one built-segment plan. +/// +/// Some callers want one final output for the entire staging root instead of +/// one output per planned group. This helper reduces an existing set of plans +/// into a single plan covering the same shard set. +pub(crate) fn collapse_segment_plans( + segment_plans: &[IndexSegmentPlan], +) -> Result { + let Some(first_plan) = segment_plans.first() else { + return Err(Error::index( + "Distributed vector segment build plan contains no segment plans".to_string(), + )); + }; + + let mut fragment_bitmap = RoaringBitmap::new(); + let mut partial_indices = Vec::new(); + let mut estimated_bytes = 0_u64; + + for plan in segment_plans { + fragment_bitmap |= plan.segment().fragment_bitmap().clone(); + partial_indices.extend_from_slice(plan.partial_indices()); + estimated_bytes = estimated_bytes.saturating_add(plan.estimated_bytes()); + } + + let staging_index_uuid = first_plan.staging_index_uuid(); + let segment = IndexSegment::new( + staging_index_uuid, + fragment_bitmap, + first_plan.segment().index_details().clone(), + first_plan.segment().index_version(), + ); + + Ok(IndexSegmentPlan::new( + staging_index_uuid, + segment, + partial_indices, + estimated_bytes, + first_plan.requested_index_type(), + )) +} + +/// Remove the source `partial_*` directories consumed by one segment plan. +async fn cleanup_consumed_partial_shards( + object_store: &ObjectStore, + indices_dir: &Path, + segment_plan: &IndexSegmentPlan, +) -> Result<()> { + for partial_index in segment_plan.partial_indices() { + let source_dir = indices_dir + .child(segment_plan.staging_index_uuid().to_string()) + .child(format!("partial_{}", partial_index.uuid)); + reset_final_segment_dir(object_store, &source_dir).await?; + } + Ok(()) +} + +fn estimate_partial_index_bytes(index_metadata: &TableIndexMetadata) -> u64 { + index_metadata + .files + .as_ref() + .map(|files| files.iter().map(|file| file.size_bytes).sum()) + .unwrap_or(0) +} + +/// Copy all files that belong to one partial shard into a new directory. +async fn copy_partial_segment_contents( + object_store: &ObjectStore, + source_dir: &Path, + target_dir: &Path, +) -> Result<()> { + let mut files = object_store.list(Some(source_dir.clone())); + while let Some(item) = files.next().await { + let meta = item?; + let Some(relative_parts) = meta.location.prefix_match(source_dir) else { + continue; + }; + let relative_parts = relative_parts.collect::>(); + if relative_parts.is_empty() { + continue; + } + let mut final_path = target_dir.clone(); + for part in relative_parts { + final_path = final_path.child(part.as_ref()); + } + object_store.copy(&meta.location, &final_path).await?; + } + Ok(()) +} + +/// Best-effort reset of one target directory before rewriting it. +async fn reset_final_segment_dir(object_store: &ObjectStore, final_dir: &Path) -> Result<()> { + match object_store.remove_dir_all(final_dir.clone()).await { + Ok(()) => {} + Err(Error::NotFound { .. }) => {} + Err(err) => return Err(err), + } + Ok(()) +} - // Open the unified auxiliary file. +async fn write_root_vector_index_from_auxiliary( + object_store: &ObjectStore, + index_dir: &Path, + requested_index_type: Option, + centroid_source_index_paths: &[Path], +) -> Result<()> { let aux_path = index_dir.child(INDEX_AUXILIARY_FILE_NAME); let scheduler = ScanScheduler::new( Arc::new(object_store.clone()), @@ -1912,36 +2323,20 @@ pub async fn finalize_distributed_merge( let mut pb_ivf: lance_index::pb::Ivf = Message::decode(raw_ivf_bytes.clone())?; // If the unified IVF metadata does not contain centroids, try to source them - // from any partial_* index.idx under this index directory. + // from one of the shard index files that fed this merge. if pb_ivf.centroids_tensor.is_none() { - let mut stream = object_store.list(Some(index_dir.clone())); - let mut partial_index_path = None; - - while let Some(item) = stream.next().await { - let meta = item?; - if let Some(fname) = meta.location.filename() - && fname == INDEX_FILE_NAME - { - let parts: Vec<_> = meta.location.parts().collect(); - if parts.len() >= 2 { - let parent = parts[parts.len() - 2].as_ref(); - if parent.starts_with("partial_") { - partial_index_path = Some(meta.location.clone()); - break; - } - } + for partial_index_path in centroid_source_index_paths { + if !object_store.exists(partial_index_path).await? { + continue; } - } - - if let Some(partial_index_path) = partial_index_path { let fh = scheduler - .open_file(&partial_index_path, &CachedFileSize::unknown()) + .open_file(partial_index_path, &CachedFileSize::unknown()) .await?; let partial_reader = V2Reader::try_open( fh, None, Arc::default(), - &lance_core::cache::LanceCache::no_cache(), + &LanceCache::no_cache(), V2ReaderOptions::default(), ) .await?; @@ -1953,6 +2348,7 @@ pub async fn finalize_distributed_merge( let partial_pb_ivf: lance_index::pb::Ivf = Message::decode(partial_ivf_bytes)?; if partial_pb_ivf.centroids_tensor.is_some() { pb_ivf.centroids_tensor = partial_pb_ivf.centroids_tensor; + break; } } } @@ -2033,69 +2429,6 @@ pub async fn finalize_distributed_merge( let empty_batch = RecordBatch::new_empty(arrow_schema); v2_writer.write_batch(&empty_batch).await?; v2_writer.finish().await?; - - if let Err(err) = cleanup_partial_vector_dirs(object_store, index_dir).await { - warn!( - "Failed to cleanup partial_* vector index directories under '{}': {}", - index_dir.as_ref(), - err - ); - } - - Ok(()) -} - -/// Cleanup for distributed partial vector index directories after -/// a distributed merge. -/// -/// This helper scans `index_dir` for direct child directories whose names -/// start with `partial_` (e.g. `/partial_0`, `/partial_1`) -/// and attempts to recursively delete them via [`ObjectStore::remove_dir_all`]. -/// -/// Listing and deletion failures are logged with [`warn!`] and ignored so that -/// index finalization is never blocked by cleanup. The function always returns -/// `Ok(())`. -async fn cleanup_partial_vector_dirs( - object_store: &ObjectStore, - index_dir: &object_store::path::Path, -) -> Result<()> { - let mut partial_dirs: HashSet = HashSet::new(); - let mut list_stream = object_store.list(Some(index_dir.clone())); - - while let Some(item) = list_stream.next().await { - match item { - Ok(meta) => { - if let Some(relative_parts) = meta.location.prefix_match(index_dir) { - let rel_parts: Vec<_> = relative_parts.collect(); - // Expect paths like: /partial_*/ - if rel_parts.len() >= 2 { - let parent_name = rel_parts[0].as_ref(); - if parent_name.starts_with("partial_") { - partial_dirs.insert(index_dir.child(parent_name)); - } - } - } - } - Err(e) => { - warn!( - "Failed to list index directory '{}' while collecting partial_* dirs: {}", - index_dir.as_ref(), - e - ); - } - } - } - - for dir in partial_dirs { - if let Err(e) = object_store.remove_dir_all(dir.clone()).await { - warn!( - "Failed to remove partial_* directory '{}' after distributed merge: {}", - dir.as_ref(), - e - ); - } - } - Ok(()) } @@ -3644,50 +3977,6 @@ mod tests { assert!(correct_times >= 9, "correct: {}", correct_times); } - #[tokio::test] - async fn test_cleanup_removes_only_partial_dirs() { - let object_store = ObjectStore::memory(); - let index_dir = Path::from("index/uuid_test_cleanup"); - - // partial_* directories that should be removed - let partial0_file = index_dir.child("partial_0").child("file.bin"); - let partial_abc_file = index_dir.child("partial_abc").child("file.bin"); - - // Non-partial paths that must be preserved - let partialx_file = index_dir.child("partialX").child("file.bin"); - let shard_file = index_dir.child("shard_0").child("file.bin"); - let keep_root_file = index_dir.child("keep_root.txt"); - - object_store.put(&partial0_file, b"partial0").await.unwrap(); - object_store - .put(&partial_abc_file, b"partial_abc") - .await - .unwrap(); - object_store.put(&partialx_file, b"partialx").await.unwrap(); - object_store.put(&shard_file, b"shard").await.unwrap(); - object_store.put(&keep_root_file, b"root").await.unwrap(); - - // Sanity: all files exist before cleanup - assert!(object_store.exists(&partial0_file).await.unwrap()); - assert!(object_store.exists(&partial_abc_file).await.unwrap()); - assert!(object_store.exists(&partialx_file).await.unwrap()); - assert!(object_store.exists(&shard_file).await.unwrap()); - assert!(object_store.exists(&keep_root_file).await.unwrap()); - - cleanup_partial_vector_dirs(&object_store, &index_dir) - .await - .unwrap(); - - // partial_* directories should be removed - assert!(!object_store.exists(&partial0_file).await.unwrap()); - assert!(!object_store.exists(&partial_abc_file).await.unwrap()); - - // Non-partial directories and root files must be preserved - assert!(object_store.exists(&partialx_file).await.unwrap()); - assert!(object_store.exists(&shard_file).await.unwrap()); - assert!(object_store.exists(&keep_root_file).await.unwrap()); - } - #[tokio::test(flavor = "multi_thread")] async fn test_build_ivf_model_progress_callback() { use lance_index::progress::IndexBuildProgress; @@ -3758,27 +4047,6 @@ mod tests { } } - #[tokio::test] - async fn test_cleanup_idempotent() { - let object_store = ObjectStore::memory(); - let index_dir = Path::from("index/uuid_test_cleanup_idempotent"); - - let partial_file = index_dir.child("partial_0").child("file.bin"); - object_store.put(&partial_file, b"partial").await.unwrap(); - - assert!(object_store.exists(&partial_file).await.unwrap()); - - cleanup_partial_vector_dirs(&object_store, &index_dir) - .await - .unwrap(); - assert!(!object_store.exists(&partial_file).await.unwrap()); - - // Second call should succeed even when there are no partial_* directories left. - cleanup_partial_vector_dirs(&object_store, &index_dir) - .await - .unwrap(); - } - #[tokio::test] async fn test_prewarm_ivf_legacy() { use lance_io::assert_io_eq; diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 0bdc0389648..6cab3f591bd 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -618,6 +618,7 @@ mod tests { }; use arrow_buffer::OffsetBuffer; use arrow_schema::{DataType, Field, Schema, SchemaRef}; + use futures::StreamExt; use itertools::Itertools; use lance_arrow::FixedSizeListArrayExt; use lance_index::vector::bq::{ @@ -627,8 +628,8 @@ mod tests { use crate::dataset::{InsertBuilder, UpdateBuilder, WriteMode, WriteParams}; use crate::index::DatasetIndexInternalExt; - use crate::index::vector::ivf::finalize_distributed_merge; use crate::index::vector::ivf::v2::IvfPq; + use crate::index::vector::ivf::{build_staging_segment, plan_staging_segments}; use crate::utils::test::copy_test_data_to_tmp; use crate::{ Dataset, @@ -664,6 +665,7 @@ mod tests { }; use lance_linalg::distance::{DistanceType, multivec_distance}; use lance_linalg::kernels::normalize_fsl; + use lance_table::format::IndexMetadata; use lance_testing::datagen::{generate_random_array, generate_random_array_with_range}; use object_store::path::Path; use rand::distr::uniform::SampleUniform; @@ -1437,6 +1439,64 @@ mod tests { (ivf_params, pq_params) } + async fn prepare_global_ivf(dataset: &Dataset, vector_column: &str) -> IvfBuildParams { + let batch = dataset + .scan() + .project(&[vector_column.to_string()]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let vectors = batch + .column_by_name(vector_column) + .expect("vector column should exist") + .as_fixed_size_list(); + + let dim = vectors.value_length() as usize; + assert_eq!(dim, TWO_FRAG_DIM, "unexpected vector dimension"); + + let values = vectors.values().as_primitive::(); + let kmeans_params = KMeansParams::new(None, TWO_FRAG_MAX_ITERS, 1, DistanceType::L2); + let kmeans = train_kmeans::( + values, + kmeans_params, + dim, + TWO_FRAG_NUM_PARTITIONS, + TWO_FRAG_SAMPLE_RATE, + ) + .unwrap(); + + let centroids_flat = kmeans.centroids.as_primitive::().clone(); + let centroids_fsl = + Arc::new(FixedSizeListArray::try_new_from_values(centroids_flat, dim as i32).unwrap()); + let mut ivf_params = + IvfBuildParams::try_with_centroids(TWO_FRAG_NUM_PARTITIONS, centroids_fsl).unwrap(); + ivf_params.max_iters = TWO_FRAG_MAX_ITERS as usize; + ivf_params.sample_rate = TWO_FRAG_SAMPLE_RATE; + ivf_params + } + + async fn build_distributed_partial_index_for_fragment_groups( + dataset: &mut Dataset, + fragment_groups: Vec>, // each group is a set of fragment ids + params: &VectorIndexParams, + index_name: &str, + ) -> (Uuid, Vec) { + let shared_uuid = Uuid::new_v4(); + let mut partial_indices = Vec::new(); + + for fragments in fragment_groups { + let mut builder = dataset.create_index_builder(&["vector"], IndexType::Vector, params); + builder = builder + .name(index_name.to_string()) + .fragments(fragments) + .index_uuid(shared_uuid.to_string()); + partial_indices.push(builder.execute_uncommitted().await.unwrap()); + } + + (shared_uuid, partial_indices) + } + async fn build_ivfpq_for_fragment_groups( dataset: &mut Dataset, fragment_groups: Vec>, // each group is a set of fragment ids @@ -1457,12 +1517,11 @@ mod tests { .name(index_name.to_string()) .fragments(fragments) .index_uuid(shared_uuid.to_string()); - // Build partial index shards without committing to manifest. builder.execute_uncommitted().await.unwrap(); } - let index_dir = dataset.indices_dir().child(shared_uuid.to_string()); - finalize_distributed_merge(dataset.object_store(), &index_dir, Some(IndexType::IvfPq)) + dataset + .merge_index_metadata(&shared_uuid.to_string(), IndexType::IvfPq, None) .await .unwrap(); @@ -1485,7 +1544,6 @@ mod tests { let idx_a = &stats_a["indices"][0]; let idx_b = &stats_b["indices"][0]; - // Centroids: same shape and values (within tolerance). let centroids_a = idx_a["centroids"] .as_array() .expect("centroids should be an array"); @@ -1517,7 +1575,6 @@ mod tests { } } - // Partitions sizes. let parts_a = idx_a["partitions"] .as_array() .expect("partitions should be an array"); @@ -1536,6 +1593,94 @@ mod tests { assert_eq!(sizes_a, sizes_b, "partition sizes mismatch"); } + async fn load_staging_shard_uuids(dataset: &Dataset, shared_uuid: Uuid) -> Vec { + let mut shard_uuids = dataset + .object_store() + .read_dir(dataset.indices_dir().child(shared_uuid.to_string())) + .await + .unwrap() + .into_iter() + .filter(|name| name.starts_with("partial_")) + .map(|name| Uuid::parse_str(name.trim_start_matches("partial_")).unwrap()) + .collect::>(); + shard_uuids.sort(); + shard_uuids + } + + /// Reconstruct the caller-side shard contract used by the tests. + /// + /// Production callers are expected to already know this mapping from + /// distributed scheduling state. The test helper rebuilds it by inspecting + /// which `partial_*` directories were created and pairing them with the + /// fragment groups we originally assigned. + async fn build_partial_indices( + dataset: &Dataset, + shared_uuid: Uuid, + fragment_groups: &[Vec], + ) -> Vec { + let shard_uuids = load_staging_shard_uuids(dataset, shared_uuid).await; + assert_eq!(shard_uuids.len(), fragment_groups.len()); + let mut partial_indices = Vec::with_capacity(shard_uuids.len()); + for (shard_uuid, fragment_group) in shard_uuids.into_iter().zip(fragment_groups.iter()) { + let partial_dir = dataset + .indices_dir() + .child(shared_uuid.to_string()) + .child(format!("partial_{}", shard_uuid)); + let mut estimated_bytes = 0_u64; + let mut files = dataset.object_store().list(Some(partial_dir)); + while let Some(item) = files.next().await { + estimated_bytes += item.unwrap().size; + } + partial_indices.push(IndexMetadata { + uuid: shard_uuid, + name: String::new(), + fields: Vec::new(), + dataset_version: dataset.version().version, + fragment_bitmap: Some(fragment_group.iter().copied().collect()), + index_details: None, + index_version: IndexType::Vector.version(), + created_at: None, + base_id: None, + files: Some(vec![lance_table::format::IndexFile { + path: String::new(), + size_bytes: estimated_bytes, + }]), + }); + } + partial_indices + } + + /// Execute the internal staged segment-build workflow used by the + /// regression tests: plan segment groups from caller-provided shard + /// metadata, build each segment, and publish them as one logical index. + async fn build_distributed_segments( + dataset: &mut Dataset, + shared_uuid: Uuid, + partial_indices: &[IndexMetadata], + target_segment_bytes: Option, + index_name: &str, + ) -> Vec { + let index_dir = dataset.indices_dir().child(shared_uuid.to_string()); + let segment_plans = + plan_staging_segments(&index_dir, partial_indices, None, target_segment_bytes) + .await + .unwrap(); + let mut segments = Vec::with_capacity(segment_plans.len()); + for plan in &segment_plans { + segments.push( + build_staging_segment(dataset.object_store(), &dataset.indices_dir(), plan) + .await + .unwrap(), + ); + } + dataset + .commit_existing_index_segments(index_name, "vector", segments.clone()) + .await + .unwrap(); + + segments + } + #[tokio::test] async fn test_ivfpq_recall_performance_on_two_frags_single_vs_split() { const INDEX_NAME: &str = "vector_idx"; @@ -1543,7 +1688,6 @@ mod tests { let test_dir = TempStrDir::default(); let base_uri = test_dir.as_str(); - // Generate the data once, then write it twice to two independent dataset URIs. let (schema, batches) = make_two_fragment_batches(); let ds_single_uri = format!("{}/single", base_uri); @@ -1553,7 +1697,6 @@ mod tests { write_dataset_from_batches(&ds_single_uri, schema.clone(), batches.clone()).await; let mut ds_split = write_dataset_from_batches(&ds_split_uri, schema, batches).await; - // Ensure we have at least 2 fragments. let fragments_single = ds_single.get_fragments(); assert!( fragments_single.len() >= 2, @@ -1567,10 +1710,8 @@ mod tests { fragments_split.len() ); - // Pretrain global IVF centroids and PQ codebook. let (ivf_params, pq_params) = prepare_global_ivf_pq(&ds_single, "vector").await; - // Build single index using two fragments in one distributed build. let group_single = vec![ fragments_single[0].id() as u32, fragments_single[1].id() as u32, @@ -1584,7 +1725,6 @@ mod tests { ) .await; - // Build split index: one fragment per distributed build, then merge. let group0 = vec![fragments_split[0].id() as u32]; let group1 = vec![fragments_split[1].id() as u32]; build_ivfpq_for_fragment_groups( @@ -1596,14 +1736,12 @@ mod tests { ) .await; - // Compare IVF layout via index statistics. let stats_single_json = ds_single.index_statistics(INDEX_NAME).await.unwrap(); let stats_split_json = ds_split.index_statistics(INDEX_NAME).await.unwrap(); let stats_single: serde_json::Value = serde_json::from_str(&stats_single_json).unwrap(); let stats_split: serde_json::Value = serde_json::from_str(&stats_split_json).unwrap(); assert_ivf_layout_equal(&stats_single, &stats_split); - // Compare row id sets per partition. let ctx_single = load_vector_index_context(&ds_single, "vector", INDEX_NAME).await; let ctx_split = load_vector_index_context(&ds_split, "vector", INDEX_NAME).await; @@ -1624,7 +1762,6 @@ mod tests { ); } - // Compare Top-K row ids on a deterministic set of queries. const K: usize = 10; const NUM_QUERIES: usize = 10; @@ -1653,7 +1790,6 @@ mod tests { ids_per_query } - // Collect a deterministic query set from ds_single. let query_batch = ds_single .scan() .project(&["vector"] as &[&str]) @@ -1677,6 +1813,421 @@ mod tests { ); } + #[rstest] + #[case::ivf_flat(IndexType::IvfFlat)] + #[case::ivf_pq(IndexType::IvfPq)] + #[case::ivf_sq(IndexType::IvfSq)] + #[tokio::test] + async fn test_distributed_vector_build_commits_multiple_segments_and_preserves_query_results( + #[case] index_type: IndexType, + ) { + const INDEX_NAME: &str = "vector_idx"; + const K: usize = 10; + const NUM_QUERIES: usize = 10; + + let test_dir = TempStrDir::default(); + let base_uri = test_dir.as_str(); + + // Generate the data once, then write it twice to two independent dataset URIs. + let (schema, batches) = make_two_fragment_batches(); + + let ds_single_uri = format!("{}/single", base_uri); + let ds_split_uri = format!("{}/split", base_uri); + + let mut ds_single = + write_dataset_from_batches(&ds_single_uri, schema.clone(), batches.clone()).await; + let mut ds_split = write_dataset_from_batches(&ds_split_uri, schema, batches).await; + + // Ensure we have at least 2 fragments. + let fragments_single = ds_single.get_fragments(); + assert!( + fragments_single.len() >= 2, + "expected at least 2 fragments in ds_single, got {}", + fragments_single.len() + ); + let fragments_split = ds_split.get_fragments(); + assert!( + fragments_split.len() >= 2, + "expected at least 2 fragments in ds_split, got {}", + fragments_split.len() + ); + + let distributed_params = match index_type { + IndexType::IvfFlat => { + let ivf_params = prepare_global_ivf(&ds_single, "vector").await; + VectorIndexParams::with_ivf_flat_params(DistanceType::L2, ivf_params) + } + IndexType::IvfPq => { + let (ivf_params, pq_params) = prepare_global_ivf_pq(&ds_single, "vector").await; + VectorIndexParams::with_ivf_pq_params(DistanceType::L2, ivf_params, pq_params) + } + IndexType::IvfSq => { + let ivf_params = prepare_global_ivf(&ds_single, "vector").await; + VectorIndexParams::with_ivf_sq_params( + DistanceType::L2, + ivf_params, + SQBuildParams::default(), + ) + } + other => panic!("unsupported test index type: {}", other), + }; + + ds_single + .create_index( + &["vector"], + IndexType::Vector, + Some(INDEX_NAME.to_string()), + &distributed_params, + true, + ) + .await + .unwrap(); + + let fragment_groups = fragments_split + .iter() + .map(|fragment| vec![fragment.id() as u32]) + .collect::>(); + let expected_segment_count = fragment_groups.len(); + let (shared_uuid, partial_indices) = build_distributed_partial_index_for_fragment_groups( + &mut ds_split, + fragment_groups, + &distributed_params, + INDEX_NAME, + ) + .await; + let segments = build_distributed_segments( + &mut ds_split, + shared_uuid, + &partial_indices, + None, + INDEX_NAME, + ) + .await; + assert_eq!(segments.len(), expected_segment_count); + let staging_dir = ds_split.indices_dir().child(shared_uuid.to_string()); + let staging_entries = ds_split.object_store().read_dir(staging_dir).await.unwrap(); + assert!( + staging_entries + .iter() + .all(|entry| !entry.starts_with("partial_")), + "built segments should clean up consumed partial shards", + ); + + let committed_segments = ds_split.load_indices_by_name(INDEX_NAME).await.unwrap(); + assert_eq!(committed_segments.len(), expected_segment_count); + for committed in committed_segments { + let covered_fragments = committed + .fragment_bitmap + .as_ref() + .expect("distributed segment should have fragment coverage"); + assert_eq!(covered_fragments.len(), 1); + } + + async fn collect_row_ids(ds: &Dataset, queries: &[Arc]) -> Vec> { + let mut ids_per_query = Vec::with_capacity(queries.len()); + for q in queries { + let result = ds + .scan() + .with_row_id() + .project(&["_rowid"] as &[&str]) + .unwrap() + .nearest("vector", q.as_ref(), K) + .unwrap() + .try_into_batch() + .await + .unwrap(); + + let row_ids = result[ROW_ID] + .as_primitive::() + .values() + .iter() + .copied() + .collect::>(); + ids_per_query.push(row_ids); + } + ids_per_query + } + + // Collect a deterministic query set from ds_single. + let query_batch = ds_single + .scan() + .project(&["vector"] as &[&str]) + .unwrap() + .limit(Some(NUM_QUERIES as i64), None) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let vectors = query_batch["vector"].as_fixed_size_list(); + let queries: Vec> = (0..vectors.len()) + .map(|i| vectors.value(i) as Arc) + .collect(); + + let ids_single = collect_row_ids(&ds_single, &queries).await; + let ids_split = collect_row_ids(&ds_split, &queries).await; + + assert_eq!( + ids_single, ids_split, + "single vs segmented distributed index returned different Top-K row ids", + ); + } + + #[rstest] + #[case::ivf_flat(IndexType::IvfFlat)] + #[case::ivf_pq(IndexType::IvfPq)] + #[case::ivf_sq(IndexType::IvfSq)] + #[tokio::test] + async fn test_distributed_vector_grouped_build_allows_concurrent_group_execution( + #[case] index_type: IndexType, + ) { + const INDEX_NAME: &str = "grouped_idx"; + const K: usize = 10; + const NUM_QUERIES: usize = 10; + + let test_dir = TempStrDir::default(); + let base_uri = test_dir.as_str(); + + let (schema, batches) = make_two_fragment_batches(); + let ds_single_uri = format!("{}/grouped_single", base_uri); + let ds_split_uri = format!("{}/grouped_split", base_uri); + + let mut ds_single = + write_dataset_from_batches(&ds_single_uri, schema.clone(), batches.clone()).await; + let mut ds_split = write_dataset_from_batches(&ds_split_uri, schema, batches).await; + + let distributed_params = match index_type { + IndexType::IvfFlat => { + let ivf_params = prepare_global_ivf(&ds_single, "vector").await; + VectorIndexParams::with_ivf_flat_params(DistanceType::L2, ivf_params) + } + IndexType::IvfPq => { + let (ivf_params, pq_params) = prepare_global_ivf_pq(&ds_single, "vector").await; + VectorIndexParams::with_ivf_pq_params(DistanceType::L2, ivf_params, pq_params) + } + IndexType::IvfSq => { + let ivf_params = prepare_global_ivf(&ds_single, "vector").await; + VectorIndexParams::with_ivf_sq_params( + DistanceType::L2, + ivf_params, + SQBuildParams::default(), + ) + } + other => panic!("unsupported test index type: {}", other), + }; + + ds_single + .create_index( + &["vector"], + IndexType::Vector, + Some(INDEX_NAME.to_string()), + &distributed_params, + true, + ) + .await + .unwrap(); + + let fragment_groups = ds_split + .get_fragments() + .into_iter() + .map(|fragment| vec![fragment.id() as u32]) + .collect::>(); + let (shared_uuid, partial_indices) = build_distributed_partial_index_for_fragment_groups( + &mut ds_split, + fragment_groups, + &distributed_params, + INDEX_NAME, + ) + .await; + + let index_dir = ds_split.indices_dir().child(shared_uuid.to_string()); + let shard_plan = plan_staging_segments(&index_dir, &partial_indices, None, None) + .await + .unwrap(); + let shard_count = shard_plan.len(); + assert!(shard_count >= 4); + let target_segment_bytes = shard_plan[0].estimated_bytes().saturating_mul(2); + + let grouped_plan = plan_staging_segments( + &index_dir, + &partial_indices, + None, + Some(target_segment_bytes), + ) + .await + .unwrap(); + assert!(grouped_plan.len() < shard_count); + assert!( + grouped_plan + .iter() + .any(|plan| plan.partial_indices().len() > 1) + ); + + let grouped_segments = build_distributed_segments( + &mut ds_split, + shared_uuid, + &partial_indices, + Some(target_segment_bytes), + INDEX_NAME, + ) + .await; + assert_eq!(grouped_segments.len(), grouped_plan.len()); + + async fn collect_row_ids(ds: &Dataset, queries: &[Arc]) -> Vec> { + let mut ids_per_query = Vec::with_capacity(queries.len()); + for q in queries { + let result = ds + .scan() + .with_row_id() + .project(&["_rowid"] as &[&str]) + .unwrap() + .nearest("vector", q.as_ref(), K) + .unwrap() + .try_into_batch() + .await + .unwrap(); + + ids_per_query.push( + result[ROW_ID] + .as_primitive::() + .values() + .iter() + .copied() + .collect(), + ); + } + ids_per_query + } + + let query_batch = ds_single + .scan() + .project(&["vector"] as &[&str]) + .unwrap() + .limit(Some(NUM_QUERIES as i64), None) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let vectors = query_batch["vector"].as_fixed_size_list(); + let queries: Vec> = (0..vectors.len()) + .map(|i| vectors.value(i) as Arc) + .collect(); + + let ids_single = collect_row_ids(&ds_single, &queries).await; + let ids_split = collect_row_ids(&ds_split, &queries).await; + assert_eq!(ids_single, ids_split); + } + + #[tokio::test] + async fn test_distributed_vector_plan_rejects_overlapping_fragment_coverage() { + let test_dir = TempStrDir::default(); + let base_uri = test_dir.as_str(); + let (schema, batches) = make_two_fragment_batches(); + let dataset_uri = format!("{}/overlap_fragments", base_uri); + let mut dataset = write_dataset_from_batches(&dataset_uri, schema, batches).await; + + let fragment = dataset.get_fragments()[0].id() as u32; + let shared_uuid = Uuid::new_v4(); + let params = VectorIndexParams::with_ivf_flat_params( + DistanceType::L2, + prepare_global_ivf(&dataset, "vector").await, + ); + + for _ in 0..2 { + dataset + .create_index_builder(&["vector"], IndexType::Vector, ¶ms) + .name("vector_idx".to_string()) + .fragments(vec![fragment]) + .index_uuid(shared_uuid.to_string()) + .execute_uncommitted() + .await + .unwrap(); + } + + let index_dir = dataset.indices_dir().child(shared_uuid.to_string()); + let partial_indices = + build_partial_indices(&dataset, shared_uuid, &[vec![fragment], vec![fragment]]).await; + let err = plan_staging_segments(&index_dir, &partial_indices, None, None) + .await + .unwrap_err(); + assert!(err.to_string().contains("overlapping fragment coverage")); + } + + #[tokio::test] + async fn test_distributed_vector_build_supports_hnsw_variants() { + let test_dir = TempStrDir::default(); + let base_uri = test_dir.as_str(); + let (schema, batches) = make_two_fragment_batches(); + let dataset_uri = format!("{}/distributed_hnsw_supported", base_uri); + let mut dataset = write_dataset_from_batches(&dataset_uri, schema, batches).await; + + let fragments = dataset.get_fragments(); + assert!(fragments.len() >= 2); + let shared_uuid = Uuid::new_v4(); + let params = VectorIndexParams::ivf_hnsw( + DistanceType::L2, + prepare_global_ivf(&dataset, "vector").await, + HnswBuildParams::default(), + ); + + for fragment in fragments.iter().take(2) { + dataset + .create_index_builder(&["vector"], IndexType::Vector, ¶ms) + .name("vector_idx".to_string()) + .fragments(vec![fragment.id() as u32]) + .index_uuid(shared_uuid.to_string()) + .execute_uncommitted() + .await + .unwrap(); + } + + let index_dir = dataset.indices_dir().child(shared_uuid.to_string()); + let fragment_groups = fragments + .iter() + .take(2) + .map(|fragment| vec![fragment.id() as u32]) + .collect::>(); + let partial_indices = build_partial_indices(&dataset, shared_uuid, &fragment_groups).await; + let plans = plan_staging_segments(&index_dir, &partial_indices, None, Some(1)) + .await + .unwrap(); + assert_eq!(plans.len(), fragments.iter().take(2).count()); + + let mut segments = Vec::with_capacity(plans.len()); + for plan in &plans { + segments.push( + build_staging_segment(dataset.object_store(), &dataset.indices_dir(), plan) + .await + .unwrap(), + ); + } + assert_eq!(segments.len(), plans.len()); + + dataset + .commit_existing_index_segments("vector_idx", "vector", segments) + .await + .unwrap(); + + let query_batch = dataset + .scan() + .project(&["vector"] as &[&str]) + .unwrap() + .limit(Some(4), None) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let q = query_batch["vector"].as_fixed_size_list().value(0); + let result = dataset + .scan() + .project(&["_rowid"] as &[&str]) + .unwrap() + .nearest("vector", q.as_ref(), 5) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert!(result.num_rows() > 0); + } async fn test_index( params: VectorIndexParams, nlist: usize, From 88c627bb093c0fb078e80fdd9140a6c5f7f94d5b Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Sun, 22 Mar 2026 19:58:17 +0000 Subject: [PATCH 061/206] chore: release beta version 4.1.0-beta.1 --- .bumpversion.toml | 2 +- Cargo.lock | 196 ++++++++++++++++++------------------ Cargo.toml | 40 ++++---- java/lance-jni/Cargo.lock | 206 +++++++++++++++++++++----------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 178 ++++++++++++++++---------------- python/Cargo.toml | 2 +- 8 files changed, 325 insertions(+), 303 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 28f95e5cdef..87d1247bafe 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.1.0-beta.0" +current_version = "4.1.0-beta.1" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 46c328d8d3d..1be2a23aebf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,7 +174,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -185,7 +185,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2111,9 +2111,9 @@ dependencies = [ [[package]] name = "datafusion" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea28305c211e3541c9cfcf06a23d0d8c7c824b4502ed1fdf0a6ff4ad24ee531c" +checksum = "43c18ba387f9c05ac1f3be32a73f8f3cc6c1cfc43e5d4b7a8e5b0d3a5eb48dc7" dependencies = [ "arrow", "arrow-schema", @@ -2162,9 +2162,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ab99b6df5f60a6ddbc515e4c05caee1192d395cf3cb67ce5d1c17e3c9b9b74" +checksum = "3c75a4ce672b27fb8423810efb92a3600027717a1664d06a2c307eeeabcec694" dependencies = [ "arrow", "async-trait", @@ -2187,9 +2187,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ae3d14912c0d779ada98d30dc60f3244f3c26c2446b87394629ea5c076a31c" +checksum = "2c8b9a3795ffb46bf4957a34c67d89a67558b311ae455c8d4295ff2115eeea50" dependencies = [ "arrow", "async-trait", @@ -2210,9 +2210,9 @@ dependencies = [ [[package]] name = "datafusion-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2df29b9592a5d55b8238eaf67d2f21963d5a08cd1a8b7670134405206caabd" +checksum = "205dc1e20441973f470e6b7ef87626a3b9187970e5106058fef1b713047f770c" dependencies = [ "ahash", "arrow", @@ -2233,9 +2233,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42639baa0049d5fffd7e283504b9b5e7b9b2e7a2dea476eed60ab0d40d999b85" +checksum = "8cf5880c02ff6f5f11fb5bc19211789fb32fd3c53d79b7d6cb2b12e401312ba0" dependencies = [ "futures", "log", @@ -2244,9 +2244,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25951b617bb22a9619e1520450590cb2004bfcad10bcb396b961f4a1a10dcec5" +checksum = "bc614d6e709450e29b7b032a42c1bdb705f166a6b2edef7bed7c7897eb905499" dependencies = [ "arrow", "async-trait", @@ -2273,9 +2273,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-arrow" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0b28226960ba99c50d78ac6f736ebe09eb5cb3bb9bb58194266278000ca41f" +checksum = "6e497d5fc48dac7ce86f6b4fb09a3a494385774af301ff20ec91aebfae9b05b4" dependencies = [ "arrow", "arrow-ipc", @@ -2297,9 +2297,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f538b57b052a678b1ce860181c65d3ace5a8486312dc50b41c01dd585a773a51" +checksum = "0dfc250cad940d0327ca2e9109dc98830892d17a3d6b2ca11d68570e872cf379" dependencies = [ "arrow", "async-trait", @@ -2320,9 +2320,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89fbc1d32b1b03c9734e27c0c5f041232b68621c8455f22769838634750a196c" +checksum = "c91e9677ed62833b0e8129dec0d1a8f3c9bb7590bd6dd714a43e4c3b663e4aa0" dependencies = [ "arrow", "async-trait", @@ -2342,9 +2342,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-parquet" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "203271d31fe5613a5943181db70ec98162121d1de94a9a300d5e5f19f9500a32" +checksum = "23798383465e0c569bd442d1453b50691261f8ad6511d840c48457b3bf51ae21" dependencies = [ "arrow", "async-trait", @@ -2372,15 +2372,15 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b6450dc702b3d39e8ced54c3356abb453bd2f3cea86d90d555a4b92f7a38462" +checksum = "3e13e5fe3447baa0584b61ee8644086e007e1ef6e58f4be48bc8a72417854729" [[package]] name = "datafusion-execution" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e66a02fa601de49da5181dbdcf904a18b16a184db2b31f5e5534552ea2d5e660" +checksum = "48a6cc03e34899a54546b229235f7b192634c8e832f78a267f0989b18216c56d" dependencies = [ "arrow", "async-trait", @@ -2399,9 +2399,9 @@ dependencies = [ [[package]] name = "datafusion-expr" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdf59a9b308a1a07dc2eb2f85e6366bc0226dc390b40f3aa0a72d79f1cfe2465" +checksum = "ee3315d87eca7a7df58e52a1fb43b4c4171b545fd30ffc3102945c162a9f6ddb" dependencies = [ "arrow", "async-trait", @@ -2421,9 +2421,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd99eac4c6538c708638db43e7a3bd88e0e57955ddb722d420fb9a6d38dfc28f" +checksum = "98c6d83feae0753799f933a2c47dfd15980c6947960cb95ed60f5c1f885548b3" dependencies = [ "arrow", "datafusion-common", @@ -2434,9 +2434,9 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11aa2c492ac046397b36d57c62a72982aad306495bbcbcdbcabd424d4a2fe245" +checksum = "49b82962015cc3db4d7662459c9f7fcda0591b5edacb8af1cf3bc3031f274800" dependencies = [ "arrow", "arrow-buffer", @@ -2465,9 +2465,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325a00081898945d48d6194d9ca26120e523c993be3bb7c084061a5a2a72e787" +checksum = "4e42c227d9e55a6c8041785d4a8a117e4de531033d480aae10984247ac62e27e" dependencies = [ "ahash", "arrow", @@ -2486,9 +2486,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809bbcb1e0dbec5d0ce30d493d135aea7564f1ba4550395f7f94321223df2dae" +checksum = "cead3cfed825b0b688700f4338d281cd7857e4907775a5b9554c083edd5f3f95" dependencies = [ "ahash", "arrow", @@ -2499,9 +2499,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ebaa5d7024ef45973e0a7db1e9aeaa647936496f4d4061c0448f23d77d6320" +checksum = "62ea99612970aebab8cf864d02eb3d296bbab7f4881e1023d282b57fe431b201" dependencies = [ "arrow", "arrow-ord", @@ -2522,9 +2522,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60eab6f39df9ee49a2c7fa38eddc01fa0086ee31b29c7d19f38e72f479609752" +checksum = "d83dbf3ab8b9af6f209b068825a7adbd3b88bf276f2a1ec14ba09567b97f5674" dependencies = [ "arrow", "async-trait", @@ -2538,9 +2538,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e00b2c15e342a90e65a846199c9e49293dd09fe1bcd63d8be2544604892f7eb8" +checksum = "732edabe07496e2fc5a1e57a284d7a36edcea445a2821119770a0dea624b472c" dependencies = [ "arrow", "datafusion-common", @@ -2556,9 +2556,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "493e2e1d1f4753dfc139a5213f1b5d0b97eea46a82d9bda3c7908aa96981b74b" +checksum = "e0c6e30e09700799bd52adce8c377ab03dda96e73a623e4803a31ad94fe7ce14" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -2566,9 +2566,9 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba01c55ade8278a791b429f7bf5cb1de64de587a342d084b18245edfae7096e2" +checksum = "402f2a8ed70fb99a18f71580a1fe338604222a3d32ddeac6e72c5b34feea2d4d" dependencies = [ "datafusion-doc", "quote", @@ -2577,9 +2577,9 @@ dependencies = [ [[package]] name = "datafusion-optimizer" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a80c6dfbba6a2163a9507f6353ac78c69d8deb26232c9e419160e58ff7c3e047" +checksum = "99f32edb8ba12f08138f86c09b80fae3d4a320551262fa06b91d8a8cb3065a5b" dependencies = [ "arrow", "chrono", @@ -2596,9 +2596,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d3a86264bb9163e7360b6622e789bc7fcbb43672e78a8493f0bc369a41a57c6" +checksum = "987c5e29e96186589301b42e25aa7d11bbe319a73eb02ef8d755edc55b5b89fc" dependencies = [ "ahash", "arrow", @@ -2619,9 +2619,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-adapter" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f5e00e524ac33500be6c5eeac940bd3f6b984ba9b7df0cd5f6c34a8a2cc4d6b" +checksum = "1de89d0afa08b6686697bd8a6bac4ba2cd44c7003356e1bce6114d5a93f94b5c" dependencies = [ "arrow", "datafusion-common", @@ -2634,9 +2634,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae769ea5d688b4e74e9be5cad6f9d9f295b540825355868a3ab942380dd97ce" +checksum = "602d1970c0fe87f1c3a36665d131fbfe1c4379d35f8fc5ec43a362229ad2954d" dependencies = [ "ahash", "arrow", @@ -2651,9 +2651,9 @@ dependencies = [ [[package]] name = "datafusion-physical-optimizer" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3588753ab2b47b0e43cd823fe5e7944df6734dabd6dafb72e2cc1c2a22f1944" +checksum = "b24d704b6385ebe27c756a12e5ba15684576d3b47aeca79cc9fb09480236dc32" dependencies = [ "arrow", "datafusion-common", @@ -2669,9 +2669,9 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79949cbb109c2a45c527bfe0d956b9f2916807c05d4d2e66f3fd0af827ac2b61" +checksum = "c21d94141ea5043e98793f170798e9c1887095813b8291c5260599341e383a38" dependencies = [ "ahash", "arrow", @@ -2700,9 +2700,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6434e2ee8a39d04b95fed688ff34dc251af6e4a0c2e1714716b6e3846690d589" +checksum = "1a68cce43d18c0dfac95cacd74e70565f7e2fb12b9ed41e2d312f0fa837626b1" dependencies = [ "arrow", "datafusion-common", @@ -2717,9 +2717,9 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91efb8302b4877d499c37e9a71886b90236ab27d9cc42fd51112febf341abd6" +checksum = "6b4e1c40a0b1896aed4a4504145c2eb7fa9b9da13c2d04b40a4767a09f076199" dependencies = [ "async-trait", "datafusion-common", @@ -2731,9 +2731,9 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f01eef7bcf4d00e87305b55f1b75792384e130fe0258bac02cd48378ae5ff87" +checksum = "2f1891e5b106d1d73c7fe403bd8a265d19c3977edc17f60808daf26c2fe65ffb" dependencies = [ "arrow", "bigdecimal", @@ -2748,9 +2748,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe00df31ca03a167d3e40054120930fe5fb689e66bc625b602fac7153b222aea" +checksum = "2379388ecab67079eeb1185c953fb9c5ed4b283fa3cb81417538378a30545957" dependencies = [ "async-recursion", "async-trait", @@ -2953,7 +2953,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3160,7 +3160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3360,7 +3360,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "lance-datagen", @@ -4389,7 +4389,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4484,7 +4484,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4579,7 +4579,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "all_asserts", "approx", @@ -4675,7 +4675,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4711,7 +4711,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrayref", "paste", @@ -4720,7 +4720,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4760,7 +4760,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4794,7 +4794,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4814,7 +4814,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4861,7 +4861,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "all_asserts", "arrow", @@ -4887,7 +4887,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -4944,7 +4944,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "approx", "arrow", @@ -5025,7 +5025,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "approx", "arrow-array", @@ -5094,7 +5094,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -5109,7 +5109,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -5127,7 +5127,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -5185,7 +5185,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -5231,7 +5231,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "proc-macro2", "quote", @@ -5240,7 +5240,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "arrow-schema", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "clap", "lance-core", @@ -5820,9 +5820,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.14" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", @@ -5940,7 +5940,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7579,7 +7579,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8044,7 +8044,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8503,7 +8503,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9467,7 +9467,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7f88edf3eda..e3136449962 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.1.0-beta.0", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.1.0-beta.0", path = "./rust/lance-arrow" } -lance-core = { version = "=4.1.0-beta.0", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.1.0-beta.0", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.1.0-beta.0", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.1.0-beta.0", path = "./rust/lance-encoding" } -lance-file = { version = "=4.1.0-beta.0", path = "./rust/lance-file" } -lance-geo = { version = "=4.1.0-beta.0", path = "./rust/lance-geo" } -lance-index = { version = "=4.1.0-beta.0", path = "./rust/lance-index" } -lance-io = { version = "=4.1.0-beta.0", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.1.0-beta.0", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.1.0-beta.0", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.1.0-beta.0", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.1.0-beta.0", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=4.1.0-beta.1", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=4.1.0-beta.1", path = "./rust/lance-arrow" } +lance-core = { version = "=4.1.0-beta.1", path = "./rust/lance-core" } +lance-datafusion = { version = "=4.1.0-beta.1", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=4.1.0-beta.1", path = "./rust/lance-datagen" } +lance-encoding = { version = "=4.1.0-beta.1", path = "./rust/lance-encoding" } +lance-file = { version = "=4.1.0-beta.1", path = "./rust/lance-file" } +lance-geo = { version = "=4.1.0-beta.1", path = "./rust/lance-geo" } +lance-index = { version = "=4.1.0-beta.1", path = "./rust/lance-index" } +lance-io = { version = "=4.1.0-beta.1", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=4.1.0-beta.1", path = "./rust/lance-linalg" } +lance-namespace = { version = "=4.1.0-beta.1", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=4.1.0-beta.1", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=4.1.0-beta.1", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=4.1.0-beta.0", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.1.0-beta.0", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.1.0-beta.0", path = "./rust/lance-testing" } +lance-table = { version = "=4.1.0-beta.1", path = "./rust/lance-table" } +lance-test-macros = { version = "=4.1.0-beta.1", path = "./rust/lance-test-macros" } +lance-testing = { version = "=4.1.0-beta.1", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.1.0-beta.0", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=4.1.0-beta.1", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.1.0-beta.0", path = "./rust/compression/fsst" } +fsst = { version = "=4.1.0-beta.1", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index c67bcf496c1..61bcfc2d04e 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -139,9 +139,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -1429,9 +1429,9 @@ dependencies = [ [[package]] name = "datafusion" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea28305c211e3541c9cfcf06a23d0d8c7c824b4502ed1fdf0a6ff4ad24ee531c" +checksum = "43c18ba387f9c05ac1f3be32a73f8f3cc6c1cfc43e5d4b7a8e5b0d3a5eb48dc7" dependencies = [ "arrow", "arrow-schema", @@ -1480,9 +1480,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ab99b6df5f60a6ddbc515e4c05caee1192d395cf3cb67ce5d1c17e3c9b9b74" +checksum = "3c75a4ce672b27fb8423810efb92a3600027717a1664d06a2c307eeeabcec694" dependencies = [ "arrow", "async-trait", @@ -1505,9 +1505,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ae3d14912c0d779ada98d30dc60f3244f3c26c2446b87394629ea5c076a31c" +checksum = "2c8b9a3795ffb46bf4957a34c67d89a67558b311ae455c8d4295ff2115eeea50" dependencies = [ "arrow", "async-trait", @@ -1528,9 +1528,9 @@ dependencies = [ [[package]] name = "datafusion-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2df29b9592a5d55b8238eaf67d2f21963d5a08cd1a8b7670134405206caabd" +checksum = "205dc1e20441973f470e6b7ef87626a3b9187970e5106058fef1b713047f770c" dependencies = [ "ahash", "arrow", @@ -1551,9 +1551,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42639baa0049d5fffd7e283504b9b5e7b9b2e7a2dea476eed60ab0d40d999b85" +checksum = "8cf5880c02ff6f5f11fb5bc19211789fb32fd3c53d79b7d6cb2b12e401312ba0" dependencies = [ "futures", "log", @@ -1562,9 +1562,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25951b617bb22a9619e1520450590cb2004bfcad10bcb396b961f4a1a10dcec5" +checksum = "bc614d6e709450e29b7b032a42c1bdb705f166a6b2edef7bed7c7897eb905499" dependencies = [ "arrow", "async-trait", @@ -1591,9 +1591,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-arrow" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0b28226960ba99c50d78ac6f736ebe09eb5cb3bb9bb58194266278000ca41f" +checksum = "6e497d5fc48dac7ce86f6b4fb09a3a494385774af301ff20ec91aebfae9b05b4" dependencies = [ "arrow", "arrow-ipc", @@ -1615,9 +1615,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f538b57b052a678b1ce860181c65d3ace5a8486312dc50b41c01dd585a773a51" +checksum = "0dfc250cad940d0327ca2e9109dc98830892d17a3d6b2ca11d68570e872cf379" dependencies = [ "arrow", "async-trait", @@ -1638,9 +1638,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89fbc1d32b1b03c9734e27c0c5f041232b68621c8455f22769838634750a196c" +checksum = "c91e9677ed62833b0e8129dec0d1a8f3c9bb7590bd6dd714a43e4c3b663e4aa0" dependencies = [ "arrow", "async-trait", @@ -1660,9 +1660,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-parquet" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "203271d31fe5613a5943181db70ec98162121d1de94a9a300d5e5f19f9500a32" +checksum = "23798383465e0c569bd442d1453b50691261f8ad6511d840c48457b3bf51ae21" dependencies = [ "arrow", "async-trait", @@ -1690,15 +1690,15 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b6450dc702b3d39e8ced54c3356abb453bd2f3cea86d90d555a4b92f7a38462" +checksum = "3e13e5fe3447baa0584b61ee8644086e007e1ef6e58f4be48bc8a72417854729" [[package]] name = "datafusion-execution" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e66a02fa601de49da5181dbdcf904a18b16a184db2b31f5e5534552ea2d5e660" +checksum = "48a6cc03e34899a54546b229235f7b192634c8e832f78a267f0989b18216c56d" dependencies = [ "arrow", "async-trait", @@ -1717,9 +1717,9 @@ dependencies = [ [[package]] name = "datafusion-expr" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdf59a9b308a1a07dc2eb2f85e6366bc0226dc390b40f3aa0a72d79f1cfe2465" +checksum = "ee3315d87eca7a7df58e52a1fb43b4c4171b545fd30ffc3102945c162a9f6ddb" dependencies = [ "arrow", "async-trait", @@ -1739,9 +1739,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd99eac4c6538c708638db43e7a3bd88e0e57955ddb722d420fb9a6d38dfc28f" +checksum = "98c6d83feae0753799f933a2c47dfd15980c6947960cb95ed60f5c1f885548b3" dependencies = [ "arrow", "datafusion-common", @@ -1752,9 +1752,9 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11aa2c492ac046397b36d57c62a72982aad306495bbcbcdbcabd424d4a2fe245" +checksum = "49b82962015cc3db4d7662459c9f7fcda0591b5edacb8af1cf3bc3031f274800" dependencies = [ "arrow", "arrow-buffer", @@ -1783,9 +1783,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325a00081898945d48d6194d9ca26120e523c993be3bb7c084061a5a2a72e787" +checksum = "4e42c227d9e55a6c8041785d4a8a117e4de531033d480aae10984247ac62e27e" dependencies = [ "ahash", "arrow", @@ -1804,9 +1804,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809bbcb1e0dbec5d0ce30d493d135aea7564f1ba4550395f7f94321223df2dae" +checksum = "cead3cfed825b0b688700f4338d281cd7857e4907775a5b9554c083edd5f3f95" dependencies = [ "ahash", "arrow", @@ -1817,9 +1817,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ebaa5d7024ef45973e0a7db1e9aeaa647936496f4d4061c0448f23d77d6320" +checksum = "62ea99612970aebab8cf864d02eb3d296bbab7f4881e1023d282b57fe431b201" dependencies = [ "arrow", "arrow-ord", @@ -1840,9 +1840,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60eab6f39df9ee49a2c7fa38eddc01fa0086ee31b29c7d19f38e72f479609752" +checksum = "d83dbf3ab8b9af6f209b068825a7adbd3b88bf276f2a1ec14ba09567b97f5674" dependencies = [ "arrow", "async-trait", @@ -1856,9 +1856,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e00b2c15e342a90e65a846199c9e49293dd09fe1bcd63d8be2544604892f7eb8" +checksum = "732edabe07496e2fc5a1e57a284d7a36edcea445a2821119770a0dea624b472c" dependencies = [ "arrow", "datafusion-common", @@ -1874,9 +1874,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "493e2e1d1f4753dfc139a5213f1b5d0b97eea46a82d9bda3c7908aa96981b74b" +checksum = "e0c6e30e09700799bd52adce8c377ab03dda96e73a623e4803a31ad94fe7ce14" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -1884,9 +1884,9 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba01c55ade8278a791b429f7bf5cb1de64de587a342d084b18245edfae7096e2" +checksum = "402f2a8ed70fb99a18f71580a1fe338604222a3d32ddeac6e72c5b34feea2d4d" dependencies = [ "datafusion-doc", "quote", @@ -1895,9 +1895,9 @@ dependencies = [ [[package]] name = "datafusion-optimizer" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a80c6dfbba6a2163a9507f6353ac78c69d8deb26232c9e419160e58ff7c3e047" +checksum = "99f32edb8ba12f08138f86c09b80fae3d4a320551262fa06b91d8a8cb3065a5b" dependencies = [ "arrow", "chrono", @@ -1914,9 +1914,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d3a86264bb9163e7360b6622e789bc7fcbb43672e78a8493f0bc369a41a57c6" +checksum = "987c5e29e96186589301b42e25aa7d11bbe319a73eb02ef8d755edc55b5b89fc" dependencies = [ "ahash", "arrow", @@ -1937,9 +1937,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-adapter" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f5e00e524ac33500be6c5eeac940bd3f6b984ba9b7df0cd5f6c34a8a2cc4d6b" +checksum = "1de89d0afa08b6686697bd8a6bac4ba2cd44c7003356e1bce6114d5a93f94b5c" dependencies = [ "arrow", "datafusion-common", @@ -1952,9 +1952,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae769ea5d688b4e74e9be5cad6f9d9f295b540825355868a3ab942380dd97ce" +checksum = "602d1970c0fe87f1c3a36665d131fbfe1c4379d35f8fc5ec43a362229ad2954d" dependencies = [ "ahash", "arrow", @@ -1969,9 +1969,9 @@ dependencies = [ [[package]] name = "datafusion-physical-optimizer" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3588753ab2b47b0e43cd823fe5e7944df6734dabd6dafb72e2cc1c2a22f1944" +checksum = "b24d704b6385ebe27c756a12e5ba15684576d3b47aeca79cc9fb09480236dc32" dependencies = [ "arrow", "datafusion-common", @@ -1987,9 +1987,9 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79949cbb109c2a45c527bfe0d956b9f2916807c05d4d2e66f3fd0af827ac2b61" +checksum = "c21d94141ea5043e98793f170798e9c1887095813b8291c5260599341e383a38" dependencies = [ "ahash", "arrow", @@ -2018,9 +2018,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6434e2ee8a39d04b95fed688ff34dc251af6e4a0c2e1714716b6e3846690d589" +checksum = "1a68cce43d18c0dfac95cacd74e70565f7e2fb12b9ed41e2d312f0fa837626b1" dependencies = [ "arrow", "datafusion-common", @@ -2035,9 +2035,9 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91efb8302b4877d499c37e9a71886b90236ab27d9cc42fd51112febf341abd6" +checksum = "6b4e1c40a0b1896aed4a4504145c2eb7fa9b9da13c2d04b40a4767a09f076199" dependencies = [ "async-trait", "datafusion-common", @@ -2049,9 +2049,9 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f01eef7bcf4d00e87305b55f1b75792384e130fe0258bac02cd48378ae5ff87" +checksum = "2f1891e5b106d1d73c7fe403bd8a265d19c3977edc17f60808daf26c2fe65ffb" dependencies = [ "arrow", "bigdecimal", @@ -2066,9 +2066,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe00df31ca03a167d3e40054120930fe5fb689e66bc625b602fac7153b222aea" +checksum = "2379388ecab67079eeb1185c953fb9c5ed4b283fa3cb81417538378a30545957" dependencies = [ "async-recursion", "async-trait", @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3229,9 +3229,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" @@ -3283,7 +3283,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -3292,9 +3292,31 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "jobserver" @@ -3353,7 +3375,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3419,7 +3441,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3439,7 +3461,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrayref", "paste", @@ -3448,7 +3470,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3485,7 +3507,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3518,7 +3540,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3536,7 +3558,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -3573,7 +3595,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -3605,7 +3627,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -3619,7 +3641,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3687,7 +3709,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3728,7 +3750,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-schema", @@ -3762,7 +3784,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3778,7 +3800,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -3791,7 +3813,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -3835,7 +3857,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4177,9 +4199,9 @@ checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" [[package]] name = "moka" -version = "0.12.14" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", @@ -5445,9 +5467,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 99883d00d8e..cd269e31a77 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 3d62ad35aa3..9217211476d 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.1.0-beta.0 + 4.1.0-beta.1 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index ba200e2bdf7..76fc648275a 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -1659,9 +1659,9 @@ dependencies = [ [[package]] name = "datafusion" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea28305c211e3541c9cfcf06a23d0d8c7c824b4502ed1fdf0a6ff4ad24ee531c" +checksum = "43c18ba387f9c05ac1f3be32a73f8f3cc6c1cfc43e5d4b7a8e5b0d3a5eb48dc7" dependencies = [ "arrow", "arrow-schema", @@ -1714,9 +1714,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ab99b6df5f60a6ddbc515e4c05caee1192d395cf3cb67ce5d1c17e3c9b9b74" +checksum = "3c75a4ce672b27fb8423810efb92a3600027717a1664d06a2c307eeeabcec694" dependencies = [ "arrow", "async-trait", @@ -1739,9 +1739,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ae3d14912c0d779ada98d30dc60f3244f3c26c2446b87394629ea5c076a31c" +checksum = "2c8b9a3795ffb46bf4957a34c67d89a67558b311ae455c8d4295ff2115eeea50" dependencies = [ "arrow", "async-trait", @@ -1762,9 +1762,9 @@ dependencies = [ [[package]] name = "datafusion-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2df29b9592a5d55b8238eaf67d2f21963d5a08cd1a8b7670134405206caabd" +checksum = "205dc1e20441973f470e6b7ef87626a3b9187970e5106058fef1b713047f770c" dependencies = [ "ahash", "arrow", @@ -1786,9 +1786,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42639baa0049d5fffd7e283504b9b5e7b9b2e7a2dea476eed60ab0d40d999b85" +checksum = "8cf5880c02ff6f5f11fb5bc19211789fb32fd3c53d79b7d6cb2b12e401312ba0" dependencies = [ "futures", "log", @@ -1797,9 +1797,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25951b617bb22a9619e1520450590cb2004bfcad10bcb396b961f4a1a10dcec5" +checksum = "bc614d6e709450e29b7b032a42c1bdb705f166a6b2edef7bed7c7897eb905499" dependencies = [ "arrow", "async-compression", @@ -1832,9 +1832,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-arrow" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0b28226960ba99c50d78ac6f736ebe09eb5cb3bb9bb58194266278000ca41f" +checksum = "6e497d5fc48dac7ce86f6b4fb09a3a494385774af301ff20ec91aebfae9b05b4" dependencies = [ "arrow", "arrow-ipc", @@ -1856,9 +1856,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f538b57b052a678b1ce860181c65d3ace5a8486312dc50b41c01dd585a773a51" +checksum = "0dfc250cad940d0327ca2e9109dc98830892d17a3d6b2ca11d68570e872cf379" dependencies = [ "arrow", "async-trait", @@ -1879,9 +1879,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89fbc1d32b1b03c9734e27c0c5f041232b68621c8455f22769838634750a196c" +checksum = "c91e9677ed62833b0e8129dec0d1a8f3c9bb7590bd6dd714a43e4c3b663e4aa0" dependencies = [ "arrow", "async-trait", @@ -1901,9 +1901,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-parquet" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "203271d31fe5613a5943181db70ec98162121d1de94a9a300d5e5f19f9500a32" +checksum = "23798383465e0c569bd442d1453b50691261f8ad6511d840c48457b3bf51ae21" dependencies = [ "arrow", "async-trait", @@ -1931,15 +1931,15 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b6450dc702b3d39e8ced54c3356abb453bd2f3cea86d90d555a4b92f7a38462" +checksum = "3e13e5fe3447baa0584b61ee8644086e007e1ef6e58f4be48bc8a72417854729" [[package]] name = "datafusion-execution" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e66a02fa601de49da5181dbdcf904a18b16a184db2b31f5e5534552ea2d5e660" +checksum = "48a6cc03e34899a54546b229235f7b192634c8e832f78a267f0989b18216c56d" dependencies = [ "arrow", "async-trait", @@ -1958,9 +1958,9 @@ dependencies = [ [[package]] name = "datafusion-expr" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdf59a9b308a1a07dc2eb2f85e6366bc0226dc390b40f3aa0a72d79f1cfe2465" +checksum = "ee3315d87eca7a7df58e52a1fb43b4c4171b545fd30ffc3102945c162a9f6ddb" dependencies = [ "arrow", "async-trait", @@ -1981,9 +1981,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd99eac4c6538c708638db43e7a3bd88e0e57955ddb722d420fb9a6d38dfc28f" +checksum = "98c6d83feae0753799f933a2c47dfd15980c6947960cb95ed60f5c1f885548b3" dependencies = [ "arrow", "datafusion-common", @@ -1994,9 +1994,9 @@ dependencies = [ [[package]] name = "datafusion-ffi" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b7b2b314f996ff30a2c695d74ec8cf9299305ca774269b56caed08652be7b5" +checksum = "ae9f6e64d9d7fc21e47dbe6d0494dc8000dce57934d991ee1518da1648c712ca" dependencies = [ "abi_stable", "arrow", @@ -2024,9 +2024,9 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11aa2c492ac046397b36d57c62a72982aad306495bbcbcdbcabd424d4a2fe245" +checksum = "49b82962015cc3db4d7662459c9f7fcda0591b5edacb8af1cf3bc3031f274800" dependencies = [ "arrow", "arrow-buffer", @@ -2055,9 +2055,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325a00081898945d48d6194d9ca26120e523c993be3bb7c084061a5a2a72e787" +checksum = "4e42c227d9e55a6c8041785d4a8a117e4de531033d480aae10984247ac62e27e" dependencies = [ "ahash", "arrow", @@ -2076,9 +2076,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809bbcb1e0dbec5d0ce30d493d135aea7564f1ba4550395f7f94321223df2dae" +checksum = "cead3cfed825b0b688700f4338d281cd7857e4907775a5b9554c083edd5f3f95" dependencies = [ "ahash", "arrow", @@ -2089,9 +2089,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ebaa5d7024ef45973e0a7db1e9aeaa647936496f4d4061c0448f23d77d6320" +checksum = "62ea99612970aebab8cf864d02eb3d296bbab7f4881e1023d282b57fe431b201" dependencies = [ "arrow", "arrow-ord", @@ -2112,9 +2112,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60eab6f39df9ee49a2c7fa38eddc01fa0086ee31b29c7d19f38e72f479609752" +checksum = "d83dbf3ab8b9af6f209b068825a7adbd3b88bf276f2a1ec14ba09567b97f5674" dependencies = [ "arrow", "async-trait", @@ -2128,9 +2128,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e00b2c15e342a90e65a846199c9e49293dd09fe1bcd63d8be2544604892f7eb8" +checksum = "732edabe07496e2fc5a1e57a284d7a36edcea445a2821119770a0dea624b472c" dependencies = [ "arrow", "datafusion-common", @@ -2146,9 +2146,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "493e2e1d1f4753dfc139a5213f1b5d0b97eea46a82d9bda3c7908aa96981b74b" +checksum = "e0c6e30e09700799bd52adce8c377ab03dda96e73a623e4803a31ad94fe7ce14" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -2156,9 +2156,9 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba01c55ade8278a791b429f7bf5cb1de64de587a342d084b18245edfae7096e2" +checksum = "402f2a8ed70fb99a18f71580a1fe338604222a3d32ddeac6e72c5b34feea2d4d" dependencies = [ "datafusion-doc", "quote", @@ -2167,9 +2167,9 @@ dependencies = [ [[package]] name = "datafusion-optimizer" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a80c6dfbba6a2163a9507f6353ac78c69d8deb26232c9e419160e58ff7c3e047" +checksum = "99f32edb8ba12f08138f86c09b80fae3d4a320551262fa06b91d8a8cb3065a5b" dependencies = [ "arrow", "chrono", @@ -2187,9 +2187,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d3a86264bb9163e7360b6622e789bc7fcbb43672e78a8493f0bc369a41a57c6" +checksum = "987c5e29e96186589301b42e25aa7d11bbe319a73eb02ef8d755edc55b5b89fc" dependencies = [ "ahash", "arrow", @@ -2211,9 +2211,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-adapter" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f5e00e524ac33500be6c5eeac940bd3f6b984ba9b7df0cd5f6c34a8a2cc4d6b" +checksum = "1de89d0afa08b6686697bd8a6bac4ba2cd44c7003356e1bce6114d5a93f94b5c" dependencies = [ "arrow", "datafusion-common", @@ -2226,9 +2226,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae769ea5d688b4e74e9be5cad6f9d9f295b540825355868a3ab942380dd97ce" +checksum = "602d1970c0fe87f1c3a36665d131fbfe1c4379d35f8fc5ec43a362229ad2954d" dependencies = [ "ahash", "arrow", @@ -2243,9 +2243,9 @@ dependencies = [ [[package]] name = "datafusion-physical-optimizer" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3588753ab2b47b0e43cd823fe5e7944df6734dabd6dafb72e2cc1c2a22f1944" +checksum = "b24d704b6385ebe27c756a12e5ba15684576d3b47aeca79cc9fb09480236dc32" dependencies = [ "arrow", "datafusion-common", @@ -2262,9 +2262,9 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79949cbb109c2a45c527bfe0d956b9f2916807c05d4d2e66f3fd0af827ac2b61" +checksum = "c21d94141ea5043e98793f170798e9c1887095813b8291c5260599341e383a38" dependencies = [ "ahash", "arrow", @@ -2293,9 +2293,9 @@ dependencies = [ [[package]] name = "datafusion-proto" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51eb50848366adf7ff2734e767442916bb45b7e739d5edbf33e9e4c47c5fa797" +checksum = "d5e139c4259ccfd12e9f786172ebdf26245c041f7a40ddd0e7651d29da0fd249" dependencies = [ "arrow", "chrono", @@ -2320,9 +2320,9 @@ dependencies = [ [[package]] name = "datafusion-proto-common" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16bf2f2a9f99c48b61537870736f0aa9500c4128407c9999261ff3e8a170523" +checksum = "5ea6437aecb636b0ea67c6a09feb68d20aaab163402acfa73173a61d78e15110" dependencies = [ "arrow", "datafusion-common", @@ -2331,9 +2331,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6434e2ee8a39d04b95fed688ff34dc251af6e4a0c2e1714716b6e3846690d589" +checksum = "1a68cce43d18c0dfac95cacd74e70565f7e2fb12b9ed41e2d312f0fa837626b1" dependencies = [ "arrow", "datafusion-common", @@ -2348,9 +2348,9 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91efb8302b4877d499c37e9a71886b90236ab27d9cc42fd51112febf341abd6" +checksum = "6b4e1c40a0b1896aed4a4504145c2eb7fa9b9da13c2d04b40a4767a09f076199" dependencies = [ "async-trait", "datafusion-common", @@ -2362,9 +2362,9 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f01eef7bcf4d00e87305b55f1b75792384e130fe0258bac02cd48378ae5ff87" +checksum = "2f1891e5b106d1d73c7fe403bd8a265d19c3977edc17f60808daf26c2fe65ffb" dependencies = [ "arrow", "bigdecimal", @@ -2380,9 +2380,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "52.3.0" +version = "52.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe00df31ca03a167d3e40054120930fe5fb689e66bc625b602fac7153b222aea" +checksum = "2379388ecab67079eeb1185c953fb9c5ed4b283fa3cb81417538378a30545957" dependencies = [ "async-recursion", "async-trait", @@ -2818,7 +2818,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3843,7 +3843,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3910,7 +3910,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3930,7 +3930,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrayref", "paste", @@ -3939,7 +3939,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3976,7 +3976,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4009,7 +4009,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4027,7 +4027,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4064,7 +4064,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -4110,7 +4110,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -4222,7 +4222,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4238,7 +4238,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -4295,7 +4295,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4852,9 +4852,9 @@ checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" [[package]] name = "moka" -version = "0.12.14" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", @@ -5659,7 +5659,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" dependencies = [ "arrow", "arrow-array", diff --git a/python/Cargo.toml b/python/Cargo.toml index 51f1f6646cb..71c53ec3183 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.1.0-beta.0" +version = "4.1.0-beta.1" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 727a45c2206a5bb71edb61adc6fae3519764cdf2 Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Mon, 23 Mar 2026 09:50:30 -0500 Subject: [PATCH 062/206] fix: adding namespace support to java SDK CommitBuilder from dataset (#6257) Currently, the namespace / tableID settings are not available when initializing a CommitBuilder through the Java SDK (although they are from the URI variant). This is problematic in scenarios where we need to update the CommitHandler functionality. For example, when managed versioning is disabled on a REST namespace implementation we override the CommitHandler to commit directly to object store rather than route through the REST namespace. --- java/lance-jni/src/blocking_dataset.rs | 4 + java/lance-jni/src/transaction.rs | 16 ++ .../main/java/org/lance/CommitBuilder.java | 12 +- .../namespace/DirectoryNamespaceTest.java | 147 ++++++++++++++++++ 4 files changed, 176 insertions(+), 3 deletions(-) diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index fe47d30c239..ff2c261ebdd 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -341,6 +341,7 @@ impl BlockingDataset { storage_format: Option, max_retries: u32, skip_auto_cleanup: bool, + commit_handler: Option>, ) -> Result { let mut builder = CommitBuilder::new(Arc::new(self.clone().inner)) .with_store_params(store_params) @@ -358,6 +359,9 @@ impl BlockingDataset { if skip_auto_cleanup { builder = builder.with_skip_auto_cleanup(true); } + if let Some(handler) = commit_handler { + builder = builder.with_commit_handler(handler); + } let new_dataset = RT.block_on(builder.execute(transaction))?; Ok(BlockingDataset { inner: new_dataset }) } diff --git a/java/lance-jni/src/transaction.rs b/java/lance-jni/src/transaction.rs index d2f5a391bb3..cdc5975bbfd 100644 --- a/java/lance-jni/src/transaction.rs +++ b/java/lance-jni/src/transaction.rs @@ -623,6 +623,8 @@ pub extern "system" fn Java_org_lance_CommitBuilder_nativeCommitToDataset<'local storage_format_obj: JObject, max_retries: jint, skip_auto_cleanup: jboolean, + namespace_obj: JObject, + table_id_obj: JObject, ) -> JObject<'local> { ok_or_throw!( env, @@ -637,6 +639,8 @@ pub extern "system" fn Java_org_lance_CommitBuilder_nativeCommitToDataset<'local storage_format_obj, max_retries as u32, skip_auto_cleanup != 0, + namespace_obj, + table_id_obj, ) ) } @@ -653,6 +657,8 @@ fn inner_commit_to_dataset<'local>( storage_format_obj: JObject, max_retries: u32, skip_auto_cleanup: bool, + namespace_obj: JObject, + table_id_obj: JObject, ) -> Result> { let write_param = if write_params_obj.is_null() { HashMap::new() @@ -745,6 +751,15 @@ fn inner_commit_to_dataset<'local>( Some(&mut java_blocking_ds), )?; + // Set namespace commit handler if provided + let namespace_info = extract_namespace_info(env, &namespace_obj, &table_id_obj)?; + let commit_handler = namespace_info.map(|(ns, tid)| { + let external_store = LanceNamespaceExternalManifestStore::new(ns, tid); + Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }) as Arc + }); + let new_blocking_ds = { let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(&java_dataset, NATIVE_DATASET) }?; @@ -757,6 +772,7 @@ fn inner_commit_to_dataset<'local>( storage_format, max_retries, skip_auto_cleanup, + commit_handler, )? }; new_blocking_ds.into_java(env) diff --git a/java/src/main/java/org/lance/CommitBuilder.java b/java/src/main/java/org/lance/CommitBuilder.java index 0b2752aae89..c56f1ab5631 100644 --- a/java/src/main/java/org/lance/CommitBuilder.java +++ b/java/src/main/java/org/lance/CommitBuilder.java @@ -124,7 +124,9 @@ public CommitBuilder storageOptionsProvider(StorageOptionsProvider provider) { } /** - * Set the namespace for managed versioning during URI-based commits. + * Set the namespace for managed versioning. When set, commits are routed through the namespace's + * {@code createTableVersion} API instead of writing directly to the object store. This is + * supported for both dataset-based and URI-based commits. * * @param namespace the LanceNamespace instance * @return this builder instance @@ -250,7 +252,9 @@ public Dataset execute(Transaction transaction) { useStableRowIds, storageFormat, maxRetries, - skipAutoCleanup); + skipAutoCleanup, + namespace, + tableId); result.setAllocator(dataset.allocator()); return result; } @@ -285,7 +289,9 @@ private static native Dataset nativeCommitToDataset( Boolean useStableRowIds, String storageFormat, int maxRetries, - boolean skipAutoCleanup); + boolean skipAutoCleanup, + Object namespace, + Object tableId); private static native Dataset nativeCommitToUri( String uri, diff --git a/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java b/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java index adfb2bd0022..fa1fa8200d6 100644 --- a/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java +++ b/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java @@ -13,12 +13,17 @@ */ package org.lance.namespace; +import org.lance.CommitBuilder; import org.lance.Dataset; +import org.lance.Fragment; +import org.lance.FragmentMetadata; import org.lance.ReadOptions; +import org.lance.Transaction; import org.lance.WriteParams; import org.lance.namespace.model.*; import org.lance.namespace.model.DescribeTableVersionRequest; import org.lance.namespace.model.DescribeTableVersionResponse; +import org.lance.operation.Append; import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; @@ -673,6 +678,148 @@ public VectorSchemaRoot getVectorSchemaRoot() { } } + @Test + void testDatasetBasedCommitBuilderWithNamespace(@TempDir Path managedVersioningTempDir) + throws Exception { + try (BufferAllocator allocator = new RootAllocator()) { + TableVersionTrackingNamespace namespace = + new TableVersionTrackingNamespace(managedVersioningTempDir); + String tableName = "test_table"; + List tableId = Arrays.asList(tableName); + + Schema schema = + new Schema( + Arrays.asList( + new Field("id", FieldType.nullable(new ArrowType.Int(32, true)), null), + new Field("name", FieldType.nullable(new ArrowType.Utf8()), null))); + + // Create initial dataset through namespace using WriteDatasetBuilder + try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + IntVector idVector = (IntVector) root.getVector("id"); + VarCharVector nameVector = (VarCharVector) root.getVector("name"); + + idVector.allocateNew(2); + nameVector.allocateNew(2); + idVector.set(0, 1); + idVector.set(1, 2); + nameVector.set(0, "Alice".getBytes()); + nameVector.set(1, "Bob".getBytes()); + idVector.setValueCount(2); + nameVector.setValueCount(2); + root.setRowCount(2); + + ArrowReader reader = + new ArrowReader(allocator) { + boolean firstRead = true; + + @Override + public boolean loadNextBatch() { + if (firstRead) { + firstRead = false; + return true; + } + return false; + } + + @Override + public long bytesRead() { + return 0; + } + + @Override + protected void closeReadSource() {} + + @Override + protected Schema readSchema() { + return schema; + } + + @Override + public VectorSchemaRoot getVectorSchemaRoot() { + return root; + } + }; + + try (Dataset dataset = + Dataset.write() + .allocator(allocator) + .reader(reader) + .namespace(namespace) + .tableId(tableId) + .mode(WriteParams.WriteMode.CREATE) + .execute()) { + assertEquals(2, dataset.countRows()); + assertEquals(1, dataset.version()); + } + } + + // Verify initial create used createTableVersion once + assertEquals( + 1, + namespace.getCreateTableVersionCount(), + "create_table_version should be called once during CREATE"); + + // Open dataset through namespace (returns dataset with managed versioning) + Dataset existingDataset = + Dataset.open().allocator(allocator).namespace(namespace).tableId(tableId).build(); + + // Get the dataset URI for Fragment.create() + String datasetUri = existingDataset.uri(); + + // Create a new fragment independently (simulating Spark worker behavior) + List fragments; + try (VectorSchemaRoot appendRoot = VectorSchemaRoot.create(schema, allocator)) { + IntVector idVector = (IntVector) appendRoot.getVector("id"); + VarCharVector nameVector = (VarCharVector) appendRoot.getVector("name"); + + idVector.allocateNew(2); + nameVector.allocateNew(2); + idVector.set(0, 3); + idVector.set(1, 4); + nameVector.set(0, "Charlie".getBytes()); + nameVector.set(1, "Diana".getBytes()); + idVector.setValueCount(2); + nameVector.setValueCount(2); + appendRoot.setRowCount(2); + + fragments = + Fragment.create(datasetUri, allocator, appendRoot, new WriteParams.Builder().build()); + } + + // Commit using dataset-based CommitBuilder WITH namespace (the new path) + int createCountBefore = namespace.getCreateTableVersionCount(); + try (Transaction txn = + new Transaction.Builder() + .readVersion(existingDataset.version()) + .operation(Append.builder().fragments(fragments).build()) + .build(); + Dataset committed = + new CommitBuilder(existingDataset) + .namespace(namespace) + .tableId(tableId) + .execute(txn)) { + assertEquals(2, committed.version()); + assertEquals(4, committed.countRows()); + } + + // Verify createTableVersion was called for the dataset-based commit + assertEquals( + createCountBefore + 1, + namespace.getCreateTableVersionCount(), + "create_table_version should be called for dataset-based CommitBuilder with namespace"); + + // Verify the data is accessible through namespace + try (Dataset latestDs = + Dataset.open().allocator(allocator).namespace(namespace).tableId(tableId).build()) { + assertEquals(4, latestDs.countRows()); + assertEquals(2, latestDs.version()); + } + + existingDataset.close(); + namespace.close(); + } + } + @Test void testConcurrentCreateAndDropWithSingleInstance() throws Exception { // Initialize namespace first - create parent namespace to ensure __manifest table From 0302473c730f30b80a40561f90237361ff103610 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Mon, 23 Mar 2026 14:56:05 +0000 Subject: [PATCH 063/206] chore: release beta version 4.1.0-beta.2 --- .bumpversion.toml | 2 +- Cargo.lock | 54 +++++++++++++++++++-------------------- Cargo.toml | 40 ++++++++++++++--------------- java/lance-jni/Cargo.lock | 38 +++++++++++++-------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 38 +++++++++++++-------------- python/Cargo.toml | 2 +- 8 files changed, 89 insertions(+), 89 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 87d1247bafe..d01802f0a88 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.1.0-beta.1" +current_version = "4.1.0-beta.2" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 1be2a23aebf..856e44e38c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3360,7 +3360,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "lance-datagen", @@ -4373,9 +4373,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -4579,7 +4579,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "all_asserts", "approx", @@ -4675,7 +4675,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -4711,7 +4711,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrayref", "paste", @@ -4720,7 +4720,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -4760,7 +4760,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4794,7 +4794,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4814,7 +4814,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4861,7 +4861,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "all_asserts", "arrow", @@ -4887,7 +4887,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "datafusion", "geo-traits", @@ -4944,7 +4944,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "approx", "arrow", @@ -5025,7 +5025,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "approx", "arrow-array", @@ -5094,7 +5094,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "async-trait", @@ -5109,7 +5109,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -5127,7 +5127,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-ipc", @@ -5185,7 +5185,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -5231,7 +5231,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "proc-macro2", "quote", @@ -5240,7 +5240,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "arrow-schema", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "clap", "lance-core", @@ -8240,9 +8240,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symbolic-common" -version = "12.17.2" +version = "12.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "751a2823d606b5d0a7616499e4130a516ebd01a44f39811be2b9600936509c23" +checksum = "52ca086c1eb5c7ee74b151ba83c6487d5d33f8c08ad991b86f3f58f6629e68d5" dependencies = [ "debugid", "memmap2", @@ -8252,9 +8252,9 @@ dependencies = [ [[package]] name = "symbolic-demangle" -version = "12.17.2" +version = "12.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b237cfbe320601dd24b4ac817a5b68bb28f5508e33f08d42be0682cadc8ac9" +checksum = "baa911a28a62823aaf2cc2e074212492a3ee69d0d926cc8f5b12b4a108ff5c0c" dependencies = [ "cpp_demangle", "rustc-demangle", diff --git a/Cargo.toml b/Cargo.toml index e3136449962..2c195d5ac6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.1.0-beta.1", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.1.0-beta.1", path = "./rust/lance-arrow" } -lance-core = { version = "=4.1.0-beta.1", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.1.0-beta.1", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.1.0-beta.1", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.1.0-beta.1", path = "./rust/lance-encoding" } -lance-file = { version = "=4.1.0-beta.1", path = "./rust/lance-file" } -lance-geo = { version = "=4.1.0-beta.1", path = "./rust/lance-geo" } -lance-index = { version = "=4.1.0-beta.1", path = "./rust/lance-index" } -lance-io = { version = "=4.1.0-beta.1", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.1.0-beta.1", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.1.0-beta.1", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.1.0-beta.1", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.1.0-beta.1", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=4.1.0-beta.2", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=4.1.0-beta.2", path = "./rust/lance-arrow" } +lance-core = { version = "=4.1.0-beta.2", path = "./rust/lance-core" } +lance-datafusion = { version = "=4.1.0-beta.2", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=4.1.0-beta.2", path = "./rust/lance-datagen" } +lance-encoding = { version = "=4.1.0-beta.2", path = "./rust/lance-encoding" } +lance-file = { version = "=4.1.0-beta.2", path = "./rust/lance-file" } +lance-geo = { version = "=4.1.0-beta.2", path = "./rust/lance-geo" } +lance-index = { version = "=4.1.0-beta.2", path = "./rust/lance-index" } +lance-io = { version = "=4.1.0-beta.2", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=4.1.0-beta.2", path = "./rust/lance-linalg" } +lance-namespace = { version = "=4.1.0-beta.2", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=4.1.0-beta.2", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=4.1.0-beta.2", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=4.1.0-beta.1", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.1.0-beta.1", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.1.0-beta.1", path = "./rust/lance-testing" } +lance-table = { version = "=4.1.0-beta.2", path = "./rust/lance-table" } +lance-test-macros = { version = "=4.1.0-beta.2", path = "./rust/lance-test-macros" } +lance-testing = { version = "=4.1.0-beta.2", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.1.0-beta.1", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=4.1.0-beta.2", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.1.0-beta.1", path = "./rust/compression/fsst" } +fsst = { version = "=4.1.0-beta.2", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 61bcfc2d04e..ca5171826fb 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3186,9 +3186,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -3375,7 +3375,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3441,7 +3441,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3461,7 +3461,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrayref", "paste", @@ -3470,7 +3470,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3507,7 +3507,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -3540,7 +3540,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -3558,7 +3558,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -3595,7 +3595,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -3627,7 +3627,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "datafusion", "geo-traits", @@ -3641,7 +3641,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3709,7 +3709,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3750,7 +3750,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-schema", @@ -3784,7 +3784,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3800,7 +3800,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "async-trait", @@ -3813,7 +3813,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-ipc", @@ -3857,7 +3857,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index cd269e31a77..1d853076f81 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 9217211476d..f331288b69a 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.1.0-beta.1 + 4.1.0-beta.2 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index 76fc648275a..9b10e46fea0 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2818,7 +2818,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3666,9 +3666,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -3843,7 +3843,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3910,7 +3910,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3930,7 +3930,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrayref", "paste", @@ -3939,7 +3939,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3976,7 +3976,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4009,7 +4009,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4027,7 +4027,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4064,7 +4064,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "datafusion", "geo-traits", @@ -4110,7 +4110,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -4222,7 +4222,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -4238,7 +4238,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "async-trait", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-ipc", @@ -4295,7 +4295,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -5659,7 +5659,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" dependencies = [ "arrow", "arrow-array", diff --git a/python/Cargo.toml b/python/Cargo.toml index 71c53ec3183..05a6ca6ac4e 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.1.0-beta.1" +version = "4.1.0-beta.2" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From baa13abc15feae141e22fe4a6f9c7a02106cc88d Mon Sep 17 00:00:00 2001 From: Wyatt Alt Date: Mon, 23 Mar 2026 09:29:34 -0700 Subject: [PATCH 064/206] fix: resolve_latest_location converts errors to not_found unconditionally (#6248) resolve_latest_location can fail for many reasons (I/O errors, permission errors, network failures), but the error was unconditionally wrapped as DatasetNotFound. Now only NotFound errors are converted to DatasetNotFound; all other errors propagate with their original type. Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance/src/dataset/builder.rs | 7 ++- rust/lance/src/dataset/tests/dataset_io.rs | 54 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/rust/lance/src/dataset/builder.rs b/rust/lance/src/dataset/builder.rs index 36ec0e18e66..b1981d05a28 100644 --- a/rust/lance/src/dataset/builder.rs +++ b/rust/lance/src/dataset/builder.rs @@ -717,7 +717,12 @@ impl DatasetBuilder { None => commit_handler .resolve_latest_location(&base_path, &object_store) .await - .map_err(|e| Error::dataset_not_found(base_path.to_string(), Box::new(e)))?, + .map_err(|e| match &e { + Error::NotFound { .. } => { + Error::dataset_not_found(base_path.to_string(), Box::new(e)) + } + _ => e, + })?, }; let manifest = Dataset::load_manifest( &object_store, diff --git a/rust/lance/src/dataset/tests/dataset_io.rs b/rust/lance/src/dataset/tests/dataset_io.rs index 325564cc8ee..e438e0801ea 100644 --- a/rust/lance/src/dataset/tests/dataset_io.rs +++ b/rust/lance/src/dataset/tests/dataset_io.rs @@ -1616,3 +1616,57 @@ async fn test_dataset_uri_roundtrips() { dataset.latest_version_id().await.unwrap() ); } + +/// A commit handler whose resolve_latest_location always returns an IO error. +/// Used to verify that non-NotFound errors from resolve_latest_location are +/// propagated as-is rather than being wrapped as DatasetNotFound. +#[derive(Debug)] +struct ErroringCommitHandler; + +#[async_trait::async_trait] +impl lance_table::io::commit::CommitHandler for ErroringCommitHandler { + async fn resolve_latest_location( + &self, + _base_path: &Path, + _object_store: &ObjectStore, + ) -> Result { + Err(Error::io("simulated I/O error".to_string())) + } + + async fn commit( + &self, + _manifest: &mut lance_table::format::Manifest, + _indices: Option>, + _base_path: &Path, + _object_store: &ObjectStore, + _manifest_writer: lance_table::io::commit::ManifestWriter, + _naming_scheme: lance_table::io::commit::ManifestNamingScheme, + _transaction: Option, + ) -> std::result::Result< + lance_table::io::commit::ManifestLocation, + lance_table::io::commit::CommitError, + > { + unimplemented!() + } +} + +#[tokio::test] +async fn test_open_dataset_non_not_found_error_is_not_masked() { + // When resolve_latest_location returns an IO error, it should propagate + // as an IO error, not be wrapped as DatasetNotFound. + let store = Arc::new(object_store::memory::InMemory::new()); + let location = url::Url::parse("memory://test").unwrap(); + + #[allow(deprecated)] + let result = DatasetBuilder::from_uri("memory://test") + .with_object_store(store, location, Arc::new(ErroringCommitHandler)) + .load() + .await; + + let err = result.unwrap_err(); + assert!( + matches!(err, Error::IO { .. }), + "Expected IO error but got: {:?}", + err, + ); +} From 384fb55e3aa5da4de947bd5e121086dafc8cb274 Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Tue, 24 Mar 2026 00:47:10 +0800 Subject: [PATCH 065/206] perf: batched WAND and new WAND structure, ~50% faster (#6241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - advances posting iterators in batch - splits posting iterators into `head`, `lead` and `tail`, reduces the cost of updating posting iterators - reuses `query_weight` - pruning documents more aggressive with block max scores - uses a Lucene-style conjunction path for phrase queries, with conjunction intersection and block-max pruning separated from OR WAND Query Type | Version | Mode | QPS | Avg Latency | P90 | P99 -- | -- | -- | -- | -- | -- | -- match | current | single-thread | — | 2.93 ms | 5.62 ms | 7.50 ms match | main (v2) | single-thread | — | 4.22 ms | 7.56 ms | 7.85 ms match | current | 8-concurrency | 612.80 | 13.05 ms | 17.80 ms | 20.78 ms match | main (v2) | 8-concurrency | 599.17 | 13.35 ms | 19.61 ms | 21.86 ms phrase | current | single-thread | — | 2.02 ms | 2.59 ms | 2.62 ms phrase | main (v2) | single-thread | — | 3.60 ms | 4.62 ms | 4.67 ms phrase | current | 8-concurrency | 1597.37 | 5.01 ms | 6.57 ms | 8.60 ms phrase | main (v2) | 8-concurrency | 1040.66 | 7.69 ms | 9.86 ms | 11.23 ms --------- Signed-off-by: BubbleCal --- rust/lance-index/src/scalar/inverted/index.rs | 7 +- .../lance-index/src/scalar/inverted/scorer.rs | 7 - rust/lance-index/src/scalar/inverted/wand.rs | 1187 ++++++++++++++--- 3 files changed, 971 insertions(+), 230 deletions(-) diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index b273ef4b740..422d154b453 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -992,7 +992,6 @@ impl InvertedPartition { return Ok(Vec::new()); } if !is_phrase_query { - // remove duplicates token_ids.sort_unstable_by_key(|(token_id, _)| *token_id); token_ids.dedup_by_key(|(token_id, _)| *token_id); } @@ -1006,10 +1005,13 @@ impl InvertedPartition { .posting_list(token_id, is_phrase_query, metrics) .await?; - Result::Ok(PostingIterator::new( + let query_weight = idf(posting.len(), num_docs); + + Result::Ok(PostingIterator::with_query_weight( token, token_id, position as u32, + query_weight, posting, num_docs, )) @@ -4803,7 +4805,6 @@ mod tests { "prewarm should not leave cached posting lists sharing the same values buffer" ); } - #[test] fn test_block_max_scores_capacity_matches_block_count() { let mut docs = DocSet::default(); diff --git a/rust/lance-index/src/scalar/inverted/scorer.rs b/rust/lance-index/src/scalar/inverted/scorer.rs index f3914707e14..58c0471d262 100644 --- a/rust/lance-index/src/scalar/inverted/scorer.rs +++ b/rust/lance-index/src/scalar/inverted/scorer.rs @@ -10,13 +10,6 @@ use std::collections::HashMap; pub trait Scorer: Send + Sync { fn query_weight(&self, token: &str) -> f32; fn doc_weight(&self, freq: u32, doc_tokens: u32) -> f32; - // calculate the contribution of the token in the document - // token: the token to score - // freq: the frequency of the token in the document - // doc_tokens: the number of tokens in the document - fn score(&self, token: &str, freq: u32, doc_tokens: u32) -> f32 { - self.query_weight(token) * self.doc_weight(freq, doc_tokens) - } } // BM25 parameters diff --git a/rust/lance-index/src/scalar/inverted/wand.rs b/rust/lance-index/src/scalar/inverted/wand.rs index 7c09ff82952..7da5ddfb0fb 100644 --- a/rust/lance-index/src/scalar/inverted/wand.rs +++ b/rust/lance-index/src/scalar/inverted/wand.rs @@ -46,6 +46,7 @@ pub struct PostingIterator { token: String, token_id: u32, position: u32, + query_weight: f32, list: PostingList, // the index of current doc, this can be changed only by `next()` index: usize, @@ -193,12 +194,24 @@ impl PostingIterator { compressed as *mut CompressedState } + #[cfg(test)] pub(crate) fn new( token: String, token_id: u32, position: u32, list: PostingList, num_doc: usize, + ) -> Self { + Self::with_query_weight(token, token_id, position, 1.0, list, num_doc) + } + + pub(crate) fn with_query_weight( + token: String, + token_id: u32, + position: u32, + query_weight: f32, + list: PostingList, + num_doc: usize, ) -> Self { let approximate_upper_bound = match list.max_score() { Some(max_score) => max_score, @@ -211,6 +224,7 @@ impl PostingIterator { token, token_id, position, + query_weight, list, index: 0, block_idx: 0, @@ -234,6 +248,16 @@ impl PostingIterator { self.approximate_upper_bound } + #[inline] + fn score(&self, scorer: &S, freq: u32, doc_length: u32) -> f32 { + self.query_weight * scorer.doc_weight(freq, doc_length) + } + + #[inline] + fn cost(&self) -> usize { + self.list.len() + } + #[inline] fn empty(&self) -> bool { self.index >= self.list.len() @@ -418,17 +442,138 @@ pub struct DocCandidate { pub doc_length: u32, } +struct HeadPosting { + // Iterators that are already positioned on or after the next candidate doc. + // The heap is ordered by smallest doc id so the top element determines + // the next target doc to consider. + posting: Box, +} + +impl HeadPosting { + fn new(posting: Box) -> Self { + Self { posting } + } + + fn doc_id(&self) -> u64 { + self.posting + .doc() + .map(|doc| doc.doc_id()) + .unwrap_or(TERMINATED_DOC_ID) + } +} + +impl PartialEq for HeadPosting { + fn eq(&self, other: &Self) -> bool { + self.doc_id() == other.doc_id() + && self.posting.approximate_upper_bound().to_bits() + == other.posting.approximate_upper_bound().to_bits() + && self.posting.token_id == other.posting.token_id + && self.posting.position == other.posting.position + } +} + +impl Eq for HeadPosting {} + +impl PartialOrd for HeadPosting { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for HeadPosting { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + other + .doc_id() + .cmp(&self.doc_id()) + .then_with(|| { + self.posting + .approximate_upper_bound() + .total_cmp(&other.posting.approximate_upper_bound()) + }) + .then_with(|| self.posting.token_id.cmp(&other.posting.token_id)) + .then_with(|| self.posting.position.cmp(&other.posting.position)) + } +} + +struct TailPosting { + // Iterators that lag behind the current target doc but may still help the + // target beat the threshold if advanced to that doc. + upper_bound: f32, + // Used as a tie-breaker when upper bounds are equal. Lower-cost iterators + // are cheaper to advance, so they are preferred. + cost: usize, + posting: Box, +} + +impl TailPosting { + fn new(upper_bound: f32, cost: usize, posting: Box) -> Self { + Self { + upper_bound, + cost, + posting, + } + } +} + +impl PartialEq for TailPosting { + fn eq(&self, other: &Self) -> bool { + self.upper_bound.to_bits() == other.upper_bound.to_bits() + && self.cost == other.cost + && self.posting.token_id == other.posting.token_id + && self.posting.position == other.posting.position + } +} + +impl Eq for TailPosting {} + +impl PartialOrd for TailPosting { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TailPosting { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.upper_bound + .total_cmp(&other.upper_bound) + .then_with(|| other.cost.cmp(&self.cost)) + .then_with(|| other.posting.token_id.cmp(&self.posting.token_id)) + .then_with(|| other.posting.position.cmp(&self.posting.position)) + } +} + pub struct Wand<'a, S: Scorer> { threshold: f32, // multiple of factor and the minimum score of the top-k documents operator: Operator, num_terms: usize, - // we need to sort the posting iterators frequently, - // so wrap them in `Box` to avoid the cost of copying + // Posting iterators whose current doc id is >= the next target doc. + // The heap top gives the smallest current doc id. + head: BinaryHeap, #[allow(clippy::vec_box)] - postings: Vec>, + // Posting iterators that already match the current target doc. + // Only these iterators participate in scoring / phrase checks for the + // current candidate. + lead: Vec>, + // Posting iterators that are behind the current target doc but still kept + // in play because their score upper bound could affect the decision for the + // current candidate. + tail: BinaryHeap, + // Sum of upper bounds for all iterators currently held in `tail`. + // This lets us cheaply decide whether the current candidate can still beat + // the threshold before fully advancing every lagging iterator. + tail_max_score: f32, + // Block-max scores are valid for all candidate docs up to this doc id. + // `None` means the window has not been initialized yet and the next + // candidate must refresh block-max state before making pruning decisions. + up_to: Option, + // For conjunctions, this is the maximum attainable score for the current + // block-max window `[target, up_to]`. + and_max_score: f32, + // Last conjunction doc returned to the caller. The next conjunction search + // resumes strictly after this doc, like Lucene's `nextDoc()/advance()`. + and_last_doc: Option, docs: &'a DocSet, scorer: S, - cur_doc: Option, } // we were using row id as doc id in the past, which is u64, @@ -441,17 +586,40 @@ impl<'a, S: Scorer> Wand<'a, S> { docs: &'a DocSet, scorer: S, ) -> Self { - let mut posting_lists = postings.collect::>(); - posting_lists.sort_unstable(); + let mut head = BinaryHeap::new(); + let mut lead = Vec::new(); + for posting in postings { + if posting.doc().is_none() { + continue; + } + let posting = Box::new(posting); + if operator == Operator::And { + lead.push(posting); + } else { + head.push(HeadPosting::new(posting)); + } + } + if operator == Operator::And { + lead.sort_unstable_by_key(|posting| posting.cost()); + } Self { threshold: 0.0, operator, - num_terms: posting_lists.len(), - postings: posting_lists.into_iter().map(Box::new).collect(), + num_terms: if operator == Operator::And { + lead.len() + } else { + head.len() + }, + head, + lead, + tail: BinaryHeap::new(), + tail_max_score: 0.0, + up_to: None, + and_max_score: f32::INFINITY, + and_last_doc: None, docs, scorer, - cur_doc: None, } } @@ -470,8 +638,9 @@ impl<'a, S: Scorer> Wand<'a, S> { match (mask.max_len(), mask.iter_addrs()) { (Some(num_rows_matched), Some(row_ids)) - if num_rows_matched * 100 - <= FLAT_SEARCH_PERCENT_THRESHOLD.deref() * self.docs.len() as u64 => + if self.operator == Operator::Or + && num_rows_matched * 100 + <= FLAT_SEARCH_PERCENT_THRESHOLD.deref() * self.docs.len() as u64 => { return self.flat_search(params, row_ids, metrics); } @@ -480,13 +649,7 @@ impl<'a, S: Scorer> Wand<'a, S> { let mut candidates = BinaryHeap::with_capacity(std::cmp::min(limit, BLOCK_SIZE * 10)); let mut num_comparisons = 0; - while let Some((pivot, doc)) = self.next()? { - if let Some(cur_doc) = self.cur_doc - && cur_doc.doc_id() >= doc.doc_id() - { - continue; - } - self.cur_doc = Some(doc); + while let Some((doc, mut score)) = self.next()? { num_comparisons += 1; let row_id = match &doc { @@ -497,14 +660,9 @@ impl<'a, S: Scorer> Wand<'a, S> { DocInfo::Located(doc) => doc.row_id, }; if !mask.selected(row_id) { - self.move_preceding(pivot, doc.doc_id() + 1); - continue; - } - - if params.phrase_slop.is_some() - && !self.check_positions(params.phrase_slop.unwrap() as i32) - { - self.move_preceding(pivot, doc.doc_id() + 1); + if self.operator == Operator::Or { + self.push_back_leads(doc.doc_id() + 1); + } continue; } @@ -512,8 +670,27 @@ impl<'a, S: Scorer> Wand<'a, S> { DocInfo::Raw(doc) => self.docs.num_tokens(doc.doc_id), DocInfo::Located(doc) => self.docs.num_tokens_by_row_id(doc.row_id), }; - let score = self.score(pivot, doc_length); - let freqs = self.iter_term_freqs(pivot).collect(); + + let score = if self.operator == Operator::Or { + self.advance_all_tail(doc.doc_id(), Some(doc_length), Some(&mut score)); + if params.phrase_slop.is_some() + && !self.check_positions(params.phrase_slop.unwrap() as i32) + { + self.push_back_leads(doc.doc_id() + 1); + continue; + } + score + } else { + self.advance_all_tail(doc.doc_id(), None, None); + if params.phrase_slop.is_some() + && !self.check_positions(params.phrase_slop.unwrap() as i32) + { + continue; + } + self.score(doc_length) + }; + + let freqs = self.iter_term_freqs().collect(); if candidates.len() < limit { candidates.push(Reverse((ScoredDoc::new(row_id, score), freqs, doc_length))); if candidates.len() == limit { @@ -524,7 +701,9 @@ impl<'a, S: Scorer> Wand<'a, S> { candidates.push(Reverse((ScoredDoc::new(row_id, score), freqs, doc_length))); self.threshold = candidates.peek().unwrap().0.0.score.0 * params.wand_factor; } - self.move_preceding(pivot, doc.doc_id() + 1); + if self.operator == Operator::Or { + self.push_back_leads(doc.doc_id() + 1); + } } metrics.record_comparisons(num_comparisons); @@ -558,76 +737,45 @@ impl<'a, S: Scorer> Wand<'a, S> { }) .sorted_unstable() .collect::>(); - let is_compressed = matches!(self.postings[0].list, PostingList::Compressed(_)); + let is_compressed = self + .head + .peek() + .map(|posting| matches!(posting.posting.list, PostingList::Compressed(_))) + .or_else(|| { + self.lead + .first() + .map(|posting| matches!(posting.list, PostingList::Compressed(_))) + }) + .unwrap_or(false); let mut num_comparisons = 0; let mut candidates = BinaryHeap::new(); - let mut current_doc = 0; for (doc_id, row_id) in doc_ids { num_comparisons += 1; + self.move_head_before_target_to_tail(doc_id); + self.move_head_doc_to_lead(doc_id); - if doc_id < current_doc { + if self.lead.is_empty() && self.tail.is_empty() { continue; } - current_doc = doc_id; - - // even we already know the candidate doc id, we still need to know how many terms are required to hit the threshold - let mut pivot = 0; - let mut approximate_upper_bound = self.postings[0].approximate_upper_bound(); - while pivot + 1 < self.postings.len() && approximate_upper_bound < self.threshold { - approximate_upper_bound += self.postings[pivot + 1].approximate_upper_bound(); - pivot += 1; - } - if let Some(least_id) = self.postings[0].block_first_doc() - && least_id > doc_id - { - current_doc = least_id; + if !self.can_target_beat_threshold(doc_id) { + self.advance_tail_and_lead_to_head(doc_id + 1); continue; } - let mut max_pivot = 0; - while max_pivot + 1 < self.postings.len() { - self.postings[max_pivot + 1].shallow_next(doc_id); - match self.postings[max_pivot + 1].block_first_doc() { - Some(block_doc_id) if block_doc_id <= doc_id => { - max_pivot += 1; - } - _ => break, - } - } - if !self.check_block_max(max_pivot, doc_id) { - // the current block max score is less than the threshold, - // which means we have to skip at least the current block - let (_, least_id) = self.get_new_candidate(max_pivot); - if least_id == TERMINATED_DOC_ID { - break; - } - current_doc = std::cmp::max(doc_id, least_id); - self.move_preceding(max_pivot, least_id); - continue; - } + self.collect_tail_matches(doc_id); - // move all postings to this doc id - if !self.check_pivot_aligned(pivot, doc_id) { - if self.postings.is_empty() { - break; - } else { - continue; - } - } - - max_pivot = 0; - while max_pivot + 1 < self.postings.len() - && self.postings[max_pivot + 1].doc().map(|d| d.doc_id()) == Some(doc_id) - { - max_pivot += 1; + if self.operator == Operator::And && self.lead.len() < self.num_terms { + self.advance_lead_to_head(doc_id + 1); + continue; } // check positions if params.phrase_slop.is_some() && !self.check_positions(params.phrase_slop.unwrap() as i32) { + self.advance_lead_to_head(doc_id + 1); continue; } @@ -636,9 +784,18 @@ impl<'a, S: Scorer> Wand<'a, S> { true => self.docs.num_tokens(doc_id as u32), false => self.docs.num_tokens_by_row_id(row_id), }; + if self.operator == Operator::Or && !self.refine_or_candidate(doc_id, doc_length) { + // `flat_search` evaluates an explicit allow-list of doc ids. Unlike the + // regular WAND path, skipping to the next block boundary is unsafe here + // because later doc ids from the same block may still be present in the + // allow-list and need to be evaluated individually. + self.advance_tail_and_lead_to_head(doc_id + 1); + continue; + } - let score = self.score(max_pivot, doc_length); - let freqs = self.iter_term_freqs(max_pivot).collect(); + self.collect_tail_matches(doc_id); + let score = self.score(doc_length); + let freqs = self.iter_term_freqs().collect(); if candidates.len() < limit { candidates.push(Reverse((ScoredDoc::new(row_id, score), freqs, doc_length))); @@ -650,6 +807,8 @@ impl<'a, S: Scorer> Wand<'a, S> { candidates.push(Reverse((ScoredDoc::new(row_id, score), freqs, doc_length))); self.threshold = candidates.peek().unwrap().0.0.score.0 * params.wand_factor; } + + self.advance_lead_to_head(doc_id + 1); } metrics.record_comparisons(num_comparisons); @@ -664,26 +823,19 @@ impl<'a, S: Scorer> Wand<'a, S> { } // calculate the score of the current document - fn score(&self, pivot: usize, doc_length: u32) -> f32 { + fn score(&self, doc_length: u32) -> f32 { let mut score = 0.0; - for (token, freq) in self.iter_token_freqs(pivot) { - score += self.scorer.score(token, freq, doc_length); + for posting in &self.lead { + if let Some(doc) = posting.doc() { + score += posting.score(&self.scorer, doc.frequency(), doc_length); + } } score } - // iterate over all the preceding terms and collect the token and frequency - fn iter_token_freqs(&self, pivot: usize) -> impl Iterator + '_ { - self.postings[..=pivot].iter().filter_map(|posting| { - posting - .doc() - .map(|doc| (posting.token.as_str(), doc.frequency())) - }) - } - // iterate over all the preceding terms and collect the term index and frequency - fn iter_term_freqs(&self, pivot: usize) -> impl Iterator + '_ { - self.postings[..=pivot].iter().filter_map(|posting| { + fn iter_term_freqs(&self) -> impl Iterator + '_ { + self.lead.iter().filter_map(|posting| { posting .doc() .map(|doc| (posting.term_index(), doc.frequency())) @@ -691,184 +843,497 @@ impl<'a, S: Scorer> Wand<'a, S> { } // find the next doc candidate - fn next(&mut self) -> Result> { - while let Some((pivot, max_pivot)) = self.find_pivot_term() { - let posting = &self.postings[pivot]; - let doc = posting.doc().unwrap(); - let doc_id = doc.doc_id(); - - if !self.check_block_max(max_pivot, doc_id) { - // the current block max score is less than the threshold, - // which means we have to skip at least the current block - let (picked_term, least_id) = self.get_new_candidate(max_pivot); - if least_id == TERMINATED_DOC_ID { - return Ok(None); - } - self.move_term(picked_term, least_id); + // Find the next term-level candidate doc. The returned score is the exact + // contribution from the current `lead` set; additional score can still come + // from `tail` iterators that are advanced to the same doc later. + fn next(&mut self) -> Result> { + if self.operator == Operator::And { + return Ok(self.next_and_candidate().map(|doc| (doc, 0.0))); + } + + while let Some(target) = self.head_doc() { + if self.up_to.is_none_or(|up_to| target > up_to) { + self.update_max_scores(target); + } + self.move_head_doc_to_lead(target); + if self.lead.is_empty() { continue; } - if !self.check_pivot_aligned(pivot, doc_id) { + let Some(doc) = self.lead.first().and_then(|posting| posting.doc()) else { + self.push_back_leads(target + 1); continue; + }; + let doc_length = match &doc { + DocInfo::Raw(doc) => self.docs.num_tokens(doc.doc_id), + DocInfo::Located(doc) => self.docs.num_tokens_by_row_id(doc.row_id), + }; + let mut lead_score = self + .lead + .iter() + .filter_map(|posting| { + posting.doc().map(|lead_doc| { + posting.score(&self.scorer, lead_doc.frequency(), doc_length) + }) + }) + .sum::(); + + while lead_score <= self.threshold { + if lead_score + self.tail_max_score <= self.threshold { + self.push_back_leads(doc.doc_id() + 1); + break; + } + if !self.advance_tail_top(target, doc_length, &mut lead_score) { + self.push_back_leads(doc.doc_id() + 1); + break; + } } - // all the posting iterators preceding pivot have reached this doc id, - // this means the sum of upper bound of all terms is not less than the threshold, - // this document is a candidate, but we still need to check filters, positions, etc. - return Ok(Some((max_pivot, doc))); + if !self.lead.is_empty() { + return Ok(self + .lead + .first() + .and_then(|posting| posting.doc()) + .map(|doc| (doc, lead_score))); + } } + Ok(None) } - fn check_block_max(&mut self, pivot: usize, pivot_doc: u64) -> bool { - let mut sum = 0.0; - for posting in self.postings[..=pivot].iter_mut() { - posting.shallow_next(pivot_doc); - sum += posting.block_max_score(); - } - sum > self.threshold - } - - // find the term and new doc_id to move / move to, - // the term should be the one with the maximum score, - // the new doc_id should be the one is the minimum among: - // 1. for the terms preceding the pivot, the next block first doc id - // 2. for the terms after the pivot, the doc id of the term - fn get_new_candidate(&self, pivot: usize) -> (usize, u64) { - let mut picked_term = pivot; - let mut max_score = self.postings[pivot].approximate_upper_bound(); - let mut least_id = self.postings[pivot] - .next_block_first_doc() - .unwrap_or(TERMINATED_DOC_ID); - for (i, posting) in self.postings[..pivot].iter().enumerate().rev() { - let next_block_first_doc = posting.next_block_first_doc().unwrap_or(TERMINATED_DOC_ID); - if next_block_first_doc < least_id { - least_id = next_block_first_doc; + fn next_and_candidate(&mut self) -> Option { + if self.lead.len() < self.num_terms { + return None; + } + if let Some(last_doc) = self.and_last_doc + && self + .lead + .first() + .and_then(|posting| posting.doc()) + .map(|doc| doc.doc_id()) + == Some(last_doc) + { + let next_target = self.and_advance_target(last_doc + 1); + if next_target == TERMINATED_DOC_ID { + return None; } - if posting.approximate_upper_bound() > max_score { - max_score = posting.approximate_upper_bound(); - picked_term = i; + self.lead[0].next(next_target); + } + + 'advance_head: loop { + let doc = self + .lead + .first() + .and_then(|posting| posting.doc())? + .doc_id(); + if self.up_to.is_none_or(|up_to| doc > up_to) { + let next_target = self.and_advance_target(doc); + if next_target == TERMINATED_DOC_ID { + return None; + } + if next_target != doc { + self.lead[0].next(next_target); + continue; + } + } + + for posting in self.lead.iter_mut().skip(1) { + if posting.doc()?.doc_id() < doc { + posting.next(doc); + } + let next = posting.doc()?.doc_id(); + if next > doc { + let next_target = self.and_advance_target(next); + if next_target == TERMINATED_DOC_ID { + return None; + } + self.lead[0].next(next_target); + continue 'advance_head; + } } + + self.and_last_doc = Some(doc); + return self.lead.first().and_then(|posting| posting.doc()); } + } - for posting in self.postings[pivot + 1..].iter() { - let doc = posting - .doc() - .map(|d| d.doc_id()) + fn and_move_to_next_block(&mut self, target: u64) { + if self.threshold <= 0.0 { + self.up_to = Some(target); + self.and_max_score = f32::INFINITY; + return; + } + + let mut up_to = TERMINATED_DOC_ID; + let mut max_score = 0.0; + for posting in &mut self.lead { + posting.shallow_next(target); + let block_end = posting + .next_block_first_doc() + .map(|doc| doc.saturating_sub(1)) .unwrap_or(TERMINATED_DOC_ID); - if doc < least_id { - least_id = doc; + up_to = up_to.min(block_end.max(target)); + max_score += posting.block_max_score(); + } + self.up_to = Some(up_to); + self.and_max_score = max_score; + } + + fn and_advance_target(&mut self, mut target: u64) -> u64 { + if self.up_to.is_none_or(|up_to| target > up_to) { + self.and_move_to_next_block(target); + } + + loop { + let Some(up_to) = self.up_to else { + return TERMINATED_DOC_ID; + }; + if self.and_max_score >= self.threshold { + return target; } + if up_to == TERMINATED_DOC_ID { + return TERMINATED_DOC_ID; + } + target = up_to + 1; + self.and_move_to_next_block(target); } + } - (picked_term, least_id) + #[allow(clippy::vec_box)] + fn head_doc(&self) -> Option { + self.head.peek().map(HeadPosting::doc_id) } - // find the first term that the sum of upper bound of all preceding terms and itself, - // are greater than or equal to the threshold. - // returns the least pivot and the max index of the terms that have the same doc id. - fn find_pivot_term(&self) -> Option<(usize, usize)> { - if self.operator == Operator::And { - // for AND query, we always require all terms to be present in the document, - // so the pivot is always the last term as long as no posting list is exhausted - if self.postings.len() == self.num_terms { - return Some((self.num_terms - 1, self.num_terms - 1)); + fn push_head(&mut self, posting: Box) { + if posting.doc().is_some() { + self.head.push(HeadPosting::new(posting)); + } + } + + fn move_head_doc_to_lead(&mut self, target: u64) { + while self.head_doc() == Some(target) { + if let Some(posting) = self.head.pop() { + self.lead.push(posting.posting); } - return None; } + } - let mut acc = 0.0; - let mut pivot = None; - for (idx, posting) in self.postings.iter().enumerate() { - acc += posting.approximate_upper_bound(); - if acc >= self.threshold { - pivot = Some(idx); - break; + // Move all head iterators that are already known to be behind `target` + // into `tail`, possibly overflowing low-value entries back into `head`. + fn move_head_before_target_to_tail(&mut self, target: u64) { + while matches!(self.head_doc(), Some(doc_id) if doc_id < target) { + if let Some(posting) = self.head.pop() { + let upper_bound = posting.posting.approximate_upper_bound(); + if let Some(mut evicted) = + self.insert_tail_with_overflow(posting.posting, upper_bound) + { + evicted.next(target); + self.push_head(evicted); + } } } - let pivot = pivot?; - let mut max_pivot = pivot; - let doc_id = self.postings[pivot].doc().unwrap().doc_id(); - while max_pivot + 1 < self.postings.len() - && self.postings[max_pivot + 1].doc().unwrap().doc_id() == doc_id - { - max_pivot += 1; + } + + fn can_target_beat_threshold(&mut self, target: u64) -> bool { + if self.up_to.is_none_or(|up_to| target > up_to) { + self.update_max_scores(target); } - Some((pivot, max_pivot)) - } + let mut sum = self + .lead + .iter() + .map(|posting| posting.block_max_score()) + .sum::(); + let mut possible_matches = self.lead.len(); + for posting in &self.tail { + if matches!(posting.posting.block_first_doc(), Some(block_doc) if block_doc <= target) { + sum += posting.posting.block_max_score(); + possible_matches += 1; + } + } - // pick the term that has the maximum upper bound and the current doc id is less than the given doc id - // so that we can move the posting iterator to the next doc id that is possible to be candidate - fn move_term(&mut self, picked_term: usize, least_id: u64) { - self.postings[picked_term].next(least_id); - let doc_id = self.postings[picked_term] - .doc() - .map(|d| d.doc_id()) - .unwrap_or(TERMINATED_DOC_ID); - if doc_id == TERMINATED_DOC_ID { - self.postings.swap_remove(picked_term); + match self.operator { + Operator::And => possible_matches >= self.num_terms && sum > self.threshold, + Operator::Or => sum > self.threshold, } - self.bubble_up(picked_term); } - fn check_pivot_aligned(&mut self, pivot: usize, pivot_doc: u64) -> bool { - for i in (0..=pivot).rev() { - self.postings[i].next(pivot_doc); - let doc_id = self.postings[i] - .doc() - .map(|d| d.doc_id()) + fn update_max_scores(&mut self, target: u64) { + // Refresh the block-max window for the current target. The resulting + // `up_to` is the furthest doc id for which this block-max view remains + // valid. + let lead_cost = self + .lead + .iter() + .map(|posting| posting.cost()) + .min() + .unwrap_or(usize::MAX); + let mut up_to = TERMINATED_DOC_ID; + for posting in &mut self.lead { + posting.shallow_next(target); + let block_end = posting + .next_block_first_doc() + .map(|doc| doc.saturating_sub(1)) .unwrap_or(TERMINATED_DOC_ID); - if doc_id != pivot_doc { - if doc_id == TERMINATED_DOC_ID { - self.postings.swap_remove(i); - } - self.bubble_up(i); + up_to = up_to.min(block_end); + } + let head = std::mem::take(&mut self.head); + let mut rebuilt_head = BinaryHeap::with_capacity(head.len()); + for mut posting in head.into_vec() { + if posting.posting.cost() <= lead_cost { + posting.posting.shallow_next(posting.doc_id()); + let block_end = posting + .posting + .next_block_first_doc() + .map(|doc| doc.saturating_sub(1)) + .unwrap_or(TERMINATED_DOC_ID); + up_to = up_to.min(block_end); + } + rebuilt_head.push(posting); + } + self.head = rebuilt_head; + if up_to == TERMINATED_DOC_ID + && let Some(top) = self.tail.peek() + && top.cost <= lead_cost + { + let block_end = top + .posting + .next_block_first_doc() + .map(|doc| doc.saturating_sub(1)) + .unwrap_or(TERMINATED_DOC_ID); + up_to = up_to.min(block_end.max(target)); + } + self.up_to = Some(up_to); + + let tail = std::mem::take(&mut self.tail); + self.tail_max_score = 0.0; + for mut tail_posting in tail.into_vec() { + tail_posting.posting.shallow_next(target); + let upper_bound = match tail_posting.posting.block_first_doc() { + Some(block_doc) if block_doc <= target => tail_posting.posting.block_max_score(), + _ => 0.0, + }; + if let Some(mut evicted) = + self.insert_tail_with_overflow(tail_posting.posting, upper_bound) + { + evicted.next(target); + self.push_head(evicted); + } + } + } + + fn refine_or_candidate(&mut self, target: u64, doc_length: u32) -> bool { + if self.threshold <= 0.0 { + return true; + } + + let mut lead_score = self + .lead + .iter() + .filter_map(|posting| { + posting + .doc() + .map(|doc| posting.score(&self.scorer, doc.frequency(), doc_length)) + }) + .sum::(); + + while lead_score <= self.threshold { + if lead_score + self.tail_max_score <= self.threshold { + return false; + } + if !self.advance_tail_top(target, doc_length, &mut lead_score) { return false; - } else { - self.bubble_up(i); } } + true } - fn move_preceding(&mut self, pivot: usize, least_id: u64) { - for i in 0..=pivot { - self.postings[i].next(least_id); + fn collect_tail_matches(&mut self, target: u64) { + let mut remaining = Vec::with_capacity(self.tail.len()); + let tail = std::mem::take(&mut self.tail); + self.tail_max_score = 0.0; + for tail_posting in tail.into_vec() { + let mut posting = tail_posting.posting; + posting.next(target); + match posting.doc().map(|doc| doc.doc_id()) { + Some(doc_id) if doc_id == target => self.lead.push(posting), + Some(_) => remaining.push(posting), + None => {} + } } - let mut i = 0; - while i < self.postings.len() { - if self.postings[i].doc().is_none() { - self.postings.swap_remove(i); - } else { - i += 1; + for posting in remaining { + self.push_head(posting); + } + } + + fn advance_tail_and_lead_to_head(&mut self, least_id: u64) { + let mut postings = Vec::with_capacity(self.tail.len() + self.lead.len()); + while let Some(tail) = self.tail.pop() { + postings.push(tail.posting); + } + self.tail_max_score = 0.0; + postings.append(&mut self.lead); + for mut posting in postings { + posting.next(least_id); + self.push_head(posting); + } + } + + fn advance_lead_to_head(&mut self, least_id: u64) { + let lead = std::mem::take(&mut self.lead); + for mut posting in lead { + posting.next(least_id); + self.push_head(posting); + } + // In the flat-search path this is only called after `collect_tail_matches`, + // which drains the current tail into either `lead` or `head`. At this + // point `tail` is expected to be empty, so clearing it is a no-op that + // just resets the cached `tail_max_score`. + debug_assert!(self.tail.is_empty()); + self.clear_tail(); + } + + fn clear_tail(&mut self) { + self.tail.clear(); + self.tail_max_score = 0.0; + } + + fn insert_tail(&mut self, posting: Box, upper_bound: f32) { + self.tail_max_score += upper_bound; + self.tail + .push(TailPosting::new(upper_bound, posting.cost(), posting)); + } + + fn insert_tail_with_overflow( + &mut self, + posting: Box, + upper_bound: f32, + ) -> Option> { + // Keep only the lagging iterators that are most useful for deciding the + // current candidate. If a stronger tail entry arrives, evict the weakest + // one back to the caller so it can be advanced into `head`. + if self.threshold <= 0.0 || upper_bound <= 0.0 { + return Some(posting); + } + + if self.tail_max_score + upper_bound < self.threshold { + self.insert_tail(posting, upper_bound); + return None; + } + + if self.tail.is_empty() { + return Some(posting); + } + + let candidate = TailPosting::new(upper_bound, posting.cost(), posting); + if let Some(top) = self.tail.peek() + && top > &candidate + { + let evicted = self.tail.pop().expect("peeked tail posting should exist"); + self.tail_max_score = self.tail_max_score - evicted.upper_bound + upper_bound; + self.tail.push(candidate); + return Some(evicted.posting); + } + + Some(candidate.posting) + } + + fn push_back_leads(&mut self, target: u64) { + // After finishing a candidate doc, convert the aligned iterators back + // into lagging iterators. Entries that do not stay in `tail` are + // advanced to `target` and returned to `head`. + let leads = std::mem::take(&mut self.lead); + for posting in leads { + let upper_bound = posting.approximate_upper_bound(); + if let Some(mut evicted) = self.insert_tail_with_overflow(posting, upper_bound) { + evicted.next(target); + self.push_head(evicted); } } - self.postings.sort_unstable(); } - fn bubble_up(&mut self, index: usize) { - if index >= self.postings.len() { - return; + fn advance_tail_top(&mut self, target: u64, doc_length: u32, lead_score: &mut f32) -> bool { + // Advance the most promising lagging iterator to the current target. + // If it lands on the target, fold its exact contribution into + // `lead_score`; otherwise put it back into `head`. + let Some(TailPosting { + upper_bound, + cost: _, + mut posting, + }) = self.tail.pop() + else { + return false; + }; + self.tail_max_score -= upper_bound; + posting.next(target); + match posting.doc().map(|doc| doc.doc_id()) { + Some(doc_id) if doc_id == target => { + let frequency = posting.doc().expect("posting must exist").frequency(); + *lead_score += posting.score(&self.scorer, frequency, doc_length); + self.lead.push(posting); + } + Some(_) => self.push_head(posting), + None => {} } + true + } - for i in index + 1..self.postings.len() { - if self.postings[i].cmp(&self.postings[i - 1]) >= std::cmp::Ordering::Equal { - break; + fn advance_all_tail( + &mut self, + target: u64, + doc_length: Option, + mut score: Option<&mut f32>, + ) { + // Materialize all remaining lagging iterators for `target`. This is + // only done once we have already decided to fully score / validate the + // candidate. + let tail = std::mem::take(&mut self.tail); + self.tail_max_score = 0.0; + for tail_posting in tail.into_vec() { + let mut posting = tail_posting.posting; + posting.next(target); + match posting.doc().map(|doc| doc.doc_id()) { + Some(doc_id) if doc_id == target => { + if let (Some(doc_length), Some(score)) = (doc_length, score.as_deref_mut()) { + let frequency = posting + .doc() + .expect("posting moved to target should have doc") + .frequency(); + *score += posting.score(&self.scorer, frequency, doc_length); + } + self.lead.push(posting) + } + Some(_) => self.push_head(posting), + None => {} } - self.postings.swap(i - 1, i); } } + fn current_doc_postings(&self) -> Vec<&PostingIterator> { + if !self.lead.is_empty() { + return self.lead.iter().map(|posting| posting.as_ref()).collect(); + } + + let Some(target) = self.head_doc() else { + return Vec::new(); + }; + self.head + .iter() + .filter(|posting| posting.doc_id() == target) + .map(|posting| posting.posting.as_ref()) + .collect() + } + fn check_positions(&self, slop: i32) -> bool { if slop == 0 { return self.check_exact_positions(); } let mut position_iters = self - .postings - .iter() + .current_doc_postings() + .into_iter() .map(|posting| posting.position_cursor().expect("positions must exist")) .collect::>(); position_iters.sort_unstable_by_key(|iter| iter.position_in_query); @@ -907,8 +1372,8 @@ impl<'a, S: Scorer> Wand<'a, S> { fn check_exact_positions(&self) -> bool { let mut position_iters = self - .postings - .iter() + .current_doc_postings() + .into_iter() .map(|posting| posting.position_cursor().expect("positions must exist")) .collect::>(); position_iters.sort_unstable_by_key(|iter| iter.len()); @@ -1040,6 +1505,42 @@ mod tests { }, }; + struct UnitScorer; + + impl Scorer for UnitScorer { + fn query_weight(&self, _token: &str) -> f32 { + 1.0 + } + + fn doc_weight(&self, freq: u32, _doc_tokens: u32) -> f32 { + freq as f32 + } + } + + struct PanicQueryWeightScorer; + + impl Scorer for PanicQueryWeightScorer { + fn query_weight(&self, _token: &str) -> f32 { + panic!("query_weight should be precomputed before WAND construction"); + } + + fn doc_weight(&self, freq: u32, _doc_tokens: u32) -> f32 { + freq as f32 + } + } + + struct InverseDocLengthScorer; + + impl Scorer for InverseDocLengthScorer { + fn query_weight(&self, _token: &str) -> f32 { + 1.0 + } + + fn doc_weight(&self, freq: u32, doc_tokens: u32) -> f32 { + freq as f32 / doc_tokens as f32 + } + } + fn generate_posting_list( doc_ids: Vec, max_score: f32, @@ -1219,6 +1720,208 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_wand_new_uses_precomputed_query_weight() { + let mut docs = DocSet::default(); + docs.append(1, 1); + + let postings = vec![PostingIterator::with_query_weight( + String::from("term"), + 0, + 0, + 2.0, + generate_posting_list(vec![0], 1.0, None, false), + docs.len(), + )]; + + let wand = Wand::new( + Operator::Or, + postings.into_iter(), + &docs, + PanicQueryWeightScorer, + ); + assert_eq!(wand.head.len(), 1); + } + + #[test] + fn test_and_search_terminates_for_disjoint_postings() { + let mut docs = DocSet::default(); + for i in 0..6 { + docs.append(i, 1); + } + + let postings = vec![ + PostingIterator::with_query_weight( + String::from("a"), + 0, + 0, + 1.0, + generate_posting_list(vec![0, 2, 4], 1.0, None, false), + docs.len(), + ), + PostingIterator::with_query_weight( + String::from("b"), + 1, + 1, + 1.0, + generate_posting_list(vec![1, 3, 5], 1.0, None, false), + docs.len(), + ), + ]; + + let mut wand = Wand::new(Operator::And, postings.into_iter(), &docs, UnitScorer); + assert!(wand.next().unwrap().is_none()); + } + + #[test] + fn test_up_to_refreshes_on_first_candidate() { + let mut docs = DocSet::default(); + for i in 0..=(BLOCK_SIZE as u64 + 1) { + docs.append(i, 1); + } + + let postings = vec![PostingIterator::with_query_weight( + String::from("term"), + 0, + 0, + 1.0, + generate_posting_list( + (0..=(BLOCK_SIZE as u32 + 1)).collect(), + 1.0, + Some(vec![1.0, 1.0]), + true, + ), + docs.len(), + )]; + + let mut wand = Wand::new(Operator::Or, postings.into_iter(), &docs, UnitScorer); + assert!(wand.up_to.is_none()); + let _ = wand.next().unwrap(); + assert!(wand.up_to.is_some()); + } + + #[test] + fn test_and_search_prunes_with_threshold_and_keeps_candidate() { + let mut docs = DocSet::default(); + for i in 0..(2 * BLOCK_SIZE as u64) { + let doc_tokens = if i < BLOCK_SIZE as u64 { 100 } else { 1 }; + docs.append(i, doc_tokens); + } + let all_docs = (0..2 * BLOCK_SIZE as u32).collect::>(); + + let postings = vec![ + PostingIterator::with_query_weight( + String::from("a"), + 0, + 0, + 1.0, + generate_posting_list(all_docs.clone(), 1.0, Some(vec![0.02, 1.0]), true), + docs.len(), + ), + PostingIterator::with_query_weight( + String::from("b"), + 1, + 1, + 1.0, + generate_posting_list(all_docs, 1.0, Some(vec![0.02, 1.0]), true), + docs.len(), + ), + ]; + + let mut wand = Wand::new( + Operator::And, + postings.into_iter(), + &docs, + InverseDocLengthScorer, + ); + wand.threshold = 0.5; + + let candidate = wand.next().unwrap().unwrap(); + assert_eq!(candidate.0.doc_id(), BLOCK_SIZE as u64); + } + + #[rstest] + fn test_wand_batches_lagging_iterators(#[values(false, true)] is_compressed: bool) { + let mut docs = DocSet::default(); + for i in 0..16 { + docs.append(i as u64, 1); + } + + let postings = vec![ + PostingIterator::new( + String::from("a"), + 0, + 0, + generate_posting_list(vec![1, 10], 1.0, None, is_compressed), + docs.len(), + ), + PostingIterator::new( + String::from("b"), + 1, + 1, + generate_posting_list(vec![2, 10], 1.0, None, is_compressed), + docs.len(), + ), + PostingIterator::new( + String::from("c"), + 2, + 2, + generate_posting_list(vec![10], 1.0, None, is_compressed), + docs.len(), + ), + ]; + + let mut wand = Wand::new(Operator::Or, postings.into_iter(), &docs, UnitScorer); + wand.threshold = 2.5; + + let candidate = wand.next().unwrap().unwrap(); + assert_eq!(candidate.0.doc_id(), 10); + assert_eq!(wand.lead.len(), 3); + } + + #[test] + fn test_flat_search_or_keeps_masked_docs_in_same_block() { + let mut docs = DocSet::default(); + for i in 0..=(BLOCK_SIZE as u64 + 1) { + let doc_tokens = if i == 1 { 100 } else { 1 }; + docs.append(i, doc_tokens); + } + + let posting = PostingIterator::with_query_weight( + String::from("term"), + 0, + 0, + 1.0, + generate_posting_list( + (1..=(BLOCK_SIZE as u32 + 1)).collect(), + 1.0, + Some(vec![1.0, 1.0]), + true, + ), + docs.len(), + ); + + let mut wand = Wand::new( + Operator::Or, + vec![posting].into_iter(), + &docs, + InverseDocLengthScorer, + ); + wand.threshold = 0.5; + + let selected = vec![RowAddress::from(1_u64), RowAddress::from(2_u64)]; + let result = wand + .flat_search( + &FtsSearchParams::default(), + Box::new(selected.into_iter()), + &NoOpMetricsCollector, + ) + .unwrap(); + + let matched = result.into_iter().map(|doc| doc.row_id).collect::>(); + assert_eq!(matched, vec![2]); + } + #[test] fn test_block_max_score_matches_stored_value() { let doc_ids = vec![0_u32]; @@ -1289,4 +1992,48 @@ mod tests { assert!(wand.check_exact_positions()); assert!(wand.check_positions(0)); } + + #[rstest] + fn test_and_phrase_miss_advances_to_next_candidate(#[values(false, true)] is_compressed: bool) { + let mut docs = DocSet::default(); + docs.append(0, 8); + docs.append(1, 8); + + let postings = vec![ + PostingIterator::new( + String::from("a"), + 0, + 0, + generate_posting_list_with_positions( + vec![0, 1], + vec![vec![1_u32], vec![10_u32]], + 1.0, + is_compressed, + ), + docs.len(), + ), + PostingIterator::new( + String::from("b"), + 1, + 1, + generate_posting_list_with_positions( + vec![0, 1], + vec![vec![3_u32], vec![11_u32]], + 1.0, + is_compressed, + ), + docs.len(), + ), + ]; + + let mut wand = Wand::new(Operator::And, postings.into_iter(), &docs, UnitScorer); + let first = wand.next().unwrap().unwrap(); + assert_eq!(first.0.doc_id(), 0); + assert!(!wand.check_positions(0)); + + wand.threshold = 1.5; + let second = wand.next().unwrap().unwrap(); + assert_eq!(second.0.doc_id(), 1); + assert!(wand.check_positions(0)); + } } From 1d4052c982ab2f927994ce6db3bfc83ad6dd5855 Mon Sep 17 00:00:00 2001 From: yaommen Date: Tue, 24 Mar 2026 01:06:23 +0800 Subject: [PATCH 066/206] fix: return errors for unsupported fixed-size-list child types (#6253) ## Summary - reject unsupported `FixedSizeList` child combinations during schema/type conversion - return schema errors instead of letting these cases reach later panic paths - replace the old panic-based tests with a small matrix of unsupported `FixedSizeList` child cases ## Implementation This keeps the fix at the schema/type conversion boundary instead of deeper in the writer stack. Unsupported `FixedSizeList` child types are now rejected while converting Arrow `DataType` into Lance `LogicalType`. Supported paths remain unchanged, including primitive nested FSL and the existing `FixedSizeList` version gating. ## Testing - cargo test -p lance-encoding unsupported_fsl_child_types -- --nocapture - cargo test -p lance-encoding test_fixed_size_list_struct_requires_v2_2 -- --nocapture --- rust/lance-core/src/datatypes.rs | 13 +++ .../src/encodings/logical/fixed_size_list.rs | 109 ++++++++++++------ 2 files changed, 86 insertions(+), 36 deletions(-) diff --git a/rust/lance-core/src/datatypes.rs b/rust/lance-core/src/datatypes.rs index 1d6feaa0218..d395420e138 100644 --- a/rust/lance-core/src/datatypes.rs +++ b/rust/lance-core/src/datatypes.rs @@ -123,6 +123,17 @@ fn timeunit_to_str(unit: &TimeUnit) -> &'static str { } } +fn is_supported_fixed_size_list_child(data_type: &DataType, nested: bool) -> bool { + match data_type { + DataType::Struct(_) => !nested, + DataType::List(_) | DataType::LargeList(_) | DataType::Map(_, _) => false, + DataType::FixedSizeList(field, _) => { + is_supported_fixed_size_list_child(field.data_type(), true) + } + _ => true, + } +} + fn parse_timeunit(unit: &str) -> Result { match unit { "s" => Ok(TimeUnit::Second), @@ -192,6 +203,8 @@ impl TryFrom<&DataType> for LogicalType { // Don't want to directly use `bfloat16`, in case a built-in type is added // that isn't identical to our extension type. format!("fixed_size_list:lance.bfloat16:{}", *len) + } else if !is_supported_fixed_size_list_child(field.data_type(), false) { + return Err(Error::schema(format!("Unsupported data type: {:?}", dt))); } else { format!( "fixed_size_list:{}:{}", diff --git a/rust/lance-encoding/src/encodings/logical/fixed_size_list.rs b/rust/lance-encoding/src/encodings/logical/fixed_size_list.rs index ed62e633316..4de11cd18fb 100644 --- a/rust/lance-encoding/src/encodings/logical/fixed_size_list.rs +++ b/rust/lance-encoding/src/encodings/logical/fixed_size_list.rs @@ -617,6 +617,71 @@ mod tests { ]) } + fn make_fsl_of_list() -> DataType { + DataType::FixedSizeList( + Arc::new(Field::new( + "item", + DataType::List(Arc::new(Field::new("item", DataType::Int32, true))), + true, + )), + 2, + ) + } + + fn make_fsl_of_large_list() -> DataType { + DataType::FixedSizeList( + Arc::new(Field::new( + "item", + DataType::LargeList(Arc::new(Field::new("item", DataType::Int32, true))), + true, + )), + 2, + ) + } + + fn make_fsl_of_map() -> DataType { + DataType::FixedSizeList( + Arc::new(Field::new( + "item", + DataType::Map( + Arc::new(Field::new( + "entries", + DataType::Struct(Fields::from(vec![ + Field::new("key", DataType::Utf8, false), + Field::new("value", DataType::Int32, true), + ])), + false, + )), + false, + ), + true, + )), + 2, + ) + } + + fn make_fsl_of_nested_fsl_struct() -> DataType { + DataType::FixedSizeList( + Arc::new(Field::new( + "item", + DataType::FixedSizeList( + Arc::new(Field::new( + "item", + DataType::Struct(Fields::from(vec![Field::new( + "x", + DataType::Int32, + true, + )])), + true, + )), + 4, + ), + true, + )), + 2, + ) + } + #[rstest] #[case::simple(simple_struct_fields(), 2, LanceFileVersion::V2_2)] #[case::nested_struct(nested_struct_fields(), 2, LanceFileVersion::V2_2)] @@ -644,43 +709,15 @@ mod tests { check_specific_random(field, test_cases).await; } - // FSL and FSL are not yet supported (blocked by repdef) - #[test] - #[should_panic(expected = "Unsupported logical type: list")] - fn test_fsl_list_rejected() { - let inner = Field::new( - "item", - DataType::List(Arc::new(Field::new("item", DataType::Int32, true))), - true, - ); - let data_type = DataType::FixedSizeList(Arc::new(inner), 2); - let arrow_field = Field::new("test", data_type, true); - let lance_field = lance_core::datatypes::Field::try_from(&arrow_field).unwrap(); - let _ = lance_field.data_type(); - } - - #[test] - #[should_panic(expected = "Unsupported logical type: map")] - fn test_fsl_map_rejected() { - let inner = Field::new( - "item", - DataType::Map( - Arc::new(Field::new( - "entries", - DataType::Struct(Fields::from(vec![ - Field::new("key", DataType::Utf8, false), - Field::new("value", DataType::Int32, true), - ])), - false, - )), - false, - ), - true, - ); - let data_type = DataType::FixedSizeList(Arc::new(inner), 2); + #[rstest] + #[case::list(make_fsl_of_list())] + #[case::large_list(make_fsl_of_large_list())] + #[case::map(make_fsl_of_map())] + #[case::nested_fsl_struct(make_fsl_of_nested_fsl_struct())] + fn test_unsupported_fsl_child_types_return_error(#[case] data_type: DataType) { let arrow_field = Field::new("test", data_type, true); - let lance_field = lance_core::datatypes::Field::try_from(&arrow_field).unwrap(); - let _ = lance_field.data_type(); + let err = lance_core::datatypes::Field::try_from(&arrow_field).unwrap_err(); + assert!(err.to_string().contains("Unsupported data type")); } #[test] From 40929feaca2e9273ab4d1bec103276cbb334275f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C4=83zvan=20Burl=C4=83cioiu?= Date: Mon, 23 Mar 2026 19:18:35 +0200 Subject: [PATCH 067/206] feat: add OpenDAL Azdls backend for abfss:// with use_opendal flag (#6256) When use_opendal=true is set in storage options, abfss:// URLs now use OpenDAL's Azdls service (Azure Data Lake Storage Gen2 DFS API) instead of MicrosoftAzureBuilder. This provides native HNS capabilities like rename and create_dir that are not available via the Blob API. The az:// scheme continues to use OpenDAL's Azblob when use_opendal=true. Changes: - Add opendal/services-azdls feature to azure feature flag (Cargo.toml) - Refactor build_opendal_azure_store to route abfss->Azdls, az->Azblob - Add tests: Azdls provider selection, Azblob assertion, capability comparison --------- Co-authored-by: Razvan Burlacioiu --- rust/lance-io/Cargo.toml | 2 +- .../src/object_store/providers/azure.rs | 166 +++++++++++++++--- 2 files changed, 144 insertions(+), 24 deletions(-) diff --git a/rust/lance-io/Cargo.toml b/rust/lance-io/Cargo.toml index 0cd8f75747a..3aa0e1dab96 100644 --- a/rust/lance-io/Cargo.toml +++ b/rust/lance-io/Cargo.toml @@ -69,7 +69,7 @@ default = ["aws", "azure", "gcp"] gcs-test = [] gcp = ["object_store/gcp", "dep:opendal", "opendal/services-gcs", "dep:object_store_opendal"] aws = ["object_store/aws", "dep:aws-config", "dep:aws-credential-types", "dep:opendal", "opendal/services-s3", "dep:object_store_opendal"] -azure = ["object_store/azure", "dep:opendal", "opendal/services-azblob", "dep:object_store_opendal"] +azure = ["object_store/azure", "dep:opendal", "opendal/services-azblob", "opendal/services-azdls", "dep:object_store_opendal"] oss = ["dep:opendal", "opendal/services-oss", "dep:object_store_opendal"] tencent = ["dep:opendal", "opendal/services-cos", "dep:object_store_opendal"] huggingface = ["dep:opendal", "opendal/services-huggingface", "dep:object_store_opendal"] diff --git a/rust/lance-io/src/object_store/providers/azure.rs b/rust/lance-io/src/object_store/providers/azure.rs index 5db13b50c20..ba9c8a34831 100644 --- a/rust/lance-io/src/object_store/providers/azure.rs +++ b/rust/lance-io/src/object_store/providers/azure.rs @@ -10,7 +10,7 @@ use std::{ use object_store::ObjectStore as OSObjectStore; use object_store_opendal::OpendalStore; -use opendal::{Operator, services::Azblob}; +use opendal::{Operator, services::Azblob, services::Azdls}; use object_store::{ RetryConfig, @@ -28,36 +28,84 @@ use lance_core::error::{Error, Result}; pub struct AzureBlobStoreProvider; impl AzureBlobStoreProvider { - async fn build_opendal_azure_store( - &self, + fn build_opendal_operator( base_path: &Url, storage_options: &StorageOptions, - ) -> Result> { - let container = base_path - .host_str() - .ok_or_else(|| Error::invalid_input("Azure URL must contain container name"))? - .to_string(); - - let prefix = base_path.path().trim_start_matches('/').to_string(); - + ) -> Result { // Start with all storage options as the config map // OpenDAL will handle environment variables through its default credentials chain let mut config_map: HashMap = storage_options.0.clone(); - // Set required OpenDAL configuration - config_map.insert("container".to_string(), container); + match base_path.scheme() { + "az" => { + let container = base_path + .host_str() + .ok_or_else(|| Error::invalid_input("Azure URL must contain container name"))? + .to_string(); - if !prefix.is_empty() { - config_map.insert("root".to_string(), format!("/{}", prefix)); - } + config_map.insert("container".to_string(), container); - let operator = Operator::from_iter::(config_map) - .map_err(|e| { - Error::invalid_input(format!("Failed to create Azure Blob operator: {:?}", e)) - })? - .finish(); + let prefix = base_path.path().trim_start_matches('/'); + if !prefix.is_empty() { + config_map.insert("root".to_string(), format!("/{}", prefix)); + } + + Operator::from_iter::(config_map) + .map_err(|e| { + Error::invalid_input(format!( + "Failed to create Azure Blob operator: {:?}", + e + )) + }) + .map(|b| b.finish()) + } + "abfss" => { + let filesystem = base_path.username(); + if filesystem.is_empty() { + return Err(Error::invalid_input( + "abfss:// URL must include account: abfss://@.dfs.core.windows.net/path", + )); + } + let host = base_path.host_str().ok_or_else(|| { + Error::invalid_input( + "abfss:// URL must include account: abfss://@.dfs.core.windows.net/path" + ) + })?; + + config_map.insert("filesystem".to_string(), filesystem.to_string()); + config_map.insert("endpoint".to_string(), format!("https://{}", host)); + config_map + .entry("account_name".to_string()) + .or_insert_with(|| host.split('.').next().unwrap_or(host).to_string()); + + let root_path = base_path.path().trim_start_matches('/'); + if !root_path.is_empty() { + config_map.insert("root".to_string(), format!("/{}", root_path)); + } - Ok(Arc::new(OpendalStore::new(operator)) as Arc) + Operator::from_iter::(config_map) + .map_err(|e| { + Error::invalid_input(format!( + "Failed to create Azure DFS (ADLS Gen2) operator: {:?}", + e + )) + }) + .map(|b| b.finish()) + } + _ => Err(Error::invalid_input(format!( + "Unsupported Azure scheme: {}", + base_path.scheme() + ))), + } + } + + async fn build_opendal_azure_store( + &self, + base_path: &Url, + storage_options: &StorageOptions, + ) -> Result> { + let operator = Self::build_opendal_operator(base_path, storage_options)?; + Ok(Arc::new(OpendalStore::new(operator))) } async fn build_microsoft_azure_store( @@ -108,7 +156,7 @@ impl ObjectStoreProvider for AzureBlobStoreProvider { .map(|v| v.as_str() == "true") .unwrap_or(false); - let inner = if use_opendal { + let inner: Arc = if use_opendal { self.build_opendal_azure_store(&base_path, &storage_options) .await? } else { @@ -260,6 +308,12 @@ mod tests { .await .unwrap(); assert_eq!(store.scheme, "az"); + let inner_desc = store.inner.to_string(); + assert!( + inner_desc.contains("Opendal") && inner_desc.contains("azblob"), + "az:// with use_opendal=true should use OpenDAL Azblob, got: {}", + inner_desc + ); } #[test] @@ -425,4 +479,70 @@ mod tests { err ); } + + #[tokio::test] + async fn test_abfss_with_opendal_uses_azdls() { + use crate::object_store::StorageOptionsAccessor; + let provider = AzureBlobStoreProvider; + let url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap(); + let params = ObjectStoreParams { + storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options( + HashMap::from([ + ("use_opendal".to_string(), "true".to_string()), + ("account_name".to_string(), "testaccount".to_string()), + ("account_key".to_string(), "dGVzdA==".to_string()), + ]), + ))), + ..Default::default() + }; + + let store = provider.new_store(url, ¶ms).await.unwrap(); + assert_eq!(store.scheme, "abfss"); + assert!(!store.is_local()); + assert!(store.is_cloud()); + let inner_desc = store.inner.to_string(); + assert!( + inner_desc.contains("Opendal") && inner_desc.contains("azdls"), + "abfss:// with use_opendal=true should use OpenDAL Azdls, got: {}", + inner_desc + ); + } + + #[test] + fn test_azdls_capabilities_differ_from_azblob() { + let common_opts = StorageOptions(HashMap::from([ + ("account_name".to_string(), "testaccount".to_string()), + ("account_key".to_string(), "dGVzdA==".to_string()), + ( + "endpoint".to_string(), + "https://testaccount.blob.core.windows.net".to_string(), + ), + ])); + + // Build az:// operator (uses Azblob backend) + let az_url = Url::parse("az://test-container/path").unwrap(); + let az_operator = + AzureBlobStoreProvider::build_opendal_operator(&az_url, &common_opts).unwrap(); + + // Build abfss:// operator (uses Azdls backend) + let abfss_url = Url::parse("abfss://testfs@testaccount.dfs.core.windows.net/data").unwrap(); + let abfss_operator = + AzureBlobStoreProvider::build_opendal_operator(&abfss_url, &common_opts).unwrap(); + + let azblob_cap = az_operator.info().native_capability(); + let azdls_cap = abfss_operator.info().native_capability(); + + // Both support basic operations + assert!(azblob_cap.read); + assert!(azdls_cap.read); + assert!(azblob_cap.write); + assert!(azdls_cap.write); + assert!(azblob_cap.list); + assert!(azdls_cap.list); + + // Azdls supports rename and create_dir (HNS features); Azblob does not + assert!(azdls_cap.rename, "Azdls should support rename"); + assert!(azdls_cap.create_dir, "Azdls should support create_dir"); + assert!(!azblob_cap.rename, "Azblob should not support rename"); + } } From 244c721504c6ef0b4c2f9700a342509976898d6e Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Mon, 23 Mar 2026 17:25:22 +0000 Subject: [PATCH 068/206] chore: release beta version 4.1.0-beta.3 --- .bumpversion.toml | 2 +- Cargo.lock | 54 +++++++++++++++++++-------------------- Cargo.toml | 40 ++++++++++++++--------------- java/lance-jni/Cargo.lock | 46 ++++++++++++++++----------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 46 ++++++++++++++++----------------- python/Cargo.toml | 2 +- 8 files changed, 97 insertions(+), 97 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index d01802f0a88..2e52c6aa2b3 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.1.0-beta.2" +current_version = "4.1.0-beta.3" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 856e44e38c6..a1b759fa6d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3360,7 +3360,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "lance-datagen", @@ -4579,7 +4579,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "all_asserts", "approx", @@ -4675,7 +4675,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -4711,7 +4711,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrayref", "paste", @@ -4720,7 +4720,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -4760,7 +4760,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4794,7 +4794,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4814,7 +4814,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4861,7 +4861,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "all_asserts", "arrow", @@ -4887,7 +4887,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "datafusion", "geo-traits", @@ -4944,7 +4944,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "approx", "arrow", @@ -5025,7 +5025,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "approx", "arrow-array", @@ -5094,7 +5094,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "async-trait", @@ -5109,7 +5109,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -5127,7 +5127,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-ipc", @@ -5185,7 +5185,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -5231,7 +5231,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "proc-macro2", "quote", @@ -5240,7 +5240,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "arrow-schema", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "clap", "lance-core", @@ -8788,18 +8788,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.5+spec-1.1.0" +version = "0.25.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +checksum = "d15b06e6c39068c203e7c1d0bc3944796d867449e7668ef7fa5ea43727cb846e" dependencies = [ "indexmap", "toml_datetime", @@ -8809,9 +8809,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 2c195d5ac6d..518b7879379 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.1.0-beta.2", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.1.0-beta.2", path = "./rust/lance-arrow" } -lance-core = { version = "=4.1.0-beta.2", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.1.0-beta.2", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.1.0-beta.2", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.1.0-beta.2", path = "./rust/lance-encoding" } -lance-file = { version = "=4.1.0-beta.2", path = "./rust/lance-file" } -lance-geo = { version = "=4.1.0-beta.2", path = "./rust/lance-geo" } -lance-index = { version = "=4.1.0-beta.2", path = "./rust/lance-index" } -lance-io = { version = "=4.1.0-beta.2", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.1.0-beta.2", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.1.0-beta.2", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.1.0-beta.2", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.1.0-beta.2", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=4.1.0-beta.3", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=4.1.0-beta.3", path = "./rust/lance-arrow" } +lance-core = { version = "=4.1.0-beta.3", path = "./rust/lance-core" } +lance-datafusion = { version = "=4.1.0-beta.3", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=4.1.0-beta.3", path = "./rust/lance-datagen" } +lance-encoding = { version = "=4.1.0-beta.3", path = "./rust/lance-encoding" } +lance-file = { version = "=4.1.0-beta.3", path = "./rust/lance-file" } +lance-geo = { version = "=4.1.0-beta.3", path = "./rust/lance-geo" } +lance-index = { version = "=4.1.0-beta.3", path = "./rust/lance-index" } +lance-io = { version = "=4.1.0-beta.3", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=4.1.0-beta.3", path = "./rust/lance-linalg" } +lance-namespace = { version = "=4.1.0-beta.3", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=4.1.0-beta.3", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=4.1.0-beta.3", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=4.1.0-beta.2", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.1.0-beta.2", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.1.0-beta.2", path = "./rust/lance-testing" } +lance-table = { version = "=4.1.0-beta.3", path = "./rust/lance-table" } +lance-test-macros = { version = "=4.1.0-beta.3", path = "./rust/lance-test-macros" } +lance-testing = { version = "=4.1.0-beta.3", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.1.0-beta.2", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=4.1.0-beta.3", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.1.0-beta.2", path = "./rust/compression/fsst" } +fsst = { version = "=4.1.0-beta.3", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index ca5171826fb..0cd870eed82 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3375,7 +3375,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3441,7 +3441,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3461,7 +3461,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrayref", "paste", @@ -3470,7 +3470,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3507,7 +3507,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -3540,7 +3540,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -3558,7 +3558,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -3595,7 +3595,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -3627,7 +3627,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "datafusion", "geo-traits", @@ -3641,7 +3641,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3709,7 +3709,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3750,7 +3750,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-schema", @@ -3784,7 +3784,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3800,7 +3800,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "async-trait", @@ -3813,7 +3813,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-ipc", @@ -3857,7 +3857,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -6397,18 +6397,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.5+spec-1.1.0" +version = "0.25.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +checksum = "d15b06e6c39068c203e7c1d0bc3944796d867449e7668ef7fa5ea43727cb846e" dependencies = [ "indexmap", "toml_datetime", @@ -6418,9 +6418,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow", ] diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 1d853076f81..1db28faac72 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index f331288b69a..9127f55d9b8 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.1.0-beta.2 + 4.1.0-beta.3 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index 9b10e46fea0..6221084213f 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2818,7 +2818,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3843,7 +3843,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3910,7 +3910,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3930,7 +3930,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrayref", "paste", @@ -3939,7 +3939,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3976,7 +3976,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4009,7 +4009,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4027,7 +4027,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4064,7 +4064,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "datafusion", "geo-traits", @@ -4110,7 +4110,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -4222,7 +4222,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -4238,7 +4238,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "async-trait", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-ipc", @@ -4295,7 +4295,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -5659,7 +5659,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -7355,18 +7355,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.5+spec-1.1.0" +version = "0.25.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +checksum = "d15b06e6c39068c203e7c1d0bc3944796d867449e7668ef7fa5ea43727cb846e" dependencies = [ "indexmap", "toml_datetime", @@ -7376,9 +7376,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow", ] diff --git a/python/Cargo.toml b/python/Cargo.toml index 05a6ca6ac4e..f35f81a9cc9 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.1.0-beta.2" +version = "4.1.0-beta.3" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 30cad43c48b5307ddc21bb31a37dcbae65454dd2 Mon Sep 17 00:00:00 2001 From: yaommen Date: Tue, 24 Mar 2026 19:50:33 +0800 Subject: [PATCH 069/206] test: fix flaky distributed vector build results test (#6268) ## Summary This fixes a flaky regression test added in #6220 (`b80fbb3231cf58dd50e5670f9c56d309999bbd73`). The affected test is: - `test_distributed_vector_build_commits_multiple_segments_and_preserves_query_results` Recent failures showed up in both of these runs: - https://github.com/lance-format/lance/actions/runs/23450834811 `main` at `244c721504c6ef0b4c2f9700a342509976898d6e` - https://github.com/lance-format/lance/actions/runs/23460892697 #6263 In those failures, different platforms / index variants failed: - `linux-arm / case_1_ivf_flat` on `main` - `linux-build / case_2_ivf_pq` on #6263 That points to an existing flaky test. ## Root Cause The test compared the exact Top-K `_rowid` results between: - a single-segment index build, and - a distributed multi-segment index build However, the query path used by the test is ANN (`ANNIVFPartition`) under the default probing behavior. With partial probing, the candidate set can differ slightly between single-segment and multi-segment layouts, especially near the tail of Top-K. That makes exact `_rowid` equality too strict for this test and causes intermittent failures. ## Fix Make the test probe all IVF partitions before comparing Top-K row ids: - add `.minimum_nprobes(TWO_FRAG_NUM_PARTITIONS)` to the test query This keeps the existing strong assertion (`ids_single == ids_split`) but removes the probing-related source of nondeterminism. ## Testing Local verification: - `export PROTOC=/opt/homebrew/opt/protobuf@3/bin/protoc` - `cargo test -p lance test_distributed_vector_build_commits_multiple_segments_and_preserves_query_results -- --nocapture` Observed result: - `case_1_ivf_flat ... ok` - `case_2_ivf_pq ... ok` - `case_3_ivf_sq ... ok` I also verified during debugging that with full probing enabled, repeated runs of the previously flaky `ivf_flat` / `ivf_pq` cases became stable. --- rust/lance/src/index/vector/ivf/v2.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 6cab3f591bd..13faa22e8d5 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -1775,6 +1775,7 @@ mod tests { .unwrap() .nearest("vector", q.as_ref(), K) .unwrap() + .minimum_nprobes(TWO_FRAG_NUM_PARTITIONS) .try_into_batch() .await .unwrap(); From 8b4a92645c95ad49f3b05cace2225588ef9278d0 Mon Sep 17 00:00:00 2001 From: fenfeng9 <36840213+fenfeng9@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:53:02 +0800 Subject: [PATCH 070/206] fix: handle list-level NULLs in NOT filters (#6044) closes #5904 `LabelListIndex` drops list-level NULL rows during unnest, so `NOT` filters can return NULL lists incorrectly. Changed: - persists list-level NULL rows in the LabelList index and merges them back into query null sets - keeps older bitmap-only LabelList index layouts readable by treating missing null-set metadata as empty - warns when opening an older LabelList index on a nullable column, since NOT-filter semantics may still be incomplete until the index is rebuilt --------- Co-authored-by: Will Jones --- python/python/tests/test_scalar_index.py | 7 +- rust/lance-index/src/scalar.rs | 13 ++ rust/lance-index/src/scalar/bitmap.rs | 131 ++++++++++----- rust/lance-index/src/scalar/label_list.rs | 175 +++++++++++++++++--- rust/lance-index/src/scalar/lance_format.rs | 84 ++++++++++ rust/lance/src/index/scalar.rs | 44 +++++ 6 files changed, 384 insertions(+), 70 deletions(-) diff --git a/python/python/tests/test_scalar_index.py b/python/python/tests/test_scalar_index.py index 884079ccec6..e25f43ffdc0 100644 --- a/python/python/tests/test_scalar_index.py +++ b/python/python/tests/test_scalar_index.py @@ -2132,10 +2132,9 @@ def test_label_list_index_null_list_match(tmp_path: Path): "array_has_any(labels, ['foo'])", "array_has_all(labels, ['foo'])", "array_contains(labels, 'foo')", - # TODO(issue #5904): Enable after fixing NOT filters with whole-list NULLs - # "NOT array_has_any(labels, ['foo'])", - # "NOT array_has_all(labels, ['foo'])", - # "NOT array_contains(labels, 'foo')", + "NOT array_has_any(labels, ['foo'])", + "NOT array_has_all(labels, ['foo'])", + "NOT array_contains(labels, 'foo')", ] expected = { f: dataset.to_table(filter=f).column("labels").to_pylist() for f in filters diff --git a/rust/lance-index/src/scalar.rs b/rust/lance-index/src/scalar.rs index 050ea590330..51412c995b0 100644 --- a/rust/lance-index/src/scalar.rs +++ b/rust/lance-index/src/scalar.rs @@ -7,6 +7,7 @@ use arrow::buffer::{OffsetBuffer, ScalarBuffer}; use arrow_array::{BooleanArray, ListArray, RecordBatch, UInt64Array}; use arrow_schema::{Field, Schema}; use async_trait::async_trait; +use bytes::Bytes; use datafusion::functions::string::contains::ContainsFunc; use datafusion::functions_nested::array_has; use datafusion::physical_plan::SendableRecordBatchStream; @@ -173,6 +174,12 @@ pub trait IndexWriter: Send { /// /// E.g. if this is the third time this is called this method will return 2 async fn write_record_batch(&mut self, batch: RecordBatch) -> Result; + /// Adds a global buffer and returns its index. + async fn add_global_buffer(&mut self, _data: Bytes) -> Result { + Err(Error::not_supported( + "global buffers are not supported by this index writer", + )) + } /// Finishes writing the file and closes the file async fn finish(&mut self) -> Result<()>; /// Finishes writing the file and closes the file with additional metadata @@ -184,6 +191,12 @@ pub trait IndexWriter: Send { pub trait IndexReader: Send + Sync { /// Read the n-th record batch from the file async fn read_record_batch(&self, n: u64, batch_size: u64) -> Result; + /// Reads a global buffer by index. + async fn read_global_buffer(&self, _index: u32) -> Result { + Err(Error::not_supported( + "global buffers are not supported by this index reader", + )) + } /// Read the range of rows from the file. /// If projection is Some, only return the columns in the projection, /// nested columns like Some(&["x.y"]) are not supported. diff --git a/rust/lance-index/src/scalar/bitmap.rs b/rust/lance-index/src/scalar/bitmap.rs index 2f0b1c03760..9a08acd4d67 100644 --- a/rust/lance-index/src/scalar/bitmap.rs +++ b/rust/lance-index/src/scalar/bitmap.rs @@ -13,6 +13,7 @@ use arrow::array::BinaryBuilder; use arrow_array::{Array, BinaryArray, RecordBatch, UInt64Array, new_null_array}; use arrow_schema::{DataType, Field, Schema}; use async_trait::async_trait; +use bytes::Bytes; use datafusion::physical_plan::SendableRecordBatchStream; use datafusion_common::ScalarValue; use deepsize::DeepSizeOf; @@ -288,6 +289,30 @@ impl BitmapIndex { Ok(Arc::new(bitmap)) } + + pub(crate) fn value_type(&self) -> &DataType { + &self.value_type + } + + /// Loads the current bitmap index into an in-memory value-to-row-id map. + pub(crate) async fn load_bitmap_index_state( + &self, + ) -> Result> { + let mut state = HashMap::new(); + + for key in self.index_map.keys() { + let bitmap = self.load_bitmap(key, None).await?; + state.insert(key.0.clone(), (*bitmap).clone()); + } + + if !self.null_map.is_empty() { + let existing_null = new_null_array(&self.value_type, 1); + let existing_null = ScalarValue::try_from_array(existing_null.as_ref(), 0)?; + state.insert(existing_null, (*self.null_map).clone()); + } + + Ok(state) + } } impl DeepSizeOf for BitmapIndex { @@ -558,34 +583,9 @@ impl ScalarIndex for BitmapIndex { mapping: &HashMap>, dest_store: &dyn IndexStore, ) -> Result { - let mut state = HashMap::new(); - - for key in self.index_map.keys() { - let bitmap = self.load_bitmap(key, None).await?; - let remapped_bitmap = - RowAddrTreeMap::from_iter(bitmap.row_addrs().unwrap().filter_map(|addr| { - let addr_as_u64 = u64::from(addr); - mapping - .get(&addr_as_u64) - .copied() - .unwrap_or(Some(addr_as_u64)) - })); - state.insert(key.0.clone(), remapped_bitmap); - } - - if !self.null_map.is_empty() { - let remapped_null = - RowAddrTreeMap::from_iter(self.null_map.row_addrs().unwrap().filter_map(|addr| { - let addr_as_u64 = u64::from(addr); - mapping - .get(&addr_as_u64) - .copied() - .unwrap_or(Some(addr_as_u64)) - })); - state.insert(ScalarValue::try_from(&self.value_type)?, remapped_null); - } - - BitmapIndexPlugin::write_bitmap_index(state, dest_store, &self.value_type).await?; + let state = self.load_bitmap_index_state().await?; + let remapped_state = BitmapIndexPlugin::remap_bitmap_state(state, mapping); + BitmapIndexPlugin::write_bitmap_index(remapped_state, dest_store, &self.value_type).await?; Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&pbold::BitmapIndexDetails::default()) @@ -602,20 +602,7 @@ impl ScalarIndex for BitmapIndex { dest_store: &dyn IndexStore, _old_data_filter: Option, ) -> Result { - let mut state = HashMap::new(); - - // Initialize builder with existing data - for key in self.index_map.keys() { - let bitmap = self.load_bitmap(key, None).await?; - state.insert(key.0.clone(), (*bitmap).clone()); - } - - if !self.null_map.is_empty() { - let ex_null = new_null_array(&self.value_type, 1); - let ex_null = ScalarValue::try_from_array(ex_null.as_ref(), 0)?; - state.insert(ex_null, (*self.null_map).clone()); - } - + let state = self.load_bitmap_index_state().await?; BitmapIndexPlugin::do_train_bitmap_index(new_data, state, dest_store).await?; Ok(CreatedIndex { @@ -657,6 +644,24 @@ impl BitmapIndexPlugin { state: HashMap, index_store: &dyn IndexStore, value_type: &DataType, + ) -> Result<()> { + Self::write_bitmap_index_with_extras( + state, + index_store, + value_type, + HashMap::new(), + Vec::new(), + ) + .await + } + + /// Writes a bitmap index and attaches extra metadata and global buffers. + pub(crate) async fn write_bitmap_index_with_extras( + state: HashMap, + index_store: &dyn IndexStore, + value_type: &DataType, + mut metadata: HashMap, + global_buffers: Vec<(String, Bytes)>, ) -> Result<()> { let num_bitmaps = state.len(); let schema = Arc::new(Schema::new(vec![ @@ -668,6 +673,11 @@ impl BitmapIndexPlugin { .new_index_file(BITMAP_LOOKUP_NAME, schema) .await?; + for (metadata_key, data) in global_buffers { + let buffer_idx = bitmap_index_file.add_global_buffer(data).await?; + metadata.insert(metadata_key, buffer_idx.to_string()); + } + let mut cur_keys = Vec::new(); let mut cur_bitmaps = Vec::new(); let mut cur_bytes = 0; @@ -714,7 +724,6 @@ impl BitmapIndexPlugin { // Finish file with metadata that allows lightweight statistics reads let stats_json = serde_json::to_string(&BitmapStatistics { num_bitmaps }) .map_err(|e| Error::internal(format!("failed to serialize bitmap statistics: {e}")))?; - let mut metadata = HashMap::new(); metadata.insert(INDEX_STATS_METADATA_KEY.to_string(), stats_json); bitmap_index_file.finish_with_metadata(metadata).await?; @@ -722,11 +731,11 @@ impl BitmapIndexPlugin { Ok(()) } - async fn do_train_bitmap_index( + /// Builds bitmap index state from a `(value, row_id)` stream without writing it. + pub(crate) async fn build_bitmap_index_state( mut data_source: SendableRecordBatchStream, mut state: HashMap, - index_store: &dyn IndexStore, - ) -> Result<()> { + ) -> Result<(HashMap, DataType)> { let value_type = data_source.schema().field(0).data_type().clone(); while let Some(batch) = data_source.try_next().await? { let values = batch.column_by_name(VALUE_COLUMN_NAME).expect_ok()?; @@ -742,6 +751,15 @@ impl BitmapIndexPlugin { } } + Ok((state, value_type)) + } + + async fn do_train_bitmap_index( + data_source: SendableRecordBatchStream, + state: HashMap, + index_store: &dyn IndexStore, + ) -> Result<()> { + let (state, value_type) = Self::build_bitmap_index_state(data_source, state).await?; Self::write_bitmap_index(state, index_store, &value_type).await } @@ -754,6 +772,27 @@ impl BitmapIndexPlugin { Self::do_train_bitmap_index(data, dictionary, index_store).await } + + /// Remaps every bitmap in a materialized bitmap-index state using row-id mappings. + pub(crate) fn remap_bitmap_state( + state: HashMap, + mapping: &HashMap>, + ) -> HashMap { + state + .into_iter() + .map(|(key, bitmap)| { + let remapped_bitmap = + RowAddrTreeMap::from_iter(bitmap.row_addrs().unwrap().filter_map(|addr| { + let addr_as_u64 = u64::from(addr); + mapping + .get(&addr_as_u64) + .copied() + .unwrap_or(Some(addr_as_u64)) + })); + (key, remapped_bitmap) + }) + .collect() + } } #[async_trait] diff --git a/rust/lance-index/src/scalar/label_list.rs b/rust/lance-index/src/scalar/label_list.rs index a7a683a6a3d..e6238d9d5a8 100644 --- a/rust/lance-index/src/scalar/label_list.rs +++ b/rust/lance-index/src/scalar/label_list.rs @@ -1,20 +1,28 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::{any::Any, collections::HashMap, fmt::Debug, pin::Pin, sync::Arc}; +use std::{ + any::Any, + collections::HashMap, + fmt::Debug, + pin::Pin, + sync::{Arc, Mutex}, +}; use arrow::array::AsArray; use arrow_array::{Array, RecordBatch, UInt64Array}; use arrow_schema::{DataType, Field, Fields, Schema, SchemaRef}; use async_trait::async_trait; +use bytes::Bytes; use datafusion::execution::RecordBatchStream; use datafusion::physical_plan::{SendableRecordBatchStream, stream::RecordBatchStreamAdapter}; use datafusion_common::ScalarValue; use deepsize::DeepSizeOf; use futures::{StreamExt, TryStream, TryStreamExt, stream::BoxStream}; use lance_core::cache::LanceCache; -use lance_core::utils::mask::{NullableRowAddrSet, RowAddrTreeMap}; -use lance_core::{Error, Result}; +use lance_core::error::LanceOptionExt; +use lance_core::utils::mask::{NullableRowAddrSet, RowAddrTreeMap, RowSetOps}; +use lance_core::{Error, ROW_ID, Result}; use roaring::RoaringBitmap; use tracing::instrument; @@ -33,7 +41,9 @@ use crate::scalar::{CreatedIndex, UpdateCriteria}; use crate::{Index, IndexType}; pub const BITMAP_LOOKUP_NAME: &str = "bitmap_page_lookup.lance"; -const LABEL_LIST_INDEX_VERSION: u32 = 0; +pub const LABEL_LIST_NULLS_METADATA_KEY: &str = "lance:label_list_nulls"; +pub const LABEL_LIST_NULLS_MIN_VERSION: i32 = 1; +const LABEL_LIST_INDEX_VERSION: u32 = 1; #[async_trait] trait LabelListSubIndex: ScalarIndex + DeepSizeOf { @@ -64,12 +74,16 @@ impl LabelListSubIndex for T {} /// and `array_has` / `array_contains`, using an underlying bitmap index. #[derive(Clone, Debug, DeepSizeOf)] pub struct LabelListIndex { - values_index: Arc, + values_index: Arc, + list_nulls: Arc, } impl LabelListIndex { - fn new(values_index: Arc) -> Self { - Self { values_index } + fn new(values_index: Arc, list_nulls: Arc) -> Self { + Self { + values_index, + list_nulls, + } } async fn load( @@ -77,9 +91,10 @@ impl LabelListIndex { frag_reuse_index: Option>, index_cache: &LanceCache, ) -> Result> { - BitmapIndex::load(store, frag_reuse_index, index_cache) - .await - .map(|index| Arc::new(Self::new(index))) + let values_index = + BitmapIndex::load(store.clone(), frag_reuse_index.clone(), index_cache).await?; + let list_nulls = read_list_nulls(store, frag_reuse_index).await?; + Ok(Arc::new(Self::new(values_index, Arc::new(list_nulls)))) } } @@ -182,6 +197,13 @@ impl ScalarIndex for LabelListIndex { self.set_union(values_results, labels.len() == 1).await } }?; + let row_ids = if self.list_nulls.as_ref().is_empty() { + row_ids + } else { + let mut nulls = row_ids.null_rows().clone(); + nulls |= self.list_nulls.as_ref(); + row_ids.with_nulls(nulls) + }; Ok(SearchResult::Exact(row_ids)) } @@ -195,7 +217,23 @@ impl ScalarIndex for LabelListIndex { mapping: &HashMap>, dest_store: &dyn IndexStore, ) -> Result { - self.values_index.remap(mapping, dest_store).await?; + let state = self.values_index.load_bitmap_index_state().await?; + let remapped_state = BitmapIndexPlugin::remap_bitmap_state(state, mapping); + let remapped_nulls = + RowAddrTreeMap::from_iter(self.list_nulls.row_addrs().unwrap().filter_map(|addr| { + let addr_as_u64 = u64::from(addr); + mapping + .get(&addr_as_u64) + .copied() + .unwrap_or(Some(addr_as_u64)) + })); + write_label_list_bitmap_index( + remapped_state, + dest_store, + self.values_index.value_type(), + &remapped_nulls, + ) + .await?; Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&pbold::LabelListIndexDetails::default()) @@ -212,9 +250,18 @@ impl ScalarIndex for LabelListIndex { dest_store: &dyn IndexStore, old_data_filter: Option, ) -> Result { - self.values_index - .update(unnest_chunks(new_data)?, dest_store, old_data_filter) - .await?; + let state = self.values_index.load_bitmap_index_state().await?; + let list_nulls = Arc::new(Mutex::new(RowAddrTreeMap::new())); + let new_data = track_list_nulls(new_data, list_nulls.clone()); + let (merged_state, value_type) = + BitmapIndexPlugin::build_bitmap_index_state(unnest_chunks(new_data)?, state).await?; + let _ = old_data_filter; + let mut merged_nulls = (*self.list_nulls).clone(); + let new_nulls = list_nulls.lock().unwrap().clone(); + if !new_nulls.is_empty() { + merged_nulls |= &new_nulls; + } + write_label_list_bitmap_index(merged_state, dest_store, &value_type, &merged_nulls).await?; Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&pbold::LabelListIndexDetails::default()) @@ -258,6 +305,44 @@ fn extract_flatten_indices(list_arr: &dyn Array) -> UInt64Array { } } +/// Collect row_ids for list-level NULLs before unnest; unnest drops NULL lists entirely. +fn track_list_nulls( + source: SendableRecordBatchStream, + list_nulls: Arc>, +) -> SendableRecordBatchStream { + let schema = source.schema(); + let stream = source.try_filter_map(move |batch| { + let list_nulls = list_nulls.clone(); + async move { + record_list_nulls(&batch, &list_nulls)?; + Ok(Some(batch)) + } + }); + + Box::pin(RecordBatchStreamAdapter::new(schema, stream)) +} + +fn record_list_nulls( + batch: &RecordBatch, + list_nulls: &Arc>, +) -> datafusion_common::Result<()> { + let values = batch.column_by_name(VALUE_COLUMN_NAME).expect_ok()?; + let row_ids = batch.column_by_name(ROW_ID).expect_ok()?; + let row_ids = row_ids.as_any().downcast_ref::().unwrap(); + + let mut local_nulls = RowAddrTreeMap::new(); + for i in 0..values.len() { + if values.is_null(i) { + local_nulls.insert(row_ids.value(i)); + } + } + if !local_nulls.is_empty() { + let mut guard = list_nulls.lock().unwrap(); + *guard |= &local_nulls; + } + Ok(()) +} + fn unnest_schema(schema: &Schema) -> SchemaRef { let mut fields_iter = schema.fields.iter().cloned(); let key_field = fields_iter.next().unwrap(); @@ -355,6 +440,54 @@ fn unnest_chunks( ))) } +async fn read_list_nulls( + store: Arc, + frag_reuse_index: Option>, +) -> Result { + let reader = store.open_index_file(BITMAP_LOOKUP_NAME).await?; + if let Some(buffer_idx_str) = reader.schema().metadata.get(LABEL_LIST_NULLS_METADATA_KEY) { + let buffer_idx = buffer_idx_str.parse::().map_err(|err| { + Error::internal(format!( + "LabelList metadata key {} had invalid global buffer index {}: {}", + LABEL_LIST_NULLS_METADATA_KEY, buffer_idx_str, err + )) + })?; + let bytes = reader.read_global_buffer(buffer_idx).await?; + let null_map = RowAddrTreeMap::deserialize_from(bytes.as_ref())?; + return if let Some(frag_reuse_index) = frag_reuse_index { + Ok(frag_reuse_index.remap_row_addrs_tree_map(&null_map)) + } else { + Ok(null_map) + }; + } + Ok(RowAddrTreeMap::default()) +} + +fn serialize_list_nulls(null_map: &RowAddrTreeMap) -> Result { + let mut bytes = Vec::new(); + null_map.serialize_into(&mut bytes)?; + Ok(Bytes::from(bytes)) +} + +async fn write_label_list_bitmap_index( + state: HashMap, + store: &dyn IndexStore, + value_type: &DataType, + list_nulls: &RowAddrTreeMap, +) -> Result<()> { + BitmapIndexPlugin::write_bitmap_index_with_extras( + state, + store, + value_type, + HashMap::new(), + vec![( + LABEL_LIST_NULLS_METADATA_KEY.to_string(), + serialize_list_nulls(list_nulls)?, + )], + ) + .await +} + #[derive(Debug, Default)] pub struct LabelListIndexPlugin; @@ -409,9 +542,9 @@ impl ScalarIndexPlugin for LabelListIndexPlugin { &self, data: SendableRecordBatchStream, index_store: &dyn IndexStore, - request: Box, + _request: Box, fragment_ids: Option>, - progress: Arc, + _progress: Arc, ) -> Result { if fragment_ids.is_some() { return Err(Error::invalid_input_source( @@ -442,11 +575,13 @@ impl ScalarIndexPlugin for LabelListIndexPlugin { .into())); } + let list_nulls = Arc::new(Mutex::new(RowAddrTreeMap::new())); + let data = track_list_nulls(data, list_nulls.clone()); let data = unnest_chunks(data)?; - let bitmap_plugin = BitmapIndexPlugin; - bitmap_plugin - .train_index(data, index_store, request, fragment_ids, progress) - .await?; + let (state, value_type) = + BitmapIndexPlugin::build_bitmap_index_state(data, HashMap::new()).await?; + let list_nulls = list_nulls.lock().unwrap().clone(); + write_label_list_bitmap_index(state, index_store, &value_type, &list_nulls).await?; Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&pbold::LabelListIndexDetails::default()) .unwrap(), diff --git a/rust/lance-index/src/scalar/lance_format.rs b/rust/lance-index/src/scalar/lance_format.rs index 3d1d08be8d2..9839ea5400e 100644 --- a/rust/lance-index/src/scalar/lance_format.rs +++ b/rust/lance-index/src/scalar/lance_format.rs @@ -7,6 +7,7 @@ use super::{IndexReader, IndexStore, IndexWriter}; use arrow_array::RecordBatch; use arrow_schema::Schema; use async_trait::async_trait; +use bytes::Bytes; use deepsize::DeepSizeOf; use futures::TryStreamExt; use lance_core::{Error, Result, cache::LanceCache}; @@ -126,6 +127,10 @@ impl IndexWriter for current_writer::FileWriter { Ok(offset) } + async fn add_global_buffer(&mut self, data: Bytes) -> Result { + Self::add_global_buffer(self, data).await + } + async fn finish(&mut self) -> Result<()> { Self::finish(self).await.map(|_| ()) } @@ -179,6 +184,10 @@ impl IndexReader for current_reader::FileReader { self.read_range(start as usize..end as usize, None).await } + async fn read_global_buffer(&self, n: u32) -> Result { + Self::read_global_buffer(self, n).await + } + async fn read_range( &self, range: std::ops::Range, @@ -426,6 +435,28 @@ pub mod tests { prost_types::Any::from_msg(&T::default()).unwrap() } + #[tokio::test] + async fn test_global_buffer_round_trip() { + let tempdir = TempDir::default(); + let index_store = test_store(&tempdir); + + let mut writer = index_store + .new_index_file("global-buffer.lance", Arc::new(Schema::empty())) + .await + .unwrap(); + let expected = bytes::Bytes::from_static(b"scalar-global-buffer"); + let buffer_idx = writer.add_global_buffer(expected.clone()).await.unwrap(); + writer.finish().await.unwrap(); + + let reader = index_store + .open_index_file("global-buffer.lance") + .await + .unwrap(); + let actual = reader.read_global_buffer(buffer_idx).await.unwrap(); + + assert_eq!(actual, expected); + } + #[tokio::test] async fn test_basic_btree() { let tempdir = TempDir::default(); @@ -1650,4 +1681,57 @@ pub mod tests { _ => panic!("Expected Exact search result"), } } + + #[tokio::test] + async fn test_label_list_bitmap_only_layout_is_compatible() { + let tempdir = TempDir::default(); + let index_store = test_store(&tempdir); + + // Simulate an older released layout that only had the bitmap lookup file. + let values = arrow_array::UInt8Array::from(vec![1, 2]); + let row_ids = UInt64Array::from(vec![0, 2]); + let schema = Arc::new(Schema::new(vec![ + Field::new(VALUE_COLUMN_NAME, DataType::UInt8, true), + Field::new(ROW_ID, DataType::UInt64, false), + ])); + let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(values), Arc::new(row_ids)]) + .unwrap(); + + BitmapIndexPlugin::train_bitmap_index( + lance_datafusion::utils::reader_to_stream(Box::new(RecordBatchIterator::new( + vec![Ok(batch)], + schema, + ))), + index_store.as_ref(), + ) + .await + .unwrap(); + + let index = LabelListIndexPlugin + .load_index( + index_store, + &default_details::(), + None, + &LanceCache::no_cache(), + ) + .await + .unwrap(); + + let query = LabelListQuery::HasAnyLabel(vec![ScalarValue::UInt8(Some(1))]); + let result = index.search(&query, &NoOpMetricsCollector).await.unwrap(); + + match result { + SearchResult::Exact(row_ids) => { + assert!(row_ids.null_rows().is_empty()); + let actual_rows: Vec = row_ids + .true_rows() + .row_addrs() + .unwrap() + .map(u64::from) + .collect(); + assert_eq!(actual_rows, vec![0]); + } + _ => panic!("Expected Exact search result"), + } + } } diff --git a/rust/lance/src/index/scalar.rs b/rust/lance/src/index/scalar.rs index d90f26cf160..f2c9cfd8969 100644 --- a/rust/lance/src/index/scalar.rs +++ b/rust/lance/src/index/scalar.rs @@ -28,6 +28,9 @@ use lance_index::progress::IndexBuildProgress; use lance_index::registry::IndexPluginRegistry; use lance_index::scalar::IndexStore; use lance_index::scalar::inverted::METADATA_FILE; +use lance_index::scalar::label_list::{ + LABEL_LIST_NULLS_METADATA_KEY, LABEL_LIST_NULLS_MIN_VERSION, +}; use lance_index::scalar::registry::{ ScalarIndexPlugin, TrainingCriteria, TrainingOrdering, VALUE_COLUMN_NAME, }; @@ -331,6 +334,43 @@ pub async fn fetch_index_details( Ok(index_details) } +async fn validate_label_list_index_compatibility( + dataset: &Dataset, + column: &str, + index: &IndexMetadata, + index_store: &Arc, +) -> Result<()> { + let Some(field) = dataset.schema().field(column) else { + return Ok(()); + }; + + if !field.nullable { + return Ok(()); + } + + if index.index_version < LABEL_LIST_NULLS_MIN_VERSION { + log::warn!( + "LabelList index {} is old; NOT filters may be incorrect on nullable lists. Consider rebuilding.", + index.name + ); + return Ok(()); + } + + let reader = index_store.open_index_file(BITMAP_LOOKUP_NAME).await?; + if !reader + .schema() + .metadata + .contains_key(LABEL_LIST_NULLS_METADATA_KEY) + { + return Err(Error::internal(format!( + "LabelList index {} is missing required metadata key {}", + index.name, LABEL_LIST_NULLS_METADATA_KEY + ))); + } + + Ok(()) +} + pub async fn open_scalar_index( dataset: &Dataset, column: &str, @@ -343,6 +383,10 @@ pub async fn open_scalar_index( let index_details = fetch_index_details(dataset, column, index).await?; let plugin = SCALAR_INDEX_PLUGIN_REGISTRY.get_plugin_by_details(index_details.as_ref())?; + if index_details.type_url.ends_with("LabelListIndexDetails") { + validate_label_list_index_compatibility(dataset, column, index, &index_store).await?; + } + let frag_reuse_index = dataset.open_frag_reuse_index(metrics).await?; let index_cache = dataset From 66404ca95542fdd81e693a0bfa461f7669c18f39 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 25 Mar 2026 00:03:52 +0800 Subject: [PATCH 071/206] refactor!: remove staging from distributed vector indexing (#6269) This refactors distributed vector indexing to remove the staging-root workflow and treat worker outputs as segments written directly under `indices//`. Segment planning and build now operate on segments directly, and vector indexing no longer uses `merge_index_metadata`. The API and docs are updated around `with_segments(...)` / `plan_segments(...)`, and the focused distributed vector tests were updated to cover the new workflow. Java's API will be affected --- docs/src/guide/distributed_indexing.md | 112 +++--- java/lance-jni/src/blocking_dataset.rs | 180 ++++++++- java/src/main/java/org/lance/Dataset.java | 45 +++ .../java/org/lance/index/VectorIndexTest.java | 257 ++++--------- python/python/lance/dataset.py | 79 ++-- python/python/lance/indices/__init__.py | 2 + python/python/tests/test_vector_index.py | 265 ++++++-------- python/src/dataset.rs | 61 ++- python/src/indices.rs | 19 +- rust/lance-index/src/traits.rs | 13 +- rust/lance-index/src/types.rs | 24 +- .../src/vector/distributed/index_merger.rs | 14 +- rust/lance/src/dataset.rs | 62 +--- rust/lance/src/index.rs | 113 ++++-- rust/lance/src/index/create.rs | 290 ++++++++++----- rust/lance/src/index/vector.rs | 8 +- rust/lance/src/index/vector/ivf.rs | 346 ++++++------------ rust/lance/src/index/vector/ivf/v2.rs | 309 +++++----------- 18 files changed, 1046 insertions(+), 1153 deletions(-) diff --git a/docs/src/guide/distributed_indexing.md b/docs/src/guide/distributed_indexing.md index e6aa241feee..abf0f5e9b0f 100644 --- a/docs/src/guide/distributed_indexing.md +++ b/docs/src/guide/distributed_indexing.md @@ -15,12 +15,12 @@ write: 1. multiple workers build index data in parallel 2. the caller invokes Lance segment build APIs for one distributed build -3. Lance discovers the relevant worker outputs, then plans and builds index artifacts +3. Lance plans and builds index artifacts from the worker outputs supplied by the caller 4. the built artifacts are committed into the dataset manifest -For vector indices, the worker outputs are temporary shard directories under a -shared UUID. Internally, Lance can turn these shard outputs into one or more -built physical segments. +For vector indices, the worker outputs are segments stored directly +under `indices//`. Lance can turn these outputs into one or more +physical segments and then commit them as one logical index. ![Distributed Vector Segment Build](../images/distributed_vector_segment_build.svg) @@ -28,50 +28,52 @@ built physical segments. This guide uses the following terms consistently: -- **Staging root**: the shared UUID directory used during distributed index build -- **Partial shard**: one worker output written under the staging root as - `partial_/` -- **Built segment**: one physical index segment produced during segment build and - ready to be committed into the manifest +- **Segment**: one worker output written by `execute_uncommitted()` under + `indices//` +- **Physical segment**: one index segment that is ready to be committed into + the manifest - **Logical index**: the user-visible index identified by name; a logical index - may contain one or more built segments + may contain one or more physical segments For example, a distributed vector build may create a layout like: ```text -indices// -├── partial_/ -│ ├── index.idx -│ └── auxiliary.idx -├── partial_/ -│ ├── index.idx -│ └── auxiliary.idx -└── partial_/ - ├── index.idx - └── auxiliary.idx +indices// +├── index.idx +└── auxiliary.idx + +indices// +├── index.idx +└── auxiliary.idx + +indices// +├── index.idx +└── auxiliary.idx ``` After segment build, Lance produces one or more segment directories: ```text -indices// +indices// ├── index.idx └── auxiliary.idx -indices// +indices// ├── index.idx └── auxiliary.idx ``` -These physical segments are then committed together as one logical index. +These physical segments are then committed together as one logical index. In the +common no-merge case, the input segments are already the physical +segments and `build_all()` returns them unchanged. ## Roles There are two parties involved in distributed indexing: -- **Workers** build partial shards -- **The caller** launches workers, chooses when a distributed build should be - turned into built segments, provides any additional inputs requested by the +- **Workers** build segments +- **The caller** launches workers, chooses how those segments should be turned + into physical segments, provides any additional inputs requested by the segment build APIs, and commits the final result @@ -82,21 +84,22 @@ launching workers and driving the overall workflow. The current model for distributed vector indexing has two layers of parallelism. -### Shard Build +### Worker Build -First, multiple workers build partial shards in parallel: +First, multiple workers build segments in parallel: -1. on each worker, call an uncommitted shard-build API such as - `create_index_builder(...).fragments(...).index_uuid(staging_index_uuid).execute_uncommitted()` - or Python `create_index_uncommitted(..., fragment_ids=..., index_uuid=...)` -2. each worker writes one `partial_/` under the shared staging root +1. on each worker, call a shard-build API such as + `create_index_builder(...).fragments(...).execute_uncommitted()` + or Python `create_index_uncommitted(..., fragment_ids=...)` +2. each worker writes one segment under `indices//` ### Segment Build -Then the caller turns that staging root into one or more built segments: +Then the caller turns those existing segments into one or more physical +segments: -1. open the staging root with `create_index_segment_builder(staging_index_uuid)` -2. provide partial index metadata with `with_partial_indices(...)` +1. create a builder with `create_index_segment_builder()` +2. provide segment metadata with `with_segments(...)` 3. optionally choose a grouping policy with `with_target_segment_bytes(...)` 4. call `plan()` to get `Vec` @@ -105,51 +108,52 @@ At that point the caller has two execution choices: - call `build(plan)` for each plan and run those builds in parallel - call `build_all()` to let Lance build every planned segment on the current node -After the segments are built, publish them with +After the physical segments are built, publish them with `commit_existing_index_segments(...)`. ## Internal Segmented Finalize Model Internally, Lance models distributed vector segment build as: -1. **plan** which partial shards should become each built segment -2. **build** each segment from its selected partial shards +1. **plan** which input segments should become each physical segment +2. **build** each segment from its selected input segments 3. **commit** the resulting physical segments as one logical index -The plan step is driven by the staging root and any additional shard metadata -required by the segment build APIs. +The plan step is driven by the segment metadata returned from +`execute_uncommitted()` and any additional inputs requested by the segment +build APIs. This is intentionally a storage-level model: -- partial shards are temporary worker outputs -- built segments are durable physical artifacts +- segments are worker outputs that are not yet published +- physical segments are durable artifacts referenced by the manifest - the logical index identity is attached only at commit time ## Segment Grouping -When Lance builds segments from a staging root, it may either: +When Lance builds segments from existing inputs, it may either: -- keep shard boundaries, so each partial shard becomes one built segment -- group multiple partial shards into a larger built segment +- keep segment boundaries, so each input segment becomes one physical segment +- group multiple input segments into a larger physical segment -The grouping decision is separate from shard build. Workers only build partial -shards; Lance applies the segment build policy when it plans built segments. +The grouping decision is separate from worker build. Workers only build +segments; Lance applies the segment build policy when it plans +physical segments. ## Responsibility Boundaries The caller is expected to know: - which distributed build is ready for segment build -- any additional shard metadata requested by the segment build APIs -- how the resulting built segments should be published +- the segment metadata returned by worker builds +- how the resulting physical segments should be published Lance is responsible for: -- writing partial shard artifacts -- discovering partial shards under the staging root -- planning built segments from the discovered shard set -- merging shard storage into built segment artifacts -- committing built segments into the manifest +- writing segment artifacts +- planning physical segments from the supplied segment set +- merging segment storage into physical segment artifacts +- committing physical segments into the manifest This split keeps distributed scheduling outside the storage engine while still letting Lance own the on-disk index format. diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index ff2c261ebdd..203ffcdffa8 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -8,7 +8,7 @@ use crate::namespace::{ }; use crate::session::{handle_from_session, session_from_handle}; use crate::storage_options::JavaStorageOptionsProvider; -use crate::traits::{FromJObjectWithEnv, FromJString, export_vec, import_vec}; +use crate::traits::{FromJObjectWithEnv, FromJString, export_vec, import_vec, import_vec_to_rust}; use crate::utils::{ build_compaction_options, extract_storage_options, extract_write_params, get_scalar_index_params, get_vector_index_params, to_rust_map, @@ -49,7 +49,7 @@ use lance_index::DatasetIndexExt; use lance_index::IndexCriteria as RustIndexCriteria; use lance_index::optimize::OptimizeOptions; use lance_index::scalar::btree::BTreeParameters; -use lance_index::{IndexParams, IndexType}; +use lance_index::{IndexParams, IndexSegment, IndexType}; use lance_io::object_store::ObjectStoreRegistry; use lance_io::object_store::StorageOptionsProvider; use lance_namespace::LanceNamespace; @@ -1070,6 +1070,182 @@ fn inner_merge_index_metadata( Ok(()) } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeBuildIndexSegments<'local>( + mut env: JNIEnv<'local>, + java_dataset: JObject, + java_segments: JObject, + target_segment_bytes_jobj: JObject, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_build_index_segments( + &mut env, + java_dataset, + java_segments, + target_segment_bytes_jobj + ) + ) +} + +fn inner_build_index_segments<'local>( + env: &mut JNIEnv<'local>, + java_dataset: JObject, + java_segments: JObject, + target_segment_bytes_jobj: JObject, +) -> Result> { + let segments = import_vec_to_rust(env, &java_segments, |env, obj| obj.extract_object(env))?; + let target_segment_bytes = env + .get_long_opt(&target_segment_bytes_jobj)? + .map(|v| v as u64); + let template = segment_template(&segments)?; + + let built_segments = { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + let mut builder = dataset_guard + .inner + .create_index_segment_builder() + .with_segments(segments); + if let Some(target_segment_bytes) = target_segment_bytes { + builder = builder.with_target_segment_bytes(target_segment_bytes); + } + RT.block_on(builder.build_all())? + }; + + let built_metadata = built_segments + .into_iter() + .map(|segment| index_segment_to_metadata(&template, segment)) + .collect::>(); + export_vec(env, &built_metadata) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeCommitExistingIndexSegments<'local>( + mut env: JNIEnv<'local>, + java_dataset: JObject, + index_name: JString, + column: JString, + java_segments: JObject, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_commit_existing_index_segments( + &mut env, + java_dataset, + index_name, + column, + java_segments + ) + ) +} + +fn inner_commit_existing_index_segments<'local>( + env: &mut JNIEnv<'local>, + java_dataset: JObject, + index_name: JString, + column: JString, + java_segments: JObject, +) -> Result> { + let index_name = index_name.extract(env)?; + let column = column.extract(env)?; + let segment_metadata = + import_vec_to_rust(env, &java_segments, |env, obj| obj.extract_object(env))?; + let segments = segment_metadata + .iter() + .map(index_metadata_to_segment) + .collect::>>()?; + + let committed = { + let mut dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + RT.block_on(dataset_guard.inner.commit_existing_index_segments( + &index_name, + &column, + segments, + ))?; + RT.block_on(dataset_guard.inner.load_indices_by_name(&index_name))? + }; + + export_vec(env, &committed) +} + +struct SegmentTemplate { + name: String, + fields: Vec, + dataset_version: u64, +} + +fn segment_template(segments: &[IndexMetadata]) -> Result { + let first = segments + .first() + .ok_or_else(|| Error::input_error("segments cannot be empty".to_string()))?; + for segment in &segments[1..] { + if segment.name != first.name { + return Err(Error::input_error(format!( + "All segments must share the same index name, got '{}' and '{}'", + first.name, segment.name + ))); + } + if segment.fields != first.fields { + return Err(Error::input_error(format!( + "All segments must target the same field ids, got {:?} and {:?}", + first.fields, segment.fields + ))); + } + if segment.dataset_version != first.dataset_version { + return Err(Error::input_error(format!( + "All segments must share the same dataset version, got {} and {}", + first.dataset_version, segment.dataset_version + ))); + } + } + + Ok(SegmentTemplate { + name: first.name.clone(), + fields: first.fields.clone(), + dataset_version: first.dataset_version, + }) +} + +fn index_metadata_to_segment(metadata: &IndexMetadata) -> Result { + let fragment_bitmap = metadata.fragment_bitmap.clone().ok_or_else(|| { + Error::input_error(format!( + "Segment '{}' is missing fragment coverage metadata", + metadata.uuid + )) + })?; + let index_details = metadata.index_details.clone().ok_or_else(|| { + Error::input_error(format!( + "Segment '{}' is missing index details metadata", + metadata.uuid + )) + })?; + + Ok(IndexSegment::new( + metadata.uuid, + fragment_bitmap, + index_details, + metadata.index_version, + )) +} + +fn index_segment_to_metadata(template: &SegmentTemplate, segment: IndexSegment) -> IndexMetadata { + let (uuid, fragment_bitmap, index_details, index_version) = segment.into_parts(); + IndexMetadata { + uuid, + fields: template.fields.clone(), + name: template.name.clone(), + dataset_version: template.dataset_version, + fragment_bitmap: Some(fragment_bitmap), + index_details: Some(index_details), + index_version, + created_at: Some(Utc::now()), + base_id: None, + files: None, + } +} + #[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeOptimizeIndices( mut env: JNIEnv, diff --git a/java/src/main/java/org/lance/Dataset.java b/java/src/main/java/org/lance/Dataset.java index 166feebba20..506827be902 100644 --- a/java/src/main/java/org/lance/Dataset.java +++ b/java/src/main/java/org/lance/Dataset.java @@ -1019,6 +1019,51 @@ public void mergeIndexMetadata( private native void innerMergeIndexMetadata( String indexUUID, int indexType, Optional batchReadHead); + /** + * Build physical vector index segments from previously-created fragment-level index outputs. + * + * @param segments segment metadata returned by {@link #createIndex(IndexOptions)} when + * fragmentIds are provided + * @param targetSegmentBytes optional size target for merged physical segments + * @return built physical segment metadata + */ + public List buildIndexSegments(List segments, Optional targetSegmentBytes) { + Preconditions.checkNotNull(segments, "segments cannot be null"); + Preconditions.checkArgument(!segments.isEmpty(), "segments cannot be empty"); + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return nativeBuildIndexSegments(segments, targetSegmentBytes); + } + } + + private native List nativeBuildIndexSegments( + List segments, Optional targetSegmentBytes); + + /** + * Publish one or more existing physical index segments as a logical index. + * + * @param indexName logical index name + * @param column indexed column name + * @param segments physical segment metadata to publish + * @return committed manifest metadata + */ + public List commitExistingIndexSegments( + String indexName, String column, List segments) { + Preconditions.checkArgument( + indexName != null && !indexName.isEmpty(), "indexName cannot be null or empty"); + Preconditions.checkArgument( + column != null && !column.isEmpty(), "column cannot be null or empty"); + Preconditions.checkNotNull(segments, "segments cannot be null"); + Preconditions.checkArgument(!segments.isEmpty(), "segments cannot be empty"); + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return nativeCommitExistingIndexSegments(indexName, column, segments); + } + } + + private native List nativeCommitExistingIndexSegments( + String indexName, String column, List segments); + /** * Count the number of rows in the dataset. * diff --git a/java/src/test/java/org/lance/index/VectorIndexTest.java b/java/src/test/java/org/lance/index/VectorIndexTest.java index 4a5902044da..a96b6593d30 100755 --- a/java/src/test/java/org/lance/index/VectorIndexTest.java +++ b/java/src/test/java/org/lance/index/VectorIndexTest.java @@ -13,18 +13,15 @@ */ package org.lance.index; -import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.Fragment; import org.lance.TestVectorDataset; -import org.lance.Transaction; import org.lance.index.vector.IvfBuildParams; import org.lance.index.vector.PQBuildParams; import org.lance.index.vector.RQBuildParams; import org.lance.index.vector.SQBuildParams; import org.lance.index.vector.VectorIndexParams; import org.lance.index.vector.VectorTrainer; -import org.lance.operation.CreateIndex; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -33,8 +30,6 @@ import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.UUID; -import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -75,72 +70,40 @@ public void testCreateIvfFlatIndexDistributively(@TempDir Path tempDir) throws E IndexParams indexParams = IndexParams.builder().setVectorIndexParams(vectorIndexParams).build(); - UUID indexUUID = UUID.randomUUID(); - - // Partially create index on the first fragment - dataset.createIndex( - IndexOptions.builder( - Collections.singletonList(TestVectorDataset.vectorColumnName), - IndexType.IVF_FLAT, - indexParams) - .withIndexName(TestVectorDataset.indexName) - .withIndexUUID(indexUUID.toString()) - .withFragmentIds(Collections.singletonList(fragments.get(0).getId())) - .build()); - - // Partially create index on the second fragment with the same UUID - dataset.createIndex( - IndexOptions.builder( - Collections.singletonList(TestVectorDataset.vectorColumnName), - IndexType.IVF_FLAT, - indexParams) - .withIndexName(TestVectorDataset.indexName) - .withIndexUUID(indexUUID.toString()) - .withFragmentIds(Collections.singletonList(fragments.get(1).getId())) - .build()); + Index firstSegment = + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList(TestVectorDataset.vectorColumnName), + IndexType.IVF_FLAT, + indexParams) + .withIndexName(TestVectorDataset.indexName) + .withFragmentIds(Collections.singletonList(fragments.get(0).getId())) + .build()); + + Index secondSegment = + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList(TestVectorDataset.vectorColumnName), + IndexType.IVF_FLAT, + indexParams) + .withIndexName(TestVectorDataset.indexName) + .withFragmentIds(Collections.singletonList(fragments.get(1).getId())) + .build()); // The index should not be visible before metadata merge & commit assertFalse( dataset.listIndexes().contains(TestVectorDataset.indexName), "Partially created IVF_FLAT index should not present before commit"); - // Merge index metadata for all fragment-level pieces - dataset.mergeIndexMetadata(indexUUID.toString(), IndexType.IVF_FLAT, Optional.empty()); - - int fieldId = - dataset.getLanceSchema().fields().stream() - .filter(f -> f.getName().equals(TestVectorDataset.vectorColumnName)) - .findAny() - .orElseThrow( - () -> new RuntimeException("Cannot find vector field for TestVectorDataset")) - .getId(); - - long datasetVersion = dataset.version(); - - Index index = - Index.builder() - .uuid(indexUUID) - .name(TestVectorDataset.indexName) - .fields(Collections.singletonList(fieldId)) - .datasetVersion(datasetVersion) - .indexVersion(0) - .fragments( - fragments.stream().limit(2).map(Fragment::getId).collect(Collectors.toList())) - .build(); + List builtSegments = + dataset.buildIndexSegments(List.of(firstSegment, secondSegment), Optional.empty()); + assertEquals(2, builtSegments.size()); - CreateIndex createIndexOp = - CreateIndex.builder().withNewIndices(Collections.singletonList(index)).build(); - - try (Transaction createIndexTx = - new Transaction.Builder() - .readVersion(dataset.version()) - .operation(createIndexOp) - .build()) { - try (Dataset newDataset = new CommitBuilder(dataset).execute(createIndexTx)) { - assertEquals(datasetVersion + 1, newDataset.version()); - assertTrue(newDataset.listIndexes().contains(TestVectorDataset.indexName)); - } - } + List committed = + dataset.commitExistingIndexSegments( + TestVectorDataset.indexName, TestVectorDataset.vectorColumnName, builtSegments); + assertEquals(2, committed.size()); + assertTrue(dataset.listIndexes().contains(TestVectorDataset.indexName)); } } } @@ -200,68 +163,39 @@ public void testCreateIvfPqIndexDistributively(@TempDir Path tempDir) throws Exc IndexParams indexParams = IndexParams.builder().setVectorIndexParams(vectorIndexParams).build(); - UUID indexUUID = UUID.randomUUID(); - - dataset.createIndex( - IndexOptions.builder( - Collections.singletonList(TestVectorDataset.vectorColumnName), - IndexType.IVF_PQ, - indexParams) - .withIndexName(TestVectorDataset.indexName) - .withIndexUUID(indexUUID.toString()) - .withFragmentIds(Collections.singletonList(fragments.get(0).getId())) - .build()); - - dataset.createIndex( - IndexOptions.builder( - Collections.singletonList(TestVectorDataset.vectorColumnName), - IndexType.IVF_PQ, - indexParams) - .withIndexName(TestVectorDataset.indexName) - .withIndexUUID(indexUUID.toString()) - .withFragmentIds(Collections.singletonList(fragments.get(1).getId())) - .build()); + Index firstSegment = + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList(TestVectorDataset.vectorColumnName), + IndexType.IVF_PQ, + indexParams) + .withIndexName(TestVectorDataset.indexName) + .withFragmentIds(Collections.singletonList(fragments.get(0).getId())) + .build()); + + Index secondSegment = + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList(TestVectorDataset.vectorColumnName), + IndexType.IVF_PQ, + indexParams) + .withIndexName(TestVectorDataset.indexName) + .withFragmentIds(Collections.singletonList(fragments.get(1).getId())) + .build()); assertFalse( dataset.listIndexes().contains(TestVectorDataset.indexName), "Partially created IVF_PQ index should not present before commit"); - dataset.mergeIndexMetadata(indexUUID.toString(), IndexType.IVF_PQ, Optional.empty()); - - int fieldId = - dataset.getLanceSchema().fields().stream() - .filter(f -> f.getName().equals(TestVectorDataset.vectorColumnName)) - .findAny() - .orElseThrow( - () -> new RuntimeException("Cannot find vector field for TestVectorDataset")) - .getId(); - - long datasetVersion = dataset.version(); - - Index index = - Index.builder() - .uuid(indexUUID) - .name(TestVectorDataset.indexName) - .fields(Collections.singletonList(fieldId)) - .datasetVersion(datasetVersion) - .indexVersion(0) - .fragments( - fragments.stream().limit(2).map(Fragment::getId).collect(Collectors.toList())) - .build(); + List builtSegments = + dataset.buildIndexSegments(List.of(firstSegment, secondSegment), Optional.empty()); + assertEquals(2, builtSegments.size()); - CreateIndex createIndexOp = - CreateIndex.builder().withNewIndices(Collections.singletonList(index)).build(); - - try (Transaction createIndexTx = - new Transaction.Builder() - .readVersion(dataset.version()) - .operation(createIndexOp) - .build()) { - try (Dataset newDataset = new CommitBuilder(dataset).execute(createIndexTx)) { - assertEquals(datasetVersion + 1, newDataset.version()); - assertTrue(newDataset.listIndexes().contains(TestVectorDataset.indexName)); - } - } + List committed = + dataset.commitExistingIndexSegments( + TestVectorDataset.indexName, TestVectorDataset.vectorColumnName, builtSegments); + assertEquals(2, committed.size()); + assertTrue(dataset.listIndexes().contains(TestVectorDataset.indexName)); } } } @@ -305,68 +239,39 @@ public void testCreateIvfSqIndexDistributively(@TempDir Path tempDir) throws Exc IndexParams indexParams = IndexParams.builder().setVectorIndexParams(vectorIndexParams).build(); - UUID indexUUID = UUID.randomUUID(); - - dataset.createIndex( - IndexOptions.builder( - Collections.singletonList(TestVectorDataset.vectorColumnName), - IndexType.IVF_SQ, - indexParams) - .withIndexName(TestVectorDataset.indexName) - .withIndexUUID(indexUUID.toString()) - .withFragmentIds(Collections.singletonList(fragments.get(0).getId())) - .build()); - - dataset.createIndex( - IndexOptions.builder( - Collections.singletonList(TestVectorDataset.vectorColumnName), - IndexType.IVF_SQ, - indexParams) - .withIndexName(TestVectorDataset.indexName) - .withIndexUUID(indexUUID.toString()) - .withFragmentIds(Collections.singletonList(fragments.get(1).getId())) - .build()); + Index firstSegment = + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList(TestVectorDataset.vectorColumnName), + IndexType.IVF_SQ, + indexParams) + .withIndexName(TestVectorDataset.indexName) + .withFragmentIds(Collections.singletonList(fragments.get(0).getId())) + .build()); + + Index secondSegment = + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList(TestVectorDataset.vectorColumnName), + IndexType.IVF_SQ, + indexParams) + .withIndexName(TestVectorDataset.indexName) + .withFragmentIds(Collections.singletonList(fragments.get(1).getId())) + .build()); assertFalse( dataset.listIndexes().contains(TestVectorDataset.indexName), "Partially created IVF_SQ index should not present before commit"); - dataset.mergeIndexMetadata(indexUUID.toString(), IndexType.IVF_SQ, Optional.empty()); - - int fieldId = - dataset.getLanceSchema().fields().stream() - .filter(f -> f.getName().equals(TestVectorDataset.vectorColumnName)) - .findAny() - .orElseThrow( - () -> new RuntimeException("Cannot find vector field for TestVectorDataset")) - .getId(); - - long datasetVersion = dataset.version(); - - Index index = - Index.builder() - .uuid(indexUUID) - .name(TestVectorDataset.indexName) - .fields(Collections.singletonList(fieldId)) - .datasetVersion(datasetVersion) - .indexVersion(0) - .fragments( - fragments.stream().limit(2).map(Fragment::getId).collect(Collectors.toList())) - .build(); + List builtSegments = + dataset.buildIndexSegments(List.of(firstSegment, secondSegment), Optional.empty()); + assertEquals(2, builtSegments.size()); - CreateIndex createIndexOp = - CreateIndex.builder().withNewIndices(Collections.singletonList(index)).build(); - - try (Transaction createIndexTx = - new Transaction.Builder() - .readVersion(dataset.version()) - .operation(createIndexOp) - .build()) { - try (Dataset newDataset = new CommitBuilder(dataset).execute(createIndexTx)) { - assertEquals(datasetVersion + 1, newDataset.version()); - assertTrue(newDataset.listIndexes().contains(TestVectorDataset.indexName)); - } - } + List committed = + dataset.commitExistingIndexSegments( + TestVectorDataset.indexName, TestVectorDataset.vectorColumnName, builtSegments); + assertEquals(2, committed.size()); + assertTrue(dataset.listIndexes().contains(TestVectorDataset.indexName)); } } } diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index e8d632b0ecb..fd2db9de351 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -2591,14 +2591,14 @@ def create_scalar_index( fragment_ids : List[int], optional If provided, the index will be created only on the specified fragments. This enables distributed/fragment-level indexing. When provided, the - method returns an IndexMetadata object but does not commit the index - to the dataset. The index can be committed later using the commit API. + method returns metadata for one segment but does not commit + the index to the dataset. The segment can be planned, merged, and + committed later using the segment builder and commit APIs. This parameter is passed via kwargs internally. index_uuid : str, optional - A UUID to use for fragment-level distributed indexing - multiple fragment-level indices need to share UUID for later merging. - If not provided, a new UUID will be generated. This parameter is passed via - kwargs internally. + A UUID to use for the segment written by this call. + If not provided, a new UUID will be generated. This parameter is + passed via kwargs internally. with_position: bool, default False This is for the ``INVERTED`` index. If True, the index will store the @@ -3258,12 +3258,12 @@ def create_index( fragment_ids : List[int], optional If provided, the index will be created only on the specified fragments. This enables distributed/fragment-level indexing. When provided, the - method creates temporary index metadata but does not commit the index - to the dataset. The index can be committed later using - merge_index_metadata(index_uuid, "VECTOR", column=..., index_name=...). + method creates one segment but does not commit the index + to the dataset. The returned metadata can be passed to + ``create_index_segment_builder().with_segments(...)`` + and then committed with ``commit_existing_index_segments(...)``. index_uuid : str, optional - A UUID to use for fragment-level distributed indexing. Multiple - fragment-level indices need to share UUID for later merging. + A UUID to use for the segment written by this call. If not provided, a new UUID will be generated. target_partition_size: int, optional The target partition size. If set, the number of partitions will be computed @@ -3426,33 +3426,32 @@ def create_index_uncommitted( **kwargs, ) -> Index: """ - Create one uncommitted partial index and return its metadata. + Create one segment without publishing it and return its metadata. - This is the public shard-build API for distributed index construction. - Unlike :meth:`create_index`, this method does not publish the index into - the dataset manifest. Instead, it writes one partial index under the - staging UUID and returns the resulting :class:`Index` metadata. + This is the public distributed-build API for vector index + construction. Unlike :meth:`create_index`, this method does not publish + the index into the dataset manifest. Instead, it writes one segment + under ``_indices//`` and returns the resulting + :class:`Index` metadata. Callers should: - 1. run :meth:`create_index_uncommitted` on each worker with the worker's - assigned ``fragment_ids`` and a shared ``index_uuid`` + 1. run :meth:`create_index_uncommitted` on each worker with that worker's + assigned ``fragment_ids`` 2. collect the returned :class:`Index` objects - 3. pass them to :meth:`IndexSegmentBuilder.with_partial_indices` - 4. build one or more segments and commit them with + 3. pass them to :meth:`IndexSegmentBuilder.with_segments` + 4. build one or more physical segments and commit them with :meth:`commit_existing_index_segments` - Parameters are the same as :meth:`create_index`, with two additional - requirements for distributed shard build: + Parameters are the same as :meth:`create_index`, with one additional + requirement: - ``fragment_ids`` must be provided - - workers that belong to the same distributed build must share the same - ``index_uuid`` Returns ------- Index - Metadata for the partial index that was written by this call. + Metadata for the segment that was written by this call. """ return self._create_index_impl( column, @@ -3514,16 +3513,17 @@ def merge_index_metadata( batch_readhead: Optional[int] = None, ): """ - Merge distributed index metadata for supported scalar - and vector index types. + Merge distributed scalar index metadata. - This method supports all index types defined in - :class:`lance.indices.SupportedDistributedIndices`, - including scalar indices and precise vector index types. + Vector distributed indexing no longer uses this API. For vector indices, + build segments with :meth:`create_index_uncommitted`, plan or + merge them with :meth:`create_index_segment_builder`, and publish them + with :meth:`commit_existing_index_segments`. This method does NOT commit changes. - This API merges temporary index files (e.g., per-fragment partials). + This API merges temporary scalar index files (for example per-fragment + BTree or inverted index outputs). After this method returns, callers MUST explicitly commit the index manifest using lance.LanceDataset.commit(...) with a LanceOperation.CreateIndex. @@ -3531,11 +3531,11 @@ def merge_index_metadata( Parameters ---------- index_uuid: str - The shared UUID used when building fragment-level indices. + The shared UUID used when building fragment-level scalar indices. index_type: str Index type name. Must be one of the enum values in :class:`lance.indices.SupportedDistributedIndices` - (for example ``"IVF_PQ"``). + supported by scalar distributed merge. batch_readhead: int, optional Prefetch concurrency used by BTREE merge reader. Default: 1. """ @@ -3552,16 +3552,15 @@ def merge_index_metadata( self._ds.merge_index_metadata(index_uuid, t, batch_readhead) return None - def create_index_segment_builder(self, staging_index_uuid: str): + def create_index_segment_builder(self): """ - Create a builder for turning partial index outputs into committed segments. + Create a builder for turning existing segments into physical segments. - The caller should pass the shared index UUID used during - :meth:`create_index` with ``fragment_ids=...`` and ``index_uuid=...``. - Then provide the returned partial index metadata through - :meth:`IndexSegmentBuilder.with_partial_indices`. + Provide the segment metadata returned by + :meth:`create_index_uncommitted` through + :meth:`IndexSegmentBuilder.with_segments`. """ - return self._ds.create_index_segment_builder(staging_index_uuid) + return self._ds.create_index_segment_builder() def commit_existing_index_segments( self, index_name: str, column: str, segments: List[IndexSegment] diff --git a/python/python/lance/indices/__init__.py b/python/python/lance/indices/__init__.py index 085ff66e252..8dfb1148345 100644 --- a/python/python/lance/indices/__init__.py +++ b/python/python/lance/indices/__init__.py @@ -31,9 +31,11 @@ class SupportedDistributedIndices(str, Enum): # Scalar index types BTREE = "BTREE" INVERTED = "INVERTED" + # Precise vector index types supported by distributed merge IVF_FLAT = "IVF_FLAT" IVF_PQ = "IVF_PQ" IVF_SQ = "IVF_SQ" + # Deprecated generic placeholder (kept for backward compatibility) VECTOR = "VECTOR" diff --git a/python/python/tests/test_vector_index.py b/python/python/tests/test_vector_index.py index 973264c475c..2451f4684b4 100644 --- a/python/python/tests/test_vector_index.py +++ b/python/python/tests/test_vector_index.py @@ -9,7 +9,6 @@ import string import tempfile import time -import uuid from pathlib import Path from typing import Optional @@ -19,7 +18,7 @@ import pyarrow.compute as pc import pytest from lance import LanceDataset, LanceFragment -from lance.dataset import Index, VectorIndexReader +from lance.dataset import VectorIndexReader from lance.indices import IndexFileVersion, IndicesBuilder from lance.query import MatchQuery, PhraseQuery from lance.util import validate_vector_index # noqa: E402 @@ -2112,38 +2111,67 @@ def build_distributed_vector_index( world=2, **index_params, ): - """Build a distributed vector index over fragment groups and commit. - - Steps: - - Partition fragments into `world` groups - - For each group, call create_index with fragment_ids and a shared index_uuid - - Merge metadata (commit index manifest) - - Returns the dataset (post-merge) for querying. - """ + """Build a distributed vector index over fragment groups and commit.""" frags = dataset.get_fragments() frag_ids = [f.fragment_id for f in frags] groups = _split_fragments_evenly(frag_ids, world) - shared_uuid = str(uuid.uuid4()) + segments = [] for g in groups: if not g: continue - dataset.create_index( - column=column, - index_type=index_type, - fragment_ids=g, - index_uuid=shared_uuid, - num_partitions=num_partitions, - num_sub_vectors=num_sub_vectors, - **index_params, + segments.append( + dataset.create_index_uncommitted( + column=column, + index_type=index_type, + fragment_ids=g, + num_partitions=num_partitions, + num_sub_vectors=num_sub_vectors, + **index_params, + ) ) - # Merge physical index metadata and commit manifest for VECTOR - dataset.merge_index_metadata(shared_uuid, index_type) - dataset = _commit_index_helper(dataset, shared_uuid, column="vector") - return dataset + segments = ( + dataset.create_index_segment_builder().with_segments(segments).build_all() + ) + return dataset.commit_existing_index_segments(f"{column}_idx", column, segments) + + +def _commit_segments_helper( + ds, segments, column: str, index_name: Optional[str] = None +): + if index_name is None: + index_name = f"{column}_idx" + return ds.commit_existing_index_segments(index_name, column, segments) + + +def _build_segments( + ds, + column: str, + index_type: str, + fragment_groups, + *, + index_name: Optional[str] = None, + **index_kwargs, +): + if index_name is None: + index_name = f"{column}_idx" + + segments = [] + for group in fragment_groups: + if not group: + continue + segments.append( + ds.create_index_uncommitted( + column=column, + index_type=index_type, + name=index_name, + fragment_ids=group, + **index_kwargs, + ) + ) + return segments def assert_distributed_vector_consistency( @@ -2458,7 +2486,6 @@ def test_metadata_merge_pq_success(tmp_path): mid = max(1, len(frags) // 2) node1 = [f.fragment_id for f in frags[:mid]] node2 = [f.fragment_id for f in frags[mid:]] - shared_uuid = str(uuid.uuid4()) builder = IndicesBuilder(ds, "vector") pre = builder.prepare_global_ivf_pq( num_partitions=8, @@ -2468,28 +2495,19 @@ def test_metadata_merge_pq_success(tmp_path): max_iters=20, ) try: - ds.create_index( - column="vector", - index_type="IVF_PQ", - fragment_ids=node1, - index_uuid=shared_uuid, - num_partitions=8, - num_sub_vectors=16, - ivf_centroids=pre["ivf_centroids"], - pq_codebook=pre["pq_codebook"], - ) - ds.create_index( - column="vector", - index_type="IVF_PQ", - fragment_ids=node2, - index_uuid=shared_uuid, + segments = _build_segments( + ds, + "vector", + "IVF_PQ", + [node1, node2], + index_name="vector_idx", num_partitions=8, num_sub_vectors=16, ivf_centroids=pre["ivf_centroids"], pq_codebook=pre["pq_codebook"], ) - ds.merge_index_metadata(shared_uuid, "IVF_PQ") - ds = _commit_index_helper(ds, shared_uuid, "vector") + segments = ds.create_index_segment_builder().with_segments(segments).build_all() + ds = _commit_segments_helper(ds, segments, "vector") q = np.random.rand(128).astype(np.float32) results = ds.to_table(nearest={"column": "vector", "q": q, "k": 10}) assert 0 < len(results) <= 10 @@ -2504,7 +2522,6 @@ def test_distributed_workflow_merge_and_search(tmp_path): frags = ds.get_fragments() if len(frags) < 2: pytest.skip("Need at least 2 fragments for distributed testing") - shared_uuid = str(uuid.uuid4()) mid = len(frags) // 2 node1 = [f.fragment_id for f in frags[:mid]] node2 = [f.fragment_id for f in frags[mid:]] @@ -2517,28 +2534,19 @@ def test_distributed_workflow_merge_and_search(tmp_path): max_iters=20, ) try: - ds.create_index( - column="vector", - index_type="IVF_PQ", - fragment_ids=node1, - index_uuid=shared_uuid, - num_partitions=4, - num_sub_vectors=4, - ivf_centroids=pre["ivf_centroids"], - pq_codebook=pre["pq_codebook"], - ) - ds.create_index( - column="vector", - index_type="IVF_PQ", - fragment_ids=node2, - index_uuid=shared_uuid, + segments = _build_segments( + ds, + "vector", + "IVF_PQ", + [node1, node2], + index_name="vector_idx", num_partitions=4, num_sub_vectors=4, ivf_centroids=pre["ivf_centroids"], pq_codebook=pre["pq_codebook"], ) - ds._ds.merge_index_metadata(shared_uuid, "IVF_PQ") - ds = _commit_index_helper(ds, shared_uuid, "vector") + segments = ds.create_index_segment_builder().with_segments(segments).build_all() + ds = _commit_segments_helper(ds, segments, "vector") q = np.random.rand(128).astype(np.float32) results = ds.to_table(nearest={"column": "vector", "q": q, "k": 10}) assert 0 < len(results) <= 10 @@ -2552,8 +2560,6 @@ def test_vector_merge_two_shards_success_flat(tmp_path): assert len(frags) >= 2 shard1 = [frags[0].fragment_id] shard2 = [frags[1].fragment_id] - shared_uuid = str(uuid.uuid4()) - # Global preparation builder = IndicesBuilder(ds, "vector") preprocessed = builder.prepare_global_ivf_pq( @@ -2564,28 +2570,19 @@ def test_vector_merge_two_shards_success_flat(tmp_path): max_iters=20, ) - ds.create_index( - column="vector", - index_type="IVF_FLAT", - fragment_ids=shard1, - index_uuid=shared_uuid, - num_partitions=4, - num_sub_vectors=128, - ivf_centroids=preprocessed["ivf_centroids"], - pq_codebook=preprocessed["pq_codebook"], - ) - ds.create_index( - column="vector", - index_type="IVF_FLAT", - fragment_ids=shard2, - index_uuid=shared_uuid, + segments = _build_segments( + ds, + "vector", + "IVF_FLAT", + [shard1, shard2], + index_name="vector_idx", num_partitions=4, num_sub_vectors=128, ivf_centroids=preprocessed["ivf_centroids"], pq_codebook=preprocessed["pq_codebook"], ) - ds._ds.merge_index_metadata(shared_uuid, "IVF_FLAT", None) - ds = _commit_index_helper(ds, shared_uuid, column="vector") + segments = ds.create_index_segment_builder().with_segments(segments).build_all() + ds = _commit_segments_helper(ds, segments, column="vector") q = np.random.rand(128).astype(np.float32) result = ds.to_table(nearest={"column": "vector", "q": q, "k": 5}) assert 0 < len(result) <= 5 @@ -2605,8 +2602,6 @@ def test_distributed_ivf_parameterized(tmp_path, index_type, num_sub_vectors): mid = len(frags) // 2 node1 = [f.fragment_id for f in frags[:mid]] node2 = [f.fragment_id for f in frags[mid:]] - shared_uuid = str(uuid.uuid4()) - builder = IndicesBuilder(ds, "vector") pre = builder.prepare_global_ivf_pq( num_partitions=4, @@ -2620,7 +2615,6 @@ def test_distributed_ivf_parameterized(tmp_path, index_type, num_sub_vectors): base_kwargs = dict( column="vector", index_type=index_type, - index_uuid=shared_uuid, num_partitions=4, num_sub_vectors=num_sub_vectors, ) @@ -2636,11 +2630,12 @@ def test_distributed_ivf_parameterized(tmp_path, index_type, num_sub_vectors): ivf_centroids=pre["ivf_centroids"], pq_codebook=pre["pq_codebook"] ) - ds.create_index(**kwargs1) - ds.create_index(**kwargs2) - - ds._ds.merge_index_metadata(shared_uuid, index_type, None) - ds = _commit_index_helper(ds, shared_uuid, "vector") + segments = [ + ds.create_index_uncommitted(**kwargs1), + ds.create_index_uncommitted(**kwargs2), + ] + segments = ds.create_index_segment_builder().with_segments(segments).build_all() + ds = _commit_segments_helper(ds, segments, "vector") q = np.random.rand(128).astype(np.float32) results = ds.to_table(nearest={"column": "vector", "q": q, "k": 10}) @@ -2649,45 +2644,6 @@ def test_distributed_ivf_parameterized(tmp_path, index_type, num_sub_vectors): raise e -def _commit_index_helper( - ds, index_uuid: str, column: str, index_name: Optional[str] = None -): - """Helper to finalize index commit after merge_index_metadata. - - Builds a lance.dataset.Index record and commits a CreateIndex operation. - Returns the updated dataset object. - """ - - # Resolve field id for the target column - lance_field = ds.lance_schema.field(column) - if lance_field is None: - raise KeyError(f"{column} not found in schema") - field_id = lance_field.id() - - # Default index name if not provided - if index_name is None: - index_name = f"{column}_idx" - - # Build fragment id set - frag_ids = set(f.fragment_id for f in ds.get_fragments()) - - # Construct Index dataclass and commit operation - index = Index( - uuid=index_uuid, - name=index_name, - fields=[field_id], - dataset_version=ds.version, - fragment_ids=frag_ids, - index_version=0, - ) - create_index_op = lance.LanceOperation.CreateIndex( - new_indices=[index], removed_indices=[] - ) - ds = lance.LanceDataset.commit(ds.uri, create_index_op, read_version=ds.version) - # Ensure unified index partitions are materialized - return ds - - @pytest.mark.parametrize( "index_type,num_sub_vectors", [ @@ -2701,8 +2657,6 @@ def test_merge_two_shards_parameterized(tmp_path, index_type, num_sub_vectors): assert len(frags) >= 2 shard1 = [frags[0].fragment_id] shard2 = [frags[1].fragment_id] - shared_uuid = str(uuid.uuid4()) - builder = IndicesBuilder(ds, "vector") pre = builder.prepare_global_ivf_pq( num_partitions=4, @@ -2715,7 +2669,6 @@ def test_merge_two_shards_parameterized(tmp_path, index_type, num_sub_vectors): base_kwargs = { "column": "vector", "index_type": index_type, - "index_uuid": shared_uuid, "num_partitions": 4, } @@ -2729,7 +2682,7 @@ def test_merge_two_shards_parameterized(tmp_path, index_type, num_sub_vectors): # only PQ has pq_codebook if "pq_codebook" in pre: kwargs1["pq_codebook"] = pre["pq_codebook"] - ds.create_index(**kwargs1) + segment1 = ds.create_index_uncommitted(**kwargs1) # second shard kwargs2 = dict(base_kwargs) @@ -2740,10 +2693,14 @@ def test_merge_two_shards_parameterized(tmp_path, index_type, num_sub_vectors): kwargs2["ivf_centroids"] = pre["ivf_centroids"] if "pq_codebook" in pre: kwargs2["pq_codebook"] = pre["pq_codebook"] - ds.create_index(**kwargs2) + segment2 = ds.create_index_uncommitted(**kwargs2) - ds._ds.merge_index_metadata(shared_uuid, index_type, None) - ds = _commit_index_helper(ds, shared_uuid, column="vector") + segments = ( + ds.create_index_segment_builder() + .with_segments([segment1, segment2]) + .build_all() + ) + ds = _commit_segments_helper(ds, segments, column="vector") q = np.random.rand(128).astype(np.float32) results = ds.to_table(nearest={"column": "vector", "q": q, "k": 5}) @@ -2754,8 +2711,6 @@ def test_index_segment_builder_builds_vector_segments(tmp_path): ds = _make_sample_dataset_base(tmp_path, "segment_builder_ds", 2000, 128) frags = ds.get_fragments() assert len(frags) >= 2 - shared_uuid = str(uuid.uuid4()) - builder = IndicesBuilder(ds, "vector") preprocessed = builder.prepare_global_ivf_pq( num_partitions=4, @@ -2765,14 +2720,13 @@ def test_index_segment_builder_builds_vector_segments(tmp_path): max_iters=20, ) - partial_indices = [ + segments = [ ds.create_index_uncommitted( "vector", "IVF_FLAT", name="vector_idx", train=True, fragment_ids=[fragment.fragment_id], - index_uuid=shared_uuid, num_partitions=4, num_sub_vectors=128, ivf_centroids=preprocessed["ivf_centroids"], @@ -2781,12 +2735,10 @@ def test_index_segment_builder_builds_vector_segments(tmp_path): for fragment in frags[:2] ] - segment_builder = ds.create_index_segment_builder(shared_uuid).with_partial_indices( - partial_indices - ) + segment_builder = ds.create_index_segment_builder().with_segments(segments) plans = segment_builder.plan() assert len(plans) == 2 - assert all(len(plan.partial_indices) == 1 for plan in plans) + assert all(len(plan.segments) == 1 for plan in plans) segments = segment_builder.build_all() assert len(segments) == 2 @@ -2839,21 +2791,24 @@ def test_distributed_ivf_pq_order_invariance(tmp_path: Path): pytest.skip("Failed to split fragments into two non-empty groups (order_21)") def build_distributed_ivf_pq(ds_copy, shard_order): - shared_uuid = str(uuid.uuid4()) try: - for shard in shard_order: - ds_copy.create_index( - column="vector", - index_type="IVF_PQ", - fragment_ids=shard, - index_uuid=shared_uuid, - num_partitions=4, - num_sub_vectors=16, - ivf_centroids=pre["ivf_centroids"], - pq_codebook=pre["pq_codebook"], - ) - ds_copy.merge_index_metadata(shared_uuid, "IVF_PQ") - return _commit_index_helper(ds_copy, shared_uuid, column="vector") + segments = _build_segments( + ds_copy, + "vector", + "IVF_PQ", + shard_order, + index_name="vector_idx", + num_partitions=4, + num_sub_vectors=16, + ivf_centroids=pre["ivf_centroids"], + pq_codebook=pre["pq_codebook"], + ) + segments = ( + ds_copy.create_index_segment_builder() + .with_segments(segments) + .build_all() + ) + return _commit_segments_helper(ds_copy, segments, column="vector") except ValueError as e: raise e diff --git a/python/src/dataset.rs b/python/src/dataset.rs index 3b76f3ce043..695d8b317c5 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -327,27 +327,34 @@ impl MergeInsertBuilder { #[derive(Clone)] pub struct PyIndexSegmentBuilder { dataset: Arc, - staging_index_uuid: String, - partial_indices: Vec, + segments: Vec, target_segment_bytes: Option, } -#[pymethods] impl PyIndexSegmentBuilder { - #[getter] - fn staging_index_uuid(&self) -> String { - self.staging_index_uuid.clone() + fn builder(&self) -> ::IndexSegmentBuilder<'_> { + let mut builder = self + .dataset + .create_index_segment_builder() + .with_segments(self.segments.clone()); + if let Some(target_segment_bytes) = self.target_segment_bytes { + builder = builder.with_target_segment_bytes(target_segment_bytes); + } + builder } +} - fn with_partial_indices<'a>( +#[pymethods] +impl PyIndexSegmentBuilder { + fn with_segments<'a>( mut slf: PyRefMut<'a, Self>, - partial_indices: &Bound<'_, PyAny>, + segments: &Bound<'_, PyAny>, ) -> PyResult> { let mut indices = Vec::new(); - for item in partial_indices.try_iter()? { + for item in segments.try_iter()? { indices.push(item?.extract::>()?.0); } - slf.partial_indices = indices; + slf.segments = indices; Ok(slf) } @@ -360,14 +367,9 @@ impl PyIndexSegmentBuilder { } fn plan(&self, py: Python<'_>) -> PyResult>> { - let mut builder = self - .dataset - .create_index_segment_builder(self.staging_index_uuid.clone()) - .with_partial_indices(self.partial_indices.clone()); - if let Some(target_segment_bytes) = self.target_segment_bytes { - builder = builder.with_target_segment_bytes(target_segment_bytes); - } - let plans = rt().block_on(Some(py), builder.plan())?.infer_error()?; + let plans = rt() + .block_on(Some(py), self.builder().plan())? + .infer_error()?; plans .into_iter() .map(|plan| Py::new(py, PyIndexSegmentPlan::from_inner(plan))) @@ -376,26 +378,15 @@ impl PyIndexSegmentBuilder { fn build(&self, py: Python<'_>, plan: &Bound<'_, PyAny>) -> PyResult> { let plan = plan.extract::>()?; - let builder = self - .dataset - .create_index_segment_builder(self.staging_index_uuid.clone()) - .with_partial_indices(self.partial_indices.clone()); let segment = rt() - .block_on(Some(py), builder.build(&plan.inner))? + .block_on(Some(py), self.builder().build(&plan.inner))? .infer_error()?; Py::new(py, PyIndexSegment::from_inner(segment)) } fn build_all(&self, py: Python<'_>) -> PyResult>> { - let mut builder = self - .dataset - .create_index_segment_builder(self.staging_index_uuid.clone()) - .with_partial_indices(self.partial_indices.clone()); - if let Some(target_segment_bytes) = self.target_segment_bytes { - builder = builder.with_target_segment_bytes(target_segment_bytes); - } let segments = rt() - .block_on(Some(py), builder.build_all())? + .block_on(Some(py), self.builder().build_all())? .infer_error()?; segments .into_iter() @@ -2100,14 +2091,10 @@ impl Dataset { Ok(PyLance(index_metadata)) } - fn create_index_segment_builder( - &self, - staging_index_uuid: String, - ) -> PyResult { + fn create_index_segment_builder(&self) -> PyResult { Ok(PyIndexSegmentBuilder { dataset: self.ds.clone(), - staging_index_uuid, - partial_indices: Vec::new(), + segments: Vec::new(), target_segment_bytes: None, }) } diff --git a/python/src/indices.rs b/python/src/indices.rs index 9589b78ff36..f1d42918962 100644 --- a/python/src/indices.rs +++ b/python/src/indices.rs @@ -111,24 +111,14 @@ impl PyIndexSegmentPlan { #[pymethods] impl PyIndexSegmentPlan { - #[getter] - fn staging_index_uuid(&self) -> String { - self.inner.staging_index_uuid().to_string() - } - #[getter] fn segment(&self) -> PyIndexSegment { PyIndexSegment::from_inner(self.inner.segment().clone()) } #[getter] - fn partial_indices(&self) -> Vec> { - self.inner - .partial_indices() - .iter() - .cloned() - .map(PyLance) - .collect() + fn segments(&self) -> Vec> { + self.inner.segments().iter().cloned().map(PyLance).collect() } #[getter] @@ -137,9 +127,8 @@ impl PyIndexSegmentPlan { } fn __repr__(&self) -> String { format!( - "IndexSegmentPlan(staging_index_uuid={}, partial_indices={}, estimated_bytes={})", - self.staging_index_uuid(), - self.inner.partial_indices().len(), + "IndexSegmentPlan(segments={}, estimated_bytes={})", + self.inner.segments().len(), self.estimated_bytes() ) } diff --git a/rust/lance-index/src/traits.rs b/rust/lance-index/src/traits.rs index 1c5923e4050..a883c3ce494 100644 --- a/rust/lance-index/src/traits.rs +++ b/rust/lance-index/src/traits.rs @@ -148,20 +148,17 @@ pub trait DatasetIndexExt { params: &'a dyn IndexParams, ) -> Self::IndexBuilder<'a>; - /// Create a builder for building index segments from partial index outputs. + /// Create a builder for building physical index segments from uncommitted + /// vector index outputs. /// - /// The staging UUID identifies a directory containing previously-built shard - /// outputs. The caller supplies the partial index metadata returned by + /// The caller supplies the uncommitted index metadata returned by /// `execute_uncommitted()` so the builder can plan segment grouping without - /// rediscovering shard coverage. + /// rediscovering fragment coverage. /// /// This is the canonical entry point for distributed vector segment build. /// After building the physical segments, publish them as a /// logical index with [`Self::commit_existing_index_segments`]. - fn create_index_segment_builder<'a>( - &'a self, - staging_index_uuid: String, - ) -> Self::IndexSegmentBuilder<'a>; + fn create_index_segment_builder<'a>(&'a self) -> Self::IndexSegmentBuilder<'a>; /// Create indices on columns. /// diff --git a/rust/lance-index/src/types.rs b/rust/lance-index/src/types.rs index 6520878991b..4d653f21f13 100644 --- a/rust/lance-index/src/types.rs +++ b/rust/lance-index/src/types.rs @@ -76,13 +76,12 @@ impl IndexSegment { } } -/// A plan for building one physical segment from one or more vector index -/// partial indices. +/// A plan for building one physical segment from one or more existing +/// vector index segments. #[derive(Debug, Clone, PartialEq)] pub struct IndexSegmentPlan { - staging_index_uuid: Uuid, segment: IndexSegment, - partial_indices: Vec, + segments: Vec, estimated_bytes: u64, requested_index_type: Option, } @@ -90,34 +89,27 @@ pub struct IndexSegmentPlan { impl IndexSegmentPlan { /// Create a plan for one built segment. pub fn new( - staging_index_uuid: Uuid, segment: IndexSegment, - partial_indices: Vec, + segments: Vec, estimated_bytes: u64, requested_index_type: Option, ) -> Self { Self { - staging_index_uuid, segment, - partial_indices, + segments, estimated_bytes, requested_index_type, } } - /// Return the staging index UUID that owns the partial shard outputs. - pub fn staging_index_uuid(&self) -> Uuid { - self.staging_index_uuid - } - /// Return the segment metadata that should be committed after this plan is built. pub fn segment(&self) -> &IndexSegment { &self.segment } - /// Return the uncommitted partial index metadata that should be combined into the segment. - pub fn partial_indices(&self) -> &[IndexMetadata] { - &self.partial_indices + /// Return the input segment metadata that should be combined into the segment. + pub fn segments(&self) -> &[IndexMetadata] { + &self.segments } /// Return the estimated number of bytes covered by this plan. diff --git a/rust/lance-index/src/vector/distributed/index_merger.rs b/rust/lance-index/src/vector/distributed/index_merger.rs index 2edcf3e0707..a122fbe04d9 100755 --- a/rust/lance-index/src/vector/distributed/index_merger.rs +++ b/rust/lance-index/src/vector/distributed/index_merger.rs @@ -602,16 +602,16 @@ async fn read_shard_window_partitions( Ok(per_partition_batches) } -/// Merge the selected partial-shard auxiliary files into `target_dir`. +/// Merge the selected segment auxiliary files into `target_dir`. /// -/// This is the storage merge kernel for vector staged segment build. Callers -/// choose which partial shards belong to one built segment and pass the corresponding -/// auxiliary files here. The merge writes one unified `auxiliary.idx` into -/// `target_dir`. +/// This is the storage merge kernel for vector segment build. Callers choose +/// which segments belong to one built segment and pass the +/// corresponding auxiliary files here. The merge writes one unified +/// `auxiliary.idx` into `target_dir`. /// /// Supports IVF_FLAT, IVF_PQ, IVF_SQ, IVF_HNSW_FLAT, IVF_HNSW_PQ, and -/// IVF_HNSW_SQ storage types. For PQ and SQ, this assumes all selected partial -/// shards share the same quantizer/codebook and distance type; it reuses the +/// IVF_HNSW_SQ storage types. For PQ and SQ, this assumes all selected source +/// segments share the same quantizer/codebook and distance type; it reuses the /// first encountered metadata. pub async fn merge_partial_vector_auxiliary_files( object_store: &lance_io::object_store::ObjectStore, diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index cf7888d97f7..02f4b28e047 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -2684,7 +2684,7 @@ impl Dataset { self.merge_impl(stream, left_on, right_on).await } - /// Merge a staged distributed index into a single root artifact. + /// Merge a distributed scalar index into a single root artifact. pub async fn merge_index_metadata( &self, index_uuid: &str, @@ -2713,61 +2713,13 @@ impl Dataset { ) .await } - // Precise vector index types: IVF_FLAT, IVF_PQ, IVF_SQ IndexType::IvfFlat | IndexType::IvfPq | IndexType::IvfSq | IndexType::Vector => { - let mut partial_indices = self - .object_store() - .read_dir(index_dir.clone()) - .await? - .into_iter() - .filter(|name| name.starts_with("partial_")) - .map(|name| { - name.strip_prefix("partial_") - .ok_or_else(|| { - Error::index(format!( - "Distributed vector shard '{}' does not start with 'partial_'", - name - )) - }) - .and_then(|shard_uuid| { - uuid::Uuid::parse_str(shard_uuid).map_err(|err| { - Error::index(format!( - "Distributed vector shard '{}' does not end with a valid UUID: {}", - name, err - )) - }) - }) - .map(|shard_uuid| IndexMetadata { - uuid: shard_uuid, - name: String::new(), - fields: Vec::new(), - dataset_version: self.manifest.version, - fragment_bitmap: Some(RoaringBitmap::new()), - index_details: None, - index_version: index_type.version(), - created_at: None, - base_id: None, - files: Some(Vec::new()), - }) - }) - .collect::>>()?; - partial_indices.sort_by_key(|index| index.uuid); - let segment_plans = crate::index::vector::ivf::plan_staging_segments( - &index_dir, - &partial_indices, - Some(index_type), - None, - ) - .await?; - let merged_plan = - crate::index::vector::ivf::collapse_segment_plans(&segment_plans)?; - crate::index::vector::ivf::build_staging_segment( - self.object_store(), - &self.indices_dir(), - &merged_plan, - ) - .await - .map(|_| ()) + Err(Error::invalid_input( + "Vector distributed indexing no longer supports merge_index_metadata; \ + build segments, use create_index_segment_builder(), \ + and commit with commit_existing_index_segments(...)" + .to_string(), + )) } _ => Err(Error::invalid_input_source(Box::new(std::io::Error::new( std::io::ErrorKind::InvalidInput, diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index 5ae8ee91b05..28da8f61e1b 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -87,6 +87,57 @@ use crate::session::index_caches::{FragReuseIndexKey, IndexMetadataKey}; use crate::{Error, Result, dataset::Dataset}; pub use create::CreateIndexBuilder; +fn validate_index_segments(index_name: &str, segments: &[IndexSegment]) -> Result<()> { + if segments.is_empty() { + return Err(Error::invalid_input( + "CreateIndex: at least one index segment is required".to_string(), + )); + } + + let mut seen_segment_ids = HashSet::with_capacity(segments.len()); + for segment in segments { + if !seen_segment_ids.insert(segment.uuid()) { + return Err(Error::invalid_input(format!( + "CreateIndex: duplicate segment uuid {} for index '{}'", + segment.uuid(), + index_name + ))); + } + } + + Ok(()) +} + +pub(crate) async fn build_index_metadata_from_segments( + dataset: &Dataset, + index_name: &str, + field_id: i32, + segments: Vec, +) -> Result> { + validate_index_segments(index_name, &segments)?; + + let mut new_indices = Vec::with_capacity(segments.len()); + for segment in segments { + let (uuid, fragment_bitmap, index_details, index_version) = segment.into_parts(); + let index_dir = dataset.indices_dir().child(uuid.to_string()); + let files = list_index_files_with_sizes(&dataset.object_store, &index_dir).await?; + new_indices.push(IndexMetadata { + uuid, + name: index_name.to_string(), + fields: vec![field_id], + dataset_version: dataset.manifest.version, + fragment_bitmap: Some(fragment_bitmap), + index_details: Some(index_details), + index_version, + created_at: Some(chrono::Utc::now()), + base_id: None, + files: Some(files), + }); + } + + Ok(new_indices) +} + // Cache keys for different index types #[derive(Debug, Clone)] pub struct ScalarIndexCacheKey<'a> { @@ -628,11 +679,8 @@ impl DatasetIndexExt for Dataset { CreateIndexBuilder::new(self, columns, index_type, params) } - fn create_index_segment_builder<'a>( - &'a self, - staging_index_uuid: String, - ) -> create::IndexSegmentBuilder<'a> { - create::IndexSegmentBuilder::new(self, staging_index_uuid) + fn create_index_segment_builder<'a>(&'a self) -> create::IndexSegmentBuilder<'a> { + create::IndexSegmentBuilder::new(self) } #[instrument(skip_all)] @@ -800,35 +848,8 @@ impl DatasetIndexExt for Dataset { ))); }; - let mut seen_segment_ids = HashSet::with_capacity(segments.len()); - for segment in &segments { - if !seen_segment_ids.insert(segment.uuid()) { - return Err(Error::invalid_input(format!( - "CreateIndex: duplicate segment uuid {} for index '{}'", - segment.uuid(), - index_name - ))); - } - } - - let new_indices = segments - .into_iter() - .map(|segment| { - let (uuid, fragment_bitmap, index_details, index_version) = segment.into_parts(); - IndexMetadata { - uuid, - name: index_name.to_string(), - fields: vec![field.id], - dataset_version: self.manifest.version, - fragment_bitmap: Some(fragment_bitmap), - index_details: Some(index_details), - index_version, - created_at: Some(chrono::Utc::now()), - base_id: None, - files: None, // File info will be populated when index is created - } - }) - .collect(); + let new_indices = + build_index_metadata_from_segments(self, index_name, field.id, segments).await?; let transaction = Transaction::new( self.manifest.version, @@ -5225,6 +5246,24 @@ mod tests { Arc::new(vector_index_details()), IndexType::Vector.version(), ); + let seg0_path = dataset + .indices_dir() + .child(seg0.uuid().to_string()) + .child(INDEX_FILE_NAME); + let seg1_path = dataset + .indices_dir() + .child(seg1.uuid().to_string()) + .child(INDEX_FILE_NAME); + dataset + .object_store() + .put(&seg0_path, b"seg0") + .await + .unwrap(); + dataset + .object_store() + .put(&seg1_path, b"seg1") + .await + .unwrap(); dataset .commit_existing_index_segments( @@ -5256,6 +5295,12 @@ mod tests { HashSet::from([vec![0], vec![1]]), "each committed segment should preserve its fragment coverage" ); + assert!( + committed + .iter() + .all(|idx| idx.files.as_ref().is_some_and(|files| !files.is_empty())), + "committed segment metadata should capture on-disk file info" + ); } #[tokio::test] diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index 41c44994e69..a394f52258e 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -8,7 +8,7 @@ use crate::{ transaction::{Operation, TransactionBuilder}, }, index::{ - DatasetIndexExt, DatasetIndexInternalExt, + DatasetIndexExt, DatasetIndexInternalExt, build_index_metadata_from_segments, scalar::build_scalar_index, vector::{ LANCE_VECTOR_INDEX, VectorIndexParams, build_distributed_vector_index, @@ -337,7 +337,7 @@ impl<'a> CreateIndexBuilder<'a> { if let Some(fragments) = &self.fragments { // For distributed indexing, build only on specified fragments // This creates temporary index metadata without committing - let shard_uuid = Box::pin(build_distributed_vector_index( + let segment_uuid = Box::pin(build_distributed_vector_index( self.dataset, column, &index_name, @@ -348,7 +348,7 @@ impl<'a> CreateIndexBuilder<'a> { self.progress.clone(), )) .await?; - output_index_uuid = shard_uuid; + output_index_uuid = segment_uuid; } else { // Standard full dataset indexing Box::pin(build_vector_index( @@ -374,14 +374,10 @@ impl<'a> CreateIndexBuilder<'a> { .await?; } // Capture file sizes after vector index creation - let index_dir = if self.fragments.is_some() { - self.dataset - .indices_dir() - .child(index_id.to_string()) - .child(format!("partial_{}", output_index_uuid)) - } else { - self.dataset.indices_dir().child(index_id.to_string()) - }; + let index_dir = self + .dataset + .indices_dir() + .child(output_index_uuid.to_string()); let files = list_index_files_with_sizes(&self.dataset.object_store, &index_dir).await?; CreatedIndex { @@ -478,15 +474,42 @@ impl<'a> CreateIndexBuilder<'a> { } else { vec![] }; - let transaction = TransactionBuilder::new( - new_idx.dataset_version, - Operation::CreateIndex { - new_indices: vec![new_idx], - removed_indices, - }, - ) - .transaction_properties(self.transaction_properties.clone()) - .build(); + let transaction = if uses_segment_commit_path(self.index_type) { + let field_id = *new_idx.fields.first().ok_or_else(|| { + Error::internal(format!( + "Index '{}' is missing field ids after build", + new_idx.name + )) + })?; + let segments = self + .dataset + .create_index_segment_builder() + .with_segments(vec![new_idx.clone()]) + .build_all() + .await?; + let new_indices = + build_index_metadata_from_segments(self.dataset, &new_idx.name, field_id, segments) + .await?; + TransactionBuilder::new( + new_idx.dataset_version, + Operation::CreateIndex { + new_indices, + removed_indices, + }, + ) + .transaction_properties(self.transaction_properties.clone()) + .build() + } else { + TransactionBuilder::new( + new_idx.dataset_version, + Operation::CreateIndex { + new_indices: vec![new_idx], + removed_indices, + }, + ) + .transaction_properties(self.transaction_properties.clone()) + .build() + }; self.dataset .apply_commit(transaction, &Default::default(), &Default::default()) @@ -508,6 +531,20 @@ impl<'a> CreateIndexBuilder<'a> { } } +fn uses_segment_commit_path(index_type: IndexType) -> bool { + matches!( + index_type, + IndexType::Vector + | IndexType::IvfPq + | IndexType::IvfSq + | IndexType::IvfFlat + | IndexType::IvfRq + | IndexType::IvfHnswFlat + | IndexType::IvfHnswPq + | IndexType::IvfHnswSq + ) +} + impl<'a> IntoFuture for CreateIndexBuilder<'a> { type Output = Result; type IntoFuture = BoxFuture<'a, Result>; @@ -517,10 +554,9 @@ impl<'a> IntoFuture for CreateIndexBuilder<'a> { } } -/// Build physical index segments from previously-written partial index outputs. +/// Build physical index segments from previously-written vector segment outputs. /// -/// Use [`DatasetIndexExt::create_index_segment_builder`] to open a staging root -/// and then either: +/// Use [`DatasetIndexExt::create_index_segment_builder`] and then either: /// /// - call [`Self::plan`] and orchestrate individual segment builds externally, or /// - call [`Self::build_all`] to build all segments on the current node. @@ -531,63 +567,55 @@ impl<'a> IntoFuture for CreateIndexBuilder<'a> { #[derive(Clone)] pub struct IndexSegmentBuilder<'a> { dataset: &'a Dataset, - staging_index_uuid: String, - partial_indices: Vec, + segments: Vec, target_segment_bytes: Option, } impl<'a> IndexSegmentBuilder<'a> { - pub(crate) fn new(dataset: &'a Dataset, staging_index_uuid: String) -> Self { + pub(crate) fn new(dataset: &'a Dataset) -> Self { Self { dataset, - staging_index_uuid, - partial_indices: Vec::new(), + segments: Vec::new(), target_segment_bytes: None, } } - /// Provide the partial index metadata returned by `execute_uncommitted()` - /// for this staging root. - pub fn with_partial_indices(mut self, partial_indices: Vec) -> Self { - self.partial_indices = partial_indices; + /// Provide the segment metadata returned by `execute_uncommitted()`. + /// + /// These segments must already exist in storage and must not have been + /// published into a logical index yet. + pub fn with_segments(mut self, segments: Vec) -> Self { + self.segments = segments; self } - /// Set the target size, in bytes, for merged built segments. + /// Set the target size, in bytes, for merged physical segments. /// - /// When set, shard outputs will be grouped into larger built segments up to - /// approximately this size. When unset, each shard output becomes one built - /// segment. + /// When set, input segments will be grouped into larger physical segments + /// up to approximately this size. When unset, each input segment becomes + /// one physical segment. pub fn with_target_segment_bytes(mut self, bytes: u64) -> Self { self.target_segment_bytes = Some(bytes); self } - /// Plan how partial indices should be grouped into built segments. + /// Plan how input segments should be grouped into physical segments. pub async fn plan(&self) -> Result> { - if self.partial_indices.is_empty() { + if self.segments.is_empty() { return Err(Error::invalid_input( - "IndexSegmentBuilder requires at least one partial index; \ - call with_partial_indices(...) with execute_uncommitted() outputs" + "IndexSegmentBuilder requires at least one segment; \ + call with_segments(...) with execute_uncommitted() outputs" .to_string(), )); } - crate::index::vector::ivf::plan_staging_segments( - &self - .dataset - .indices_dir() - .child(self.staging_index_uuid.as_str()), - &self.partial_indices, - None, - self.target_segment_bytes, - ) - .await + crate::index::vector::ivf::plan_segments(&self.segments, None, self.target_segment_bytes) + .await } /// Build one segment from a previously-generated plan. pub async fn build(&self, plan: &IndexSegmentPlan) -> Result { - crate::index::vector::ivf::build_staging_segment( + crate::index::vector::ivf::build_segment( self.dataset.object_store(), &self.dataset.indices_dir(), plan, @@ -595,7 +623,7 @@ impl<'a> IndexSegmentBuilder<'a> { .await } - /// Plan and build all segments from this staging root. + /// Plan and build all segments from the provided inputs. pub async fn build_all(&self) -> Result> { let plans = self.plan().await?; try_join_all(plans.iter().map(|plan| self.build(plan))).await @@ -1071,7 +1099,7 @@ mod tests { } #[tokio::test] - async fn test_merge_index_metadata_vector_preserves_shared_uuid_commit_workflow() { + async fn test_vector_execute_uncommitted_segments_commit_without_staging() { let tmpdir = TempStrDir::default(); let dataset_uri = format!("file://{}", tmpdir.as_str()); @@ -1099,62 +1127,54 @@ mod tests { let fragments = dataset.get_fragments(); assert!(fragments.len() >= 2); - let shared_uuid = Uuid::new_v4(); let params = VectorIndexParams::with_ivf_flat_params( DistanceType::L2, prepare_vector_ivf(&dataset, "vector").await, ); + let mut input_segments = Vec::new(); for fragment in &fragments { - let mut builder = + let segment = CreateIndexBuilder::new(&mut dataset, &["vector"], IndexType::Vector, ¶ms) .name("vector_idx".to_string()) .fragments(vec![fragment.id() as u32]) - .index_uuid(shared_uuid.to_string()); - builder.execute_uncommitted().await.unwrap(); + .execute_uncommitted() + .await + .unwrap(); + let segment_index = dataset + .indices_dir() + .child(segment.uuid.to_string()) + .child(crate::index::INDEX_FILE_NAME); + assert!(dataset.object_store().exists(&segment_index).await.unwrap()); + input_segments.push(segment); } - dataset - .merge_index_metadata(&shared_uuid.to_string(), IndexType::IvfFlat, None) + let segments = dataset + .create_index_segment_builder() + .with_segments(input_segments.clone()) + .build_all() .await .unwrap(); - - let merged_root = dataset - .indices_dir() - .child(shared_uuid.to_string()) - .child(crate::index::INDEX_FILE_NAME); - assert!(dataset.object_store().exists(&merged_root).await.unwrap()); + assert_eq!(segments.len(), fragments.len()); + let mut built_segment_ids = segments + .iter() + .map(|segment| segment.uuid()) + .collect::>(); + built_segment_ids.sort(); + let mut input_segment_ids = input_segments + .iter() + .map(|segment| segment.uuid) + .collect::>(); + input_segment_ids.sort(); + assert_eq!(built_segment_ids, input_segment_ids); dataset - .commit_existing_index_segments( - "vector_idx", - "vector", - vec![IndexSegment::new( - shared_uuid, - fragments.iter().map(|fragment| fragment.id() as u32), - Arc::new(vector_index_details()), - IndexType::IvfFlat.version(), - )], - ) + .commit_existing_index_segments("vector_idx", "vector", segments) .await .unwrap(); let indices = dataset.load_indices_by_name("vector_idx").await.unwrap(); - assert_eq!(indices.len(), 1); - assert_eq!(indices[0].uuid, shared_uuid); - let committed_fragments: Vec = indices[0] - .fragment_bitmap - .as_ref() - .unwrap() - .iter() - .collect(); - assert_eq!( - committed_fragments, - fragments - .iter() - .map(|fragment| fragment.id() as u32) - .collect::>() - ); + assert_eq!(indices.len(), fragments.len()); let query_batch = dataset .scan() @@ -1207,28 +1227,26 @@ mod tests { let fragments = dataset.get_fragments(); assert!(fragments.len() >= 2); - let shared_uuid = Uuid::new_v4(); let params = VectorIndexParams::with_ivf_flat_params( DistanceType::L2, prepare_vector_ivf(&dataset, "vector").await, ); - let mut partial_indices = Vec::new(); + let mut input_segments = Vec::new(); for fragment in fragments.iter().take(2) { - let index_metadata = + let segment = CreateIndexBuilder::new(&mut dataset, &["vector"], IndexType::Vector, ¶ms) .name("vector_idx".to_string()) .fragments(vec![fragment.id() as u32]) - .index_uuid(shared_uuid.to_string()) .execute_uncommitted() .await .unwrap(); - partial_indices.push(index_metadata); + input_segments.push(segment); } let segments = dataset - .create_index_segment_builder(shared_uuid.to_string()) - .with_partial_indices(partial_indices) + .create_index_segment_builder() + .with_segments(input_segments) .build_all() .await .unwrap(); @@ -1341,6 +1359,84 @@ mod tests { ); } + #[tokio::test] + async fn test_create_index_vector_commits_with_segment_metadata() { + let tmpdir = TempStrDir::default(); + let dataset_uri = format!("file://{}", tmpdir.as_str()); + + let reader = gen_batch() + .col("id", lance_datagen::array::step::()) + .col( + "vector", + lance_datagen::array::rand_vec::(lance_datagen::Dimension::from(16)), + ) + .into_reader_rows( + lance_datagen::RowCount::from(128), + lance_datagen::BatchCount::from(2), + ); + let mut dataset = Dataset::write(reader, &dataset_uri, None).await.unwrap(); + + let params = VectorIndexParams::with_ivf_flat_params( + DistanceType::L2, + prepare_vector_ivf(&dataset, "vector").await, + ); + + let committed = dataset + .create_index(&["vector"], IndexType::Vector, None, ¶ms, false) + .await + .unwrap(); + + assert!( + committed + .files + .as_ref() + .is_some_and(|files| !files.is_empty()), + "single-machine vector create_index should preserve committed file info" + ); + + let loaded = dataset.load_indices_by_name(&committed.name).await.unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].uuid, committed.uuid); + assert!( + loaded[0] + .files + .as_ref() + .is_some_and(|files| !files.is_empty()), + "committed metadata loaded from the manifest should include file info" + ); + } + + #[tokio::test] + async fn test_create_index_ivf_rq_preserves_index_version_on_segment_commit_path() { + let tmpdir = TempStrDir::default(); + let dataset_uri = format!("file://{}", tmpdir.as_str()); + + let reader = gen_batch() + .col("id", lance_datagen::array::step::()) + .col( + "vector", + lance_datagen::array::rand_vec::(lance_datagen::Dimension::from(16)), + ) + .into_reader_rows( + lance_datagen::RowCount::from(128), + lance_datagen::BatchCount::from(2), + ); + let mut dataset = Dataset::write(reader, &dataset_uri, None).await.unwrap(); + + let params = VectorIndexParams::ivf_rq(4, 1, DistanceType::L2); + + let committed = dataset + .create_index(&["vector"], IndexType::IvfRq, None, ¶ms, false) + .await + .unwrap(); + + assert_eq!(committed.index_version, IndexType::IvfRq.version()); + + let loaded = dataset.load_indices_by_name(&committed.name).await.unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].index_version, IndexType::IvfRq.version()); + } + #[tokio::test] async fn test_optimize_should_not_removes_delta_indices() { let tmpdir = TempStrDir::default(); diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index c0c957a7fcf..a19f472a9bf 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -431,9 +431,9 @@ pub(crate) async fn build_distributed_vector_index( let filtered_dataset = dataset.clone(); - let out_base = dataset.indices_dir().child(uuid); - let shard_uuid = Uuid::new_v4(); - let index_dir = out_base.child(format!("partial_{}", shard_uuid)); + let segment_uuid = Uuid::parse_str(uuid) + .map_err(|err| Error::invalid_input(format!("Invalid index UUID '{uuid}': {err}")))?; + let index_dir = dataset.indices_dir().child(segment_uuid.to_string()); let fragment_filter = fragment_ids.to_vec(); @@ -700,7 +700,7 @@ pub(crate) async fn build_distributed_vector_index( } }; - Ok(shard_uuid) + Ok(segment_uuid) } /// Build a Vector Index diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index b693de2e0a0..8e47b77cdfd 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -1862,48 +1862,41 @@ async fn write_ivf_hnsw_file( /// Distributed vector segment build uses three storage-level concepts: /// -/// - A **staging root** is the shared UUID directory used during distributed -/// shard build. Each worker writes one `partial_/` directory under this -/// root by calling `execute_uncommitted()` with the same `index_uuid`. -/// - A **partial shard** is one such worker output. The caller provides the -/// `IndexMetadata` returned by `execute_uncommitted()` so the planner knows -/// shard UUIDs, fragment coverage, and approximate shard sizes. -/// - A **built segment** is a physical index segment that can be committed into -/// the manifest with `commit_existing_index_segments(...)`. +/// - A **segment** is a worker output written by `execute_uncommitted()`. It +/// already lives at its final storage path under `indices//`, +/// but it is not yet published in the manifest. +/// - A **physical segment** is an `IndexSegment` that can be committed into the +/// manifest with `commit_existing_index_segments(...)`. +/// - A **logical index** is the user-visible index identified by name; it may +/// contain one or more physical segments. /// -/// The staged segment-build path is therefore: +/// The segment-build path is therefore: /// -/// 1. workers build `partial_*` shards under one staging root -/// 2. the caller groups those shards into one or more built segments -/// 3. each segment is built from its selected shards +/// 1. workers build segments +/// 2. the caller groups those segments into one or more physical segments +/// 3. each grouped segment is built from its selected inputs /// 4. the resulting physical segments are committed as one logical index /// -/// A single merge work item produced from one staging root. -/// /// Each plan says: -/// - which staging root it belongs to -/// - which partial shards should be consumed together -/// - what the built segment metadata should look like +/// - which source segments should be consumed together +/// - what the physical segment metadata should look like /// -/// The planner returns a `Vec` so callers can decide -/// whether to execute the work serially or fan it out externally. -/// Plan how one staging root should be turned into built physical segments. +/// The planner returns a `Vec` so callers can decide whether +/// to execute the work serially or fan it out externally. /// /// This function does not touch storage. It only: -/// - validates that the caller-supplied shard contract is self-consistent -/// - enforces that shard fragment coverage is disjoint -/// - groups shards into built segments according to `target_segment_bytes` +/// - validates that the caller-supplied segment contract is self-consistent +/// - enforces that source fragment coverage is disjoint +/// - groups source segments into physical segments according to +/// `target_segment_bytes` /// /// The grouping rule is intentionally simple: -/// - `target_segment_bytes = None`: keep the shard boundary, so each shard becomes one segment -/// - `target_segment_bytes = Some(limit)`: greedily pack consecutive shards until the next shard -/// would exceed `limit` -/// -/// Callers that want one built segment for the entire staging root should pass a -/// sufficiently large `target_segment_bytes`. -pub(crate) async fn plan_staging_segments( - index_dir: &Path, - partial_indices: &[TableIndexMetadata], +/// - `target_segment_bytes = None`: keep the existing segment boundary, so each +/// input segment becomes one physical segment +/// - `target_segment_bytes = Some(limit)`: greedily pack consecutive source +/// segments until the next source would exceed `limit` +pub(crate) async fn plan_segments( + segments: &[TableIndexMetadata], requested_index_type: Option, target_segment_bytes: Option, ) -> Result> { @@ -1913,6 +1906,7 @@ pub(crate) async fn plan_staging_segments( IndexType::IvfFlat | IndexType::IvfPq | IndexType::IvfSq + | IndexType::IvfRq | IndexType::IvfHnswFlat | IndexType::IvfHnswPq | IndexType::IvfHnswSq @@ -1931,43 +1925,28 @@ pub(crate) async fn plan_staging_segments( )); } - if partial_indices.is_empty() { - return Err(Error::index(format!( - "No partial index metadata was provided for '{}'", - index_dir - ))); + if segments.is_empty() { + return Err(Error::index("No segment metadata was provided".to_string())); } - let mut sorted_partial_indices = partial_indices.to_vec(); - sorted_partial_indices.sort_by_key(|index| index.uuid); - let mut expected_shard_ids = HashSet::with_capacity(sorted_partial_indices.len()); - for partial_index in &sorted_partial_indices { - if !expected_shard_ids.insert(partial_index.uuid) { + let mut sorted_segments = segments.to_vec(); + sorted_segments.sort_by_key(|index| index.uuid); + let mut expected_segment_ids = HashSet::with_capacity(sorted_segments.len()); + for segment in &sorted_segments { + if !expected_segment_ids.insert(segment.uuid) { return Err(Error::index(format!( - "Distributed vector partial shard '{}' was provided more than once", - partial_index.uuid + "Distributed vector segment '{}' was provided more than once", + segment.uuid ))); } } - let staging_index_uuid = index_dir - .filename() - .ok_or_else(|| Error::index(format!("Index directory '{}' has no filename", index_dir))) - .and_then(|name| { - Uuid::parse_str(name).map_err(|err| { - Error::index(format!( - "Index directory '{}' does not end with a valid UUID: {}", - index_dir, err - )) - }) - })?; - let mut covered_fragments = RoaringBitmap::new(); - for partial_index in &sorted_partial_indices { - let fragment_bitmap = partial_index.fragment_bitmap.as_ref().ok_or_else(|| { + for segment in &sorted_segments { + let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { Error::index(format!( - "Partial index '{}' is missing fragment coverage", - partial_index.uuid + "Segment '{}' is missing fragment coverage", + segment.uuid )) })?; if covered_fragments.intersection_len(fragment_bitmap) > 0 { @@ -1979,15 +1958,9 @@ pub(crate) async fn plan_staging_segments( } if target_segment_bytes.is_none() { - return sorted_partial_indices + return sorted_segments .into_iter() - .map(|partial_index| { - build_segment_plan( - staging_index_uuid, - vec![partial_index], - requested_index_type, - ) - }) + .map(|segment| build_segment_plan(vec![segment], requested_index_type)) .collect(); } @@ -1996,28 +1969,23 @@ pub(crate) async fn plan_staging_segments( let mut current_group = Vec::new(); let mut current_bytes = 0_u64; - for partial_index in sorted_partial_indices { - let partial_bytes = estimate_partial_index_bytes(&partial_index); + for segment in sorted_segments { + let source_bytes = estimate_source_index_bytes(&segment); if !current_group.is_empty() - && current_bytes.saturating_add(partial_bytes) > target_segment_bytes + && current_bytes.saturating_add(source_bytes) > target_segment_bytes { plans.push(build_segment_plan( - staging_index_uuid, std::mem::take(&mut current_group), requested_index_type, )?); current_bytes = 0; } - current_bytes = current_bytes.saturating_add(partial_bytes); - current_group.push(partial_index); + current_bytes = current_bytes.saturating_add(source_bytes); + current_group.push(segment); } if !current_group.is_empty() { - plans.push(build_segment_plan( - staging_index_uuid, - current_group, - requested_index_type, - )?); + plans.push(build_segment_plan(current_group, requested_index_type)?); } Ok(plans) @@ -2025,96 +1993,63 @@ pub(crate) async fn plan_staging_segments( /// Build one planned segment into its output directory. /// -/// Most plans write directly to `indices//`. If the target -/// directory is also the staging root, we first write into a temporary -/// directory and then swap the final files back into place. -/// -/// This is similar in shape to a compaction step: several temporary shard -/// outputs are consumed and replaced by a new built physical segment. The -/// difference is that this operates on index shard outputs instead of data -/// fragments. -pub(crate) async fn build_staging_segment( +/// Single-source plans are already materialized and return immediately. For +/// multi-source plans, this function writes a new merged physical segment under +/// `indices//`. +pub(crate) async fn build_segment( object_store: &ObjectStore, indices_dir: &Path, segment_plan: &IndexSegmentPlan, ) -> Result { let built_segment = segment_plan.segment().clone(); - let final_dir = indices_dir.child(built_segment.uuid().to_string()); - let staging_dir = indices_dir.child(segment_plan.staging_index_uuid().to_string()); - if final_dir == staging_dir { - let temp_dir = indices_dir.child(Uuid::new_v4().to_string()); - build_staging_segment_to_dir(object_store, indices_dir, &temp_dir, segment_plan, false) - .await?; - - // Re-materializing back into the staging root is not atomic: we delete - // and rewrite the root files one by one because object stores do not - // offer a directory rename primitive. We only clean up the source - // `partial_*` shards after these copies succeed, so a crash here can - // leave a partial root artifact but should not destroy the source data. - for file_name in [INDEX_FILE_NAME, INDEX_AUXILIARY_FILE_NAME] { - let target_file = final_dir.child(file_name); - // ObjectStore::copy is additive. Remove any previous root artifact first so - // re-materialization back into the staging root does not leave stale files - // behind. - if object_store.exists(&target_file).await? { - object_store.delete(&target_file).await?; - } - let source_file = temp_dir.child(file_name); - if object_store.exists(&source_file).await? { - object_store.copy(&source_file, &target_file).await?; - } - } + let segments = segment_plan.segments(); + debug_assert!( + !segments.is_empty(), + "segment plans must have at least one source segment" + ); - cleanup_consumed_partial_shards(object_store, indices_dir, segment_plan).await?; - reset_final_segment_dir(object_store, &temp_dir).await?; - } else { - build_staging_segment_to_dir(object_store, indices_dir, &final_dir, segment_plan, true) - .await?; + if segments.len() == 1 && segments[0].uuid == built_segment.uuid() { + return Ok(built_segment); } + let final_dir = indices_dir.child(built_segment.uuid().to_string()); + merge_segments_to_dir(object_store, indices_dir, &final_dir, segment_plan).await?; + Ok(built_segment) } -/// Write one built segment into `final_dir`. +/// Merge the selected input segments into `final_dir`. /// -/// For a single-shard plan this is just a file copy. For a multi-shard plan we -/// read the selected `partial_*` shards directly from the staging root and -/// write the merged auxiliary/index files into `final_dir`. -async fn build_staging_segment_to_dir( +/// Callers must only invoke this helper for multi-source plans. It reads the +/// selected input segments directly from `indices//` and writes +/// the merged auxiliary/index files into `final_dir`. +async fn merge_segments_to_dir( object_store: &ObjectStore, indices_dir: &Path, final_dir: &Path, segment_plan: &IndexSegmentPlan, - cleanup_source_shards: bool, ) -> Result<()> { reset_final_segment_dir(object_store, final_dir).await?; - let partial_indices = segment_plan.partial_indices(); - if partial_indices.len() == 1 { - let source_dir = indices_dir - .child(segment_plan.staging_index_uuid().to_string()) - .child(format!("partial_{}", partial_indices[0].uuid)); - copy_partial_segment_contents(object_store, &source_dir, final_dir).await?; - if cleanup_source_shards { - cleanup_consumed_partial_shards(object_store, indices_dir, segment_plan).await?; - } - return Ok(()); - } + let segments = segment_plan.segments(); + debug_assert!( + segments.len() > 1, + "merge helper should only be used for multi-source plans" + ); - let staging_root = indices_dir.child(segment_plan.staging_index_uuid().to_string()); - let aux_paths = partial_indices + let aux_paths = segments .iter() - .map(|partial_index| { - staging_root - .child(format!("partial_{}", partial_index.uuid)) + .map(|segment| { + indices_dir + .child(segment.uuid.to_string()) .child(INDEX_AUXILIARY_FILE_NAME) }) .collect::>(); - let partial_index_paths = partial_indices + let source_index_paths = segments .iter() - .map(|partial_index| { - staging_root - .child(format!("partial_{}", partial_index.uuid)) + .map(|segment| { + indices_dir + .child(segment.uuid.to_string()) .child(INDEX_FILE_NAME) }) .collect::>(); @@ -2129,19 +2064,15 @@ async fn build_staging_segment_to_dir( object_store, final_dir, segment_plan.requested_index_type(), - &partial_index_paths, + &source_index_paths, ) .await?; - if cleanup_source_shards { - cleanup_consumed_partial_shards(object_store, indices_dir, segment_plan).await?; - } Ok(()) } -/// Collapse one group of staging shards into a single built-segment plan. +/// Collapse one group of source segments into a single physical-segment plan. fn build_segment_plan( - staging_index_uuid: Uuid, group: Vec, requested_index_type: Option, ) -> Result { @@ -2149,100 +2080,56 @@ fn build_segment_plan( let first = &group[0]; let mut fragment_bitmap = RoaringBitmap::new(); let mut estimated_bytes = 0_u64; - let mut partial_indices = Vec::with_capacity(group.len()); + let mut segments = Vec::with_capacity(group.len()); - for partial_index in &group { - let partial_fragment_bitmap = partial_index.fragment_bitmap.as_ref().ok_or_else(|| { + for segment in &group { + let source_fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { Error::index(format!( - "Partial index '{}' is missing fragment coverage", - partial_index.uuid + "Segment '{}' is missing fragment coverage", + segment.uuid )) })?; - fragment_bitmap |= partial_fragment_bitmap.clone(); - estimated_bytes = - estimated_bytes.saturating_add(estimate_partial_index_bytes(partial_index)); - partial_indices.push(partial_index.clone()); + fragment_bitmap |= source_fragment_bitmap.clone(); + estimated_bytes = estimated_bytes.saturating_add(estimate_source_index_bytes(segment)); + segments.push(segment.clone()); } - let final_uuid = if group.len() == 1 { + let segment_uuid = if group.len() == 1 { first.uuid } else { Uuid::new_v4() }; - let index_type = requested_index_type.unwrap_or(IndexType::Vector); + let index_version = match requested_index_type { + Some(index_type) => index_type.version(), + None => infer_source_index_version(&group)?, + }; let segment = IndexSegment::new( - final_uuid, + segment_uuid, fragment_bitmap, Arc::new(crate::index::vector_index_details()), - index_type.version(), + index_version, ); Ok(IndexSegmentPlan::new( - staging_index_uuid, segment, - partial_indices, + segments, estimated_bytes, requested_index_type, )) } -/// Collapse an entire staging root into one built-segment plan. -/// -/// Some callers want one final output for the entire staging root instead of -/// one output per planned group. This helper reduces an existing set of plans -/// into a single plan covering the same shard set. -pub(crate) fn collapse_segment_plans( - segment_plans: &[IndexSegmentPlan], -) -> Result { - let Some(first_plan) = segment_plans.first() else { +fn infer_source_index_version(group: &[TableIndexMetadata]) -> Result { + debug_assert!(!group.is_empty()); + let first = group[0].index_version; + if group.iter().any(|segment| segment.index_version != first) { return Err(Error::index( - "Distributed vector segment build plan contains no segment plans".to_string(), + "Distributed vector segments must all have the same index version".to_string(), )); - }; - - let mut fragment_bitmap = RoaringBitmap::new(); - let mut partial_indices = Vec::new(); - let mut estimated_bytes = 0_u64; - - for plan in segment_plans { - fragment_bitmap |= plan.segment().fragment_bitmap().clone(); - partial_indices.extend_from_slice(plan.partial_indices()); - estimated_bytes = estimated_bytes.saturating_add(plan.estimated_bytes()); - } - - let staging_index_uuid = first_plan.staging_index_uuid(); - let segment = IndexSegment::new( - staging_index_uuid, - fragment_bitmap, - first_plan.segment().index_details().clone(), - first_plan.segment().index_version(), - ); - - Ok(IndexSegmentPlan::new( - staging_index_uuid, - segment, - partial_indices, - estimated_bytes, - first_plan.requested_index_type(), - )) -} - -/// Remove the source `partial_*` directories consumed by one segment plan. -async fn cleanup_consumed_partial_shards( - object_store: &ObjectStore, - indices_dir: &Path, - segment_plan: &IndexSegmentPlan, -) -> Result<()> { - for partial_index in segment_plan.partial_indices() { - let source_dir = indices_dir - .child(segment_plan.staging_index_uuid().to_string()) - .child(format!("partial_{}", partial_index.uuid)); - reset_final_segment_dir(object_store, &source_dir).await?; } - Ok(()) + Ok(first) } -fn estimate_partial_index_bytes(index_metadata: &TableIndexMetadata) -> u64 { +fn estimate_source_index_bytes(index_metadata: &TableIndexMetadata) -> u64 { index_metadata .files .as_ref() @@ -2250,31 +2137,6 @@ fn estimate_partial_index_bytes(index_metadata: &TableIndexMetadata) -> u64 { .unwrap_or(0) } -/// Copy all files that belong to one partial shard into a new directory. -async fn copy_partial_segment_contents( - object_store: &ObjectStore, - source_dir: &Path, - target_dir: &Path, -) -> Result<()> { - let mut files = object_store.list(Some(source_dir.clone())); - while let Some(item) = files.next().await { - let meta = item?; - let Some(relative_parts) = meta.location.prefix_match(source_dir) else { - continue; - }; - let relative_parts = relative_parts.collect::>(); - if relative_parts.is_empty() { - continue; - } - let mut final_path = target_dir.clone(); - for part in relative_parts { - final_path = final_path.child(part.as_ref()); - } - object_store.copy(&meta.location, &final_path).await?; - } - Ok(()) -} - /// Best-effort reset of one target directory before rewriting it. async fn reset_final_segment_dir(object_store: &ObjectStore, final_dir: &Path) -> Result<()> { match object_store.remove_dir_all(final_dir.clone()).await { diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 13faa22e8d5..92b0549f43b 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -618,7 +618,6 @@ mod tests { }; use arrow_buffer::OffsetBuffer; use arrow_schema::{DataType, Field, Schema, SchemaRef}; - use futures::StreamExt; use itertools::Itertools; use lance_arrow::FixedSizeListArrayExt; use lance_index::vector::bq::{ @@ -629,7 +628,7 @@ mod tests { use crate::dataset::{InsertBuilder, UpdateBuilder, WriteMode, WriteParams}; use crate::index::DatasetIndexInternalExt; use crate::index::vector::ivf::v2::IvfPq; - use crate::index::vector::ivf::{build_staging_segment, plan_staging_segments}; + use crate::index::vector::ivf::{build_segment, plan_segments}; use crate::utils::test::copy_test_data_to_tmp; use crate::{ Dataset, @@ -671,7 +670,6 @@ mod tests { use rand::distr::uniform::SampleUniform; use rand::{Rng, SeedableRng, rngs::StdRng}; use rstest::rstest; - use uuid::Uuid; const NUM_ROWS: usize = 512; const DIM: usize = 32; @@ -1476,25 +1474,21 @@ mod tests { ivf_params } - async fn build_distributed_partial_index_for_fragment_groups( + async fn build_segments_for_fragment_groups( dataset: &mut Dataset, fragment_groups: Vec>, // each group is a set of fragment ids params: &VectorIndexParams, index_name: &str, - ) -> (Uuid, Vec) { - let shared_uuid = Uuid::new_v4(); - let mut partial_indices = Vec::new(); + ) -> Vec { + let mut segments = Vec::new(); for fragments in fragment_groups { let mut builder = dataset.create_index_builder(&["vector"], IndexType::Vector, params); - builder = builder - .name(index_name.to_string()) - .fragments(fragments) - .index_uuid(shared_uuid.to_string()); - partial_indices.push(builder.execute_uncommitted().await.unwrap()); + builder = builder.name(index_name.to_string()).fragments(fragments); + segments.push(builder.execute_uncommitted().await.unwrap()); } - (shared_uuid, partial_indices) + segments } async fn build_ivfpq_for_fragment_groups( @@ -1504,50 +1498,23 @@ mod tests { pq_params: &PQBuildParams, index_name: &str, ) { - let shared_uuid = Uuid::new_v4(); let params = VectorIndexParams::with_ivf_pq_params( DistanceType::L2, ivf_params.clone(), pq_params.clone(), ); - for fragments in fragment_groups { - let mut builder = dataset.create_index_builder(&["vector"], IndexType::Vector, ¶ms); - builder = builder - .name(index_name.to_string()) - .fragments(fragments) - .index_uuid(shared_uuid.to_string()); - builder.execute_uncommitted().await.unwrap(); - } - - dataset - .merge_index_metadata(&shared_uuid.to_string(), IndexType::IvfPq, None) - .await - .unwrap(); - - dataset - .commit_existing_index_segments( - index_name, - "vector", - vec![IndexSegment::new( - shared_uuid, - dataset.fragment_bitmap.as_ref().clone(), - Arc::new(crate::index::vector_index_details()), - IndexType::IvfPq.version(), - )], - ) - .await - .unwrap(); + let segments = + build_segments_for_fragment_groups(dataset, fragment_groups, ¶ms, index_name).await; + let built_segments = build_distributed_segments(dataset, &segments, None, index_name).await; + assert!(!built_segments.is_empty()); } - fn assert_ivf_layout_equal(stats_a: &serde_json::Value, stats_b: &serde_json::Value) { - let idx_a = &stats_a["indices"][0]; - let idx_b = &stats_b["indices"][0]; - - let centroids_a = idx_a["centroids"] + fn assert_centroids_equal(reference: &serde_json::Value, candidate: &serde_json::Value) { + let centroids_a = reference["centroids"] .as_array() .expect("centroids should be an array"); - let centroids_b = idx_b["centroids"] + let centroids_b = candidate["centroids"] .as_array() .expect("centroids should be an array"); assert_eq!( @@ -1574,111 +1541,74 @@ mod tests { ); } } - - let parts_a = idx_a["partitions"] - .as_array() - .expect("partitions should be an array"); - let parts_b = idx_b["partitions"] - .as_array() - .expect("partitions should be an array"); - assert_eq!(parts_a.len(), parts_b.len(), "num partitions mismatch"); - let sizes_a: Vec = parts_a - .iter() - .map(|p| p["size"].as_u64().expect("partition size")) - .collect(); - let sizes_b: Vec = parts_b - .iter() - .map(|p| p["size"].as_u64().expect("partition size")) - .collect(); - assert_eq!(sizes_a, sizes_b, "partition sizes mismatch"); } - async fn load_staging_shard_uuids(dataset: &Dataset, shared_uuid: Uuid) -> Vec { - let mut shard_uuids = dataset - .object_store() - .read_dir(dataset.indices_dir().child(shared_uuid.to_string())) - .await - .unwrap() - .into_iter() - .filter(|name| name.starts_with("partial_")) - .map(|name| Uuid::parse_str(name.trim_start_matches("partial_")).unwrap()) - .collect::>(); - shard_uuids.sort(); - shard_uuids + fn sum_partition_sizes(indices: &[serde_json::Value]) -> Vec { + let mut totals = Vec::new(); + for index in indices { + let partitions = index["partitions"] + .as_array() + .expect("partitions should be an array"); + if totals.is_empty() { + totals.resize(partitions.len(), 0); + } else { + assert_eq!(totals.len(), partitions.len(), "num partitions mismatch"); + } + for (total, partition) in totals.iter_mut().zip(partitions.iter()) { + *total += partition["size"].as_u64().expect("partition size"); + } + } + totals } - /// Reconstruct the caller-side shard contract used by the tests. - /// - /// Production callers are expected to already know this mapping from - /// distributed scheduling state. The test helper rebuilds it by inspecting - /// which `partial_*` directories were created and pairing them with the - /// fragment groups we originally assigned. - async fn build_partial_indices( - dataset: &Dataset, - shared_uuid: Uuid, - fragment_groups: &[Vec], - ) -> Vec { - let shard_uuids = load_staging_shard_uuids(dataset, shared_uuid).await; - assert_eq!(shard_uuids.len(), fragment_groups.len()); - let mut partial_indices = Vec::with_capacity(shard_uuids.len()); - for (shard_uuid, fragment_group) in shard_uuids.into_iter().zip(fragment_groups.iter()) { - let partial_dir = dataset - .indices_dir() - .child(shared_uuid.to_string()) - .child(format!("partial_{}", shard_uuid)); - let mut estimated_bytes = 0_u64; - let mut files = dataset.object_store().list(Some(partial_dir)); - while let Some(item) = files.next().await { - estimated_bytes += item.unwrap().size; - } - partial_indices.push(IndexMetadata { - uuid: shard_uuid, - name: String::new(), - fields: Vec::new(), - dataset_version: dataset.version().version, - fragment_bitmap: Some(fragment_group.iter().copied().collect()), - index_details: None, - index_version: IndexType::Vector.version(), - created_at: None, - base_id: None, - files: Some(vec![lance_table::format::IndexFile { - path: String::new(), - size_bytes: estimated_bytes, - }]), - }); + fn assert_ivf_layout_compatible(stats_a: &serde_json::Value, stats_b: &serde_json::Value) { + let indices_a = stats_a["indices"] + .as_array() + .expect("indices should be an array"); + let indices_b = stats_b["indices"] + .as_array() + .expect("indices should be an array"); + assert!( + !indices_a.is_empty() && !indices_b.is_empty(), + "indices should not be empty", + ); + + let reference = &indices_a[0]; + for index in indices_a.iter().skip(1).chain(indices_b.iter()) { + assert_centroids_equal(reference, index); } - partial_indices + + let sizes_a = sum_partition_sizes(indices_a); + let sizes_b = sum_partition_sizes(indices_b); + assert_eq!(sizes_a, sizes_b, "aggregated partition sizes mismatch"); } - /// Execute the internal staged segment-build workflow used by the - /// regression tests: plan segment groups from caller-provided shard + /// Execute the internal segment workflow used by the + /// regression tests: plan segment groups from caller-provided segment /// metadata, build each segment, and publish them as one logical index. async fn build_distributed_segments( dataset: &mut Dataset, - shared_uuid: Uuid, - partial_indices: &[IndexMetadata], + segments: &[IndexMetadata], target_segment_bytes: Option, index_name: &str, ) -> Vec { - let index_dir = dataset.indices_dir().child(shared_uuid.to_string()); - let segment_plans = - plan_staging_segments(&index_dir, partial_indices, None, target_segment_bytes) - .await - .unwrap(); - let mut segments = Vec::with_capacity(segment_plans.len()); + let segment_plans = plan_segments(segments, None, target_segment_bytes) + .await + .unwrap(); + let mut built_segments = Vec::with_capacity(segment_plans.len()); for plan in &segment_plans { - segments.push( - build_staging_segment(dataset.object_store(), &dataset.indices_dir(), plan) + built_segments.push( + build_segment(dataset.object_store(), &dataset.indices_dir(), plan) .await .unwrap(), ); } dataset - .commit_existing_index_segments(index_name, "vector", segments.clone()) + .commit_existing_index_segments(index_name, "vector", built_segments.clone()) .await .unwrap(); - segments + built_segments } #[tokio::test] @@ -1740,27 +1670,11 @@ mod tests { let stats_split_json = ds_split.index_statistics(INDEX_NAME).await.unwrap(); let stats_single: serde_json::Value = serde_json::from_str(&stats_single_json).unwrap(); let stats_split: serde_json::Value = serde_json::from_str(&stats_split_json).unwrap(); - assert_ivf_layout_equal(&stats_single, &stats_split); - - let ctx_single = load_vector_index_context(&ds_single, "vector", INDEX_NAME).await; - let ctx_split = load_vector_index_context(&ds_split, "vector", INDEX_NAME).await; - - let ivf_single = ctx_single.ivf(); - let ivf_split = ctx_split.ivf(); - let total_partitions = ivf_single.total_partitions(); - assert_eq!(total_partitions, ivf_split.total_partitions()); - - for part_id in 0..total_partitions { - let row_ids_single = load_partition_row_ids(ivf_single, part_id).await; - let row_ids_split = load_partition_row_ids(ivf_split, part_id).await; - let set_single: HashSet = row_ids_single.into_iter().collect(); - let set_split: HashSet = row_ids_split.into_iter().collect(); - assert_eq!( - set_single, set_split, - "row id set mismatch for partition {}", - part_id - ); - } + assert_ivf_layout_compatible(&stats_single, &stats_split); + assert_eq!( + stats_single["num_indexed_rows"], + stats_split["num_indexed_rows"] + ); const K: usize = 10; const NUM_QUERIES: usize = 10; @@ -1889,30 +1803,30 @@ mod tests { .map(|fragment| vec![fragment.id() as u32]) .collect::>(); let expected_segment_count = fragment_groups.len(); - let (shared_uuid, partial_indices) = build_distributed_partial_index_for_fragment_groups( + let segments = build_segments_for_fragment_groups( &mut ds_split, fragment_groups, &distributed_params, INDEX_NAME, ) .await; - let segments = build_distributed_segments( - &mut ds_split, - shared_uuid, - &partial_indices, - None, - INDEX_NAME, - ) - .await; + let segments = build_distributed_segments(&mut ds_split, &segments, None, INDEX_NAME).await; assert_eq!(segments.len(), expected_segment_count); - let staging_dir = ds_split.indices_dir().child(shared_uuid.to_string()); - let staging_entries = ds_split.object_store().read_dir(staging_dir).await.unwrap(); - assert!( - staging_entries - .iter() - .all(|entry| !entry.starts_with("partial_")), - "built segments should clean up consumed partial shards", - ); + for segment in &segments { + let segment_index = ds_split + .indices_dir() + .child(segment.uuid().to_string()) + .child(crate::index::INDEX_FILE_NAME); + assert!( + ds_split + .object_store() + .exists(&segment_index) + .await + .unwrap(), + "segment file should exist at {}", + segment_index + ); + } let committed_segments = ds_split.load_indices_by_name(INDEX_NAME).await.unwrap(); assert_eq!(committed_segments.len(), expected_segment_count); @@ -2032,7 +1946,7 @@ mod tests { .into_iter() .map(|fragment| vec![fragment.id() as u32]) .collect::>(); - let (shared_uuid, partial_indices) = build_distributed_partial_index_for_fragment_groups( + let segments = build_segments_for_fragment_groups( &mut ds_split, fragment_groups, &distributed_params, @@ -2040,33 +1954,20 @@ mod tests { ) .await; - let index_dir = ds_split.indices_dir().child(shared_uuid.to_string()); - let shard_plan = plan_staging_segments(&index_dir, &partial_indices, None, None) - .await - .unwrap(); + let shard_plan = plan_segments(&segments, None, None).await.unwrap(); let shard_count = shard_plan.len(); assert!(shard_count >= 4); let target_segment_bytes = shard_plan[0].estimated_bytes().saturating_mul(2); - let grouped_plan = plan_staging_segments( - &index_dir, - &partial_indices, - None, - Some(target_segment_bytes), - ) - .await - .unwrap(); + let grouped_plan = plan_segments(&segments, None, Some(target_segment_bytes)) + .await + .unwrap(); assert!(grouped_plan.len() < shard_count); - assert!( - grouped_plan - .iter() - .any(|plan| plan.partial_indices().len() > 1) - ); + assert!(grouped_plan.iter().any(|plan| plan.segments().len() > 1)); let grouped_segments = build_distributed_segments( &mut ds_split, - shared_uuid, - &partial_indices, + &segments, Some(target_segment_bytes), INDEX_NAME, ) @@ -2127,29 +2028,24 @@ mod tests { let mut dataset = write_dataset_from_batches(&dataset_uri, schema, batches).await; let fragment = dataset.get_fragments()[0].id() as u32; - let shared_uuid = Uuid::new_v4(); let params = VectorIndexParams::with_ivf_flat_params( DistanceType::L2, prepare_global_ivf(&dataset, "vector").await, ); + let mut segments = Vec::new(); for _ in 0..2 { - dataset + let segment = dataset .create_index_builder(&["vector"], IndexType::Vector, ¶ms) .name("vector_idx".to_string()) .fragments(vec![fragment]) - .index_uuid(shared_uuid.to_string()) .execute_uncommitted() .await .unwrap(); + segments.push(segment); } - let index_dir = dataset.indices_dir().child(shared_uuid.to_string()); - let partial_indices = - build_partial_indices(&dataset, shared_uuid, &[vec![fragment], vec![fragment]]).await; - let err = plan_staging_segments(&index_dir, &partial_indices, None, None) - .await - .unwrap_err(); + let err = plan_segments(&segments, None, None).await.unwrap_err(); assert!(err.to_string().contains("overlapping fragment coverage")); } @@ -2163,40 +2059,31 @@ mod tests { let fragments = dataset.get_fragments(); assert!(fragments.len() >= 2); - let shared_uuid = Uuid::new_v4(); let params = VectorIndexParams::ivf_hnsw( DistanceType::L2, prepare_global_ivf(&dataset, "vector").await, HnswBuildParams::default(), ); + let mut segments = Vec::new(); for fragment in fragments.iter().take(2) { - dataset + let segment = dataset .create_index_builder(&["vector"], IndexType::Vector, ¶ms) .name("vector_idx".to_string()) .fragments(vec![fragment.id() as u32]) - .index_uuid(shared_uuid.to_string()) .execute_uncommitted() .await .unwrap(); + segments.push(segment); } - let index_dir = dataset.indices_dir().child(shared_uuid.to_string()); - let fragment_groups = fragments - .iter() - .take(2) - .map(|fragment| vec![fragment.id() as u32]) - .collect::>(); - let partial_indices = build_partial_indices(&dataset, shared_uuid, &fragment_groups).await; - let plans = plan_staging_segments(&index_dir, &partial_indices, None, Some(1)) - .await - .unwrap(); + let plans = plan_segments(&segments, None, Some(1)).await.unwrap(); assert_eq!(plans.len(), fragments.iter().take(2).count()); let mut segments = Vec::with_capacity(plans.len()); for plan in &plans { segments.push( - build_staging_segment(dataset.object_store(), &dataset.indices_dir(), plan) + build_segment(dataset.object_store(), &dataset.indices_dir(), plan) .await .unwrap(), ); From b678812d02668fd796fdb731cefee452ec9f1377 Mon Sep 17 00:00:00 2001 From: yaommen Date: Wed, 25 Mar 2026 02:34:24 +0800 Subject: [PATCH 072/206] test: stabilize distributed IVF grouped build query test (#6281) --- rust/lance/src/index/vector/ivf/v2.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 92b0549f43b..92ddea55cbc 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -1848,6 +1848,7 @@ mod tests { .unwrap() .nearest("vector", q.as_ref(), K) .unwrap() + .minimum_nprobes(TWO_FRAG_NUM_PARTITIONS) .try_into_batch() .await .unwrap(); @@ -1984,6 +1985,7 @@ mod tests { .unwrap() .nearest("vector", q.as_ref(), K) .unwrap() + .minimum_nprobes(TWO_FRAG_NUM_PARTITIONS) .try_into_batch() .await .unwrap(); From f2d20686702e80e093ee35c3a80fb06cfb773172 Mon Sep 17 00:00:00 2001 From: Lei Xu Date: Tue, 24 Mar 2026 12:19:25 -0700 Subject: [PATCH 073/206] fix: pass dataset_options to SafeLanceDataset in worker processes (#6278) Worker processes were opening the dataset without dataset_options, silently dropping any options (storage_options, version, index_cache_size, etc.) set by the caller. Also remove the debug print statement. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- python/python/lance/torch/data.py | 5 +--- python/python/tests/torch_tests/test_data.py | 31 +++++++++++++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/python/python/lance/torch/data.py b/python/python/lance/torch/data.py index d5adcbbfe19..dc09cde3dc4 100644 --- a/python/python/lance/torch/data.py +++ b/python/python/lance/torch/data.py @@ -443,10 +443,7 @@ def __getitems__(self, indices): """ if self._ds is None: # Worker-process initialization - import os - - self._ds = lance.dataset(self.uri) - print(f"Worker {os.getpid()} initialized dataset") + self._ds = lance.dataset(self.uri, **self.dataset_options) # Leverage native batch reading batch = self._ds.take(indices) diff --git a/python/python/tests/torch_tests/test_data.py b/python/python/tests/torch_tests/test_data.py index 890a536cc9e..38b9439802f 100644 --- a/python/python/tests/torch_tests/test_data.py +++ b/python/python/tests/torch_tests/test_data.py @@ -12,7 +12,7 @@ from lance.sampler import ShardedBatchSampler, ShardedFragmentSampler torch = pytest.importorskip("torch") -from lance.torch.data import LanceDataset # noqa: E402 +from lance.torch.data import LanceDataset, SafeLanceDataset # noqa: E402 def test_iter_over_dataset_fixed_shape_tensor(tmp_path): @@ -324,3 +324,32 @@ def to_tensor_fn(batch, *args, **kwargs): assert first["int"].shape == (4,) assert first["val"].dtype == torch.uint8 assert first["val"].shape == (4, 100) + + +def test_safe_lance_dataset_worker_uses_dataset_options(tmp_path: Path): + """Worker processes must reopen the dataset with dataset_options. + + Regression test for: worker init called lance.dataset(uri) without + dataset_options, silently dropping version, storage_options, etc. + """ + tbl_v1 = pa.table({"id": pa.array([1, 2, 3], pa.int64())}) + ds = lance.write_dataset(tbl_v1, tmp_path / "data.lance") + version_1 = ds.version + + # Write a second version with different data so we can distinguish them. + tbl_v2 = pa.table({"id": pa.array([10, 20, 30], pa.int64())}) + lance.write_dataset(tbl_v2, tmp_path / "data.lance", mode="overwrite") + + # Pin to version 1 via dataset_options. + safe_ds = SafeLanceDataset( + str(tmp_path / "data.lance"), + dataset_options={"version": version_1}, + ) + + # Simulate worker-process state: _ds is None so __getitems__ must reopen. + safe_ds._ds = None + rows = safe_ds.__getitems__([0, 1, 2]) + + assert [r["id"] for r in rows] == [1, 2, 3], ( + "Worker reopened dataset without dataset_options (got version 2 data)" + ) From d3803a9b885c12a55fc2fba74859ea6d165c6d19 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Tue, 24 Mar 2026 14:35:29 -0700 Subject: [PATCH 074/206] fix: respect the old data filter on inverted index (#6216) This is basically the same problem encountered in https://github.com/lance-format/lance/pull/5929 However, the solution there (and in other indices fixed since then) has been to prune indices during the update phase to remove old fragments. Unfortunately, FTS indices are maybe too large for this pruning to make sense (it would require a complete scan through of the old index) though I haven't verified how bad the performance would be. This PR instead keeps track of which fragments have been invalidated. It then uses this as a block-list at search time. --- rust/lance-index/benches/inverted.rs | 6 +- rust/lance-index/src/scalar.rs | 11 +- rust/lance-index/src/scalar/inverted.rs | 2 +- .../src/scalar/inverted/builder.rs | 347 ++++++++++++++++- rust/lance-index/src/scalar/inverted/index.rs | 87 ++++- rust/lance-table/src/format/index.rs | 10 + .../src/dataset/tests/dataset_merge_update.rs | 366 ++++++++++++++++++ rust/lance/src/index/append.rs | 12 +- rust/lance/src/index/prefilter.rs | 23 +- rust/lance/src/index/vector/fixture_test.rs | 1 + rust/lance/src/io/exec/fts.rs | 14 +- 11 files changed, 858 insertions(+), 21 deletions(-) diff --git a/rust/lance-index/benches/inverted.rs b/rust/lance-index/benches/inverted.rs index 02746339bf2..bce3fac4414 100644 --- a/rust/lance-index/benches/inverted.rs +++ b/rust/lance-index/benches/inverted.rs @@ -98,7 +98,7 @@ fn bench_inverted(c: &mut Criterion) { InvertedIndexBuilder::new(InvertedIndexParams::default().with_position(false)); black_box({ builder - .update(stream, indexing_store.as_ref()) + .update(stream, indexing_store.as_ref(), None) .await .unwrap(); builder @@ -119,7 +119,7 @@ fn bench_inverted(c: &mut Criterion) { InvertedIndexBuilder::new(InvertedIndexParams::default().with_position(true)); black_box({ builder - .update(stream, indexing_with_positions_store.as_ref()) + .update(stream, indexing_with_positions_store.as_ref(), None) .await .unwrap(); builder @@ -135,7 +135,7 @@ fn bench_inverted(c: &mut Criterion) { let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default().with_position(true)); builder - .update(stream, phrase_search_store.as_ref()) + .update(stream, phrase_search_store.as_ref(), None) .await .unwrap(); }); diff --git a/rust/lance-index/src/scalar.rs b/rust/lance-index/src/scalar.rs index 51412c995b0..dbce2ec1aa7 100644 --- a/rust/lance-index/src/scalar.rs +++ b/rust/lance-index/src/scalar.rs @@ -849,10 +849,13 @@ pub struct UpdateCriteria { /// - stable row IDs: use exact row-id membership instead #[derive(Debug, Clone)] pub enum OldIndexDataFilter { - /// Keep old rows whose row-address fragment is in this bitmap. + /// Keeps track of which fragments are still valid and which are no longer valid. /// /// This is valid for address-style row IDs. - Fragments(RoaringBitmap), + Fragments { + to_keep: RoaringBitmap, + to_remove: RoaringBitmap, + }, /// Keep old rows whose row IDs are in this exact allow-list. /// /// This is required for stable row IDs, where row IDs are opaque and @@ -864,9 +867,9 @@ impl OldIndexDataFilter { /// Build a boolean mask that keeps only row IDs selected by this filter. pub fn filter_row_ids(&self, row_ids: &UInt64Array) -> BooleanArray { match self { - Self::Fragments(valid_fragments) => row_ids + Self::Fragments { to_keep, .. } => row_ids .iter() - .map(|id| id.map(|id| valid_fragments.contains((id >> 32) as u32))) + .map(|id| id.map(|id| to_keep.contains((id >> 32) as u32))) .collect(), Self::RowIds(valid_row_ids) => row_ids .iter() diff --git a/rust/lance-index/src/scalar/inverted.rs b/rust/lance-index/src/scalar/inverted.rs index 8435e1154e1..725b06ebde3 100644 --- a/rust/lance-index/src/scalar/inverted.rs +++ b/rust/lance-index/src/scalar/inverted.rs @@ -65,7 +65,7 @@ impl InvertedIndexPlugin { let mut inverted_index = InvertedIndexBuilder::new_with_fragment_mask(params, fragment_mask) .with_progress(progress); - inverted_index.update(data, index_store).await?; + inverted_index.update(data, index_store, None).await?; Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&details).unwrap(), index_version: current_fts_format_version().index_version(), diff --git a/rust/lance-index/src/scalar/inverted/builder.rs b/rust/lance-index/src/scalar/inverted/builder.rs index 6eb8575e5ab..f752ef0e68b 100644 --- a/rust/lance-index/src/scalar/inverted/builder.rs +++ b/rust/lance-index/src/scalar/inverted/builder.rs @@ -2,17 +2,17 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use super::{InvertedIndexParams, index::*}; -use crate::scalar::IndexStore; use crate::scalar::inverted::json::JsonTextStream; use crate::scalar::inverted::lance_tokenizer::DocType; use crate::scalar::inverted::tokenizer::lance_tokenizer::LanceTokenizer; #[cfg(test)] use crate::scalar::lance_format::LanceIndexStore; +use crate::scalar::{IndexStore, OldIndexDataFilter}; use crate::vector::graph::OrderedFloat; use crate::{progress::IndexBuildProgress, progress::noop_progress}; use arrow::array::AsArray; use arrow::datatypes; -use arrow_array::{Array, RecordBatch, UInt64Array}; +use arrow_array::{Array, BinaryArray, RecordBatch, UInt64Array}; use arrow_schema::{DataType, Field, Schema, SchemaRef}; use bitpacking::{BitPacker, BitPacker4x}; use datafusion::execution::{RecordBatchStream, SendableRecordBatchStream}; @@ -27,6 +27,7 @@ use lance_core::utils::tokio::{IO_CORE_RESERVATION, get_num_compute_intensive_cp use lance_core::{Error, ROW_ID, ROW_ID_FIELD, Result}; use lance_io::object_store::ObjectStore; use object_store::path::Path; +use roaring::RoaringBitmap; use smallvec::SmallVec; use std::collections::HashMap; use std::pin::Pin; @@ -125,6 +126,7 @@ pub struct InvertedIndexBuilder { posting_tail_codec: PostingTailCodec, src_store: Option>, progress: Arc, + deleted_fragments: RoaringBitmap, } impl InvertedIndexBuilder { @@ -139,6 +141,7 @@ impl InvertedIndexBuilder { Vec::new(), TokenSetFormat::default(), fragment_mask, + RoaringBitmap::new(), ) } @@ -154,6 +157,7 @@ impl InvertedIndexBuilder { partitions: Vec, token_set_format: TokenSetFormat, fragment_mask: Option, + deleted_fragments: RoaringBitmap, ) -> Self { Self { params, @@ -165,6 +169,7 @@ impl InvertedIndexBuilder { format_version: current_fts_format_version(), posting_tail_codec: current_fts_format_version().posting_tail_codec(), progress: noop_progress(), + deleted_fragments, } } @@ -190,6 +195,7 @@ impl InvertedIndexBuilder { &mut self, new_data: SendableRecordBatchStream, dest_store: &dyn IndexStore, + old_data_filter: Option, ) -> Result<()> { let schema = new_data.schema(); let doc_col = schema.field(0).name(); @@ -208,6 +214,11 @@ impl InvertedIndexBuilder { .stage_start("tokenize_docs", None, "rows") .await?; self.update_index(new_data, dest_store).await?; + + if let Some(OldIndexDataFilter::Fragments { to_remove, .. }) = old_data_filter { + self.deleted_fragments.extend(to_remove); + } + self.progress.stage_complete("tokenize_docs").await?; self.write(dest_store).await?; Ok(()) @@ -353,6 +364,11 @@ impl InvertedIndexBuilder { } async fn write_metadata(&self, dest_store: &dyn IndexStore, partitions: &[u64]) -> Result<()> { + let mut serialized_deleted_fragments = + Vec::with_capacity(self.deleted_fragments.serialized_size()); + self.deleted_fragments + .serialize_into(&mut serialized_deleted_fragments)?; + let mut metadata = HashMap::from_iter(vec![ ("partitions".to_owned(), serde_json::to_string(&partitions)?), ("params".to_owned(), serde_json::to_string(&self.params)?), @@ -365,6 +381,7 @@ impl InvertedIndexBuilder { self.posting_tail_codec.as_str().to_owned(), ), ]); + if self.params.with_position && self.format_version.uses_shared_position_stream() { metadata.insert( POSITIONS_LAYOUT_KEY.to_owned(), @@ -379,9 +396,22 @@ impl InvertedIndexBuilder { .to_owned(), ); } + + let metadata_file_schema = Arc::new(Schema::new(vec![Field::new( + DELETED_FRAGMENTS_COL, + DataType::Binary, + false, + )])); + let deleted_fragments_col = Arc::new(BinaryArray::from(vec![ + serialized_deleted_fragments.as_slice(), + ])) as Arc; + let record_batch = + RecordBatch::try_new(metadata_file_schema.clone(), vec![deleted_fragments_col])?; + let mut writer = dest_store - .new_index_file(METADATA_FILE, Arc::new(Schema::empty())) + .new_index_file(METADATA_FILE, metadata_file_schema) .await?; + writer.write_record_batch(record_batch).await?; writer.finish_with_metadata(metadata).await?; Ok(()) } @@ -1542,6 +1572,8 @@ async fn merge_metadata_files( let mut format_version = None; let mut posting_tail_codec = None; + let mut deleted_fragments = RoaringBitmap::new(); + for file_name in part_metadata_files { let reader = store.open_index_file(file_name).await?; let metadata = &reader.schema().metadata; @@ -1577,6 +1609,20 @@ async fn merge_metadata_files( if posting_tail_codec.is_none() { posting_tail_codec = Some(parse_posting_tail_codec(metadata)?); } + + if reader.num_rows() > 0 { + let metadata_batch = reader.read_range(0..1, None).await?; + let deleted_fragments_col = metadata_batch + .column_by_name(DELETED_FRAGMENTS_COL) + .expect_ok()?; + let deleted_fragments_arr = deleted_fragments_col + .as_any() + .downcast_ref::() + .expect_ok()?; + let part_deleted_fragments = + RoaringBitmap::deserialize_from(deleted_fragments_arr.value(0))?; + deleted_fragments.extend(part_deleted_fragments); + } } // Create ID mapping: sorted original IDs -> 0,1,2... @@ -1655,6 +1701,7 @@ async fn merge_metadata_files( remapped_partitions.clone(), token_set_format, None, + deleted_fragments, ) .with_format_version(format_version.unwrap_or(InvertedListFormatVersion::V1)) .with_posting_tail_codec(posting_tail_codec.unwrap_or(PostingTailCodec::Fixed32)); @@ -1931,6 +1978,7 @@ mod tests { partitions.clone(), token_set_format, None, + RoaringBitmap::new(), ); builder.write(dest_store.as_ref()).await?; @@ -1987,6 +2035,7 @@ mod tests { vec![0], TokenSetFormat::default(), None, + RoaringBitmap::new(), ) .with_posting_tail_codec(posting_tail_codec); metadata_writer @@ -2033,6 +2082,7 @@ mod tests { Vec::new(), TokenSetFormat::default(), None, + RoaringBitmap::new(), ) .with_format_version(InvertedListFormatVersion::V2) .with_posting_tail_codec(PostingTailCodec::Fixed32); @@ -2073,7 +2123,7 @@ mod tests { .max_token_length(None); let mut builder = InvertedIndexBuilder::new(params); - builder.update(stream, store.as_ref()).await?; + builder.update(stream, store.as_ref(), None).await?; let index = InvertedIndex::load(store, None, &LanceCache::no_cache()).await?; assert_eq!(index.partitions.len(), 1); @@ -2166,7 +2216,7 @@ mod tests { let progress = Arc::new(RecordingProgress::default()); let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default()) .with_progress(progress.clone()); - builder.update(stream, store.as_ref()).await?; + builder.update(stream, store.as_ref(), None).await?; let events = progress.events.lock().await.clone(); let tags = events @@ -2260,7 +2310,7 @@ mod tests { let progress = Arc::new(RecordingProgress::default()); let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default()) .with_progress(progress.clone()); - builder.update(stream, store.as_ref()).await?; + builder.update(stream, store.as_ref(), None).await?; let tags = progress .events @@ -2297,7 +2347,7 @@ mod tests { let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default().memory_limit_mb(0)); let err = builder - .update(stream, store.as_ref()) + .update(stream, store.as_ref(), None) .await .expect_err("single doc should exceed zero worker memory limit"); assert!( @@ -2532,4 +2582,287 @@ mod tests { "unexpected error: {result}" ); } + + #[tokio::test] + async fn test_new_index_has_empty_deleted_fragments() { + let index_dir = TempDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + index_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + let batch = make_doc_batch("hello world", 0); + let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)])); + let stream = Box::pin(stream); + + let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default()); + builder.update(stream, store.as_ref(), None).await.unwrap(); + + let index = InvertedIndex::load(store, None, &LanceCache::no_cache()) + .await + .unwrap(); + assert!( + index.deleted_fragments().is_empty(), + "new index should have empty deleted fragments, got {:?}", + index.deleted_fragments() + ); + } + + #[tokio::test] + async fn test_remap_preserves_deleted_fragments() { + let src_dir = TempDir::default(); + let dest_dir = TempDir::default(); + let src_store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + src_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + let dest_store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + dest_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + // Build an initial index with some deleted fragments + let batch = make_doc_batch("hello world", 0); + let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)])); + let stream = Box::pin(stream); + + let initial_deleted = RoaringBitmap::from_iter([5, 10, 42]); + let mut builder = InvertedIndexBuilder::from_existing_index( + InvertedIndexParams::default(), + None, + Vec::new(), + TokenSetFormat::default(), + None, + initial_deleted.clone(), + ); + builder + .update(stream, src_store.as_ref(), None) + .await + .unwrap(); + + // Load it back and confirm the invalidated fragments are set + let index = InvertedIndex::load(src_store.clone(), None, &LanceCache::no_cache()) + .await + .unwrap(); + assert_eq!(index.deleted_fragments(), &initial_deleted); + + // Remap the index via the ScalarIndex trait method + use crate::scalar::ScalarIndex; + let mapping = HashMap::from([(0u64, Some(50 << 32))]); + index.remap(&mapping, dest_store.as_ref()).await.unwrap(); + + // Reload from dest and verify deleted fragments are preserved + let remapped_index = InvertedIndex::load(dest_store.clone(), None, &LanceCache::no_cache()) + .await + .unwrap(); + assert_eq!( + remapped_index.deleted_fragments(), + &initial_deleted, + "remap should preserve deleted fragments" + ); + } + + #[tokio::test] + async fn test_update_grows_deleted_fragments_from_old_data_filter() { + let index_dir = TempDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + index_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + // Build an initial index with no deleted fragments + let batch = make_doc_batch("hello world", 0); + let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)])); + let stream = Box::pin(stream); + + let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default()); + builder.update(stream, store.as_ref(), None).await.unwrap(); + + // Load the index and update it with an old_data_filter that invalidates fragments + let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache()) + .await + .unwrap(); + assert!(index.deleted_fragments().is_empty()); + + let update_dir = TempDir::default(); + let update_store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + update_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + let batch2 = make_doc_batch("new document", 1 << 32 | 1); + let stream2 = + RecordBatchStreamAdapter::new(batch2.schema(), stream::iter(vec![Ok(batch2)])); + let stream2 = Box::pin(stream2); + + let old_data_filter = Some(crate::scalar::OldIndexDataFilter::Fragments { + to_keep: RoaringBitmap::from_iter([0]), + to_remove: RoaringBitmap::from_iter([3, 7]), + }); + + // Use ScalarIndex::update trait method + use crate::scalar::ScalarIndex; + index + .update(stream2, update_store.as_ref(), old_data_filter) + .await + .unwrap(); + + let updated_index = + InvertedIndex::load(update_store.clone(), None, &LanceCache::no_cache()) + .await + .unwrap(); + assert_eq!( + updated_index.deleted_fragments(), + &RoaringBitmap::from_iter([3, 7]), + "update should add deleted fragments from old_data_filter" + ); + } + + #[tokio::test] + async fn test_update_accumulates_deleted_fragments() { + let dir1 = TempDir::default(); + let store1 = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + dir1.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + // Build initial index + let batch = make_doc_batch("hello world", 0); + let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)])); + let stream = Box::pin(stream); + + let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default()); + builder.update(stream, store1.as_ref(), None).await.unwrap(); + + // First update: delete fragments 3 and 7 + let index = InvertedIndex::load(store1.clone(), None, &LanceCache::no_cache()) + .await + .unwrap(); + + let dir2 = TempDir::default(); + let store2 = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + dir2.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + let batch2 = make_doc_batch("second doc", 1 << 32 | 1); + let stream2 = + RecordBatchStreamAdapter::new(batch2.schema(), stream::iter(vec![Ok(batch2)])); + let stream2 = Box::pin(stream2); + + use crate::scalar::ScalarIndex; + index + .update( + stream2, + store2.as_ref(), + Some(crate::scalar::OldIndexDataFilter::Fragments { + to_keep: RoaringBitmap::from_iter([0]), + to_remove: RoaringBitmap::from_iter([3, 7]), + }), + ) + .await + .unwrap(); + + // Second update: invalidate additional fragments 12 and 15 + let index2 = InvertedIndex::load(store2.clone(), None, &LanceCache::no_cache()) + .await + .unwrap(); + assert_eq!( + index2.deleted_fragments(), + &RoaringBitmap::from_iter([3, 7]) + ); + + let dir3 = TempDir::default(); + let store3 = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + dir3.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + let batch3 = make_doc_batch("third doc", 2 << 32 | 2); + let stream3 = + RecordBatchStreamAdapter::new(batch3.schema(), stream::iter(vec![Ok(batch3)])); + let stream3 = Box::pin(stream3); + + index2 + .update( + stream3, + store3.as_ref(), + Some(crate::scalar::OldIndexDataFilter::Fragments { + to_keep: RoaringBitmap::from_iter([0, 1]), + to_remove: RoaringBitmap::from_iter([12, 15]), + }), + ) + .await + .unwrap(); + + let index3 = InvertedIndex::load(store3.clone(), None, &LanceCache::no_cache()) + .await + .unwrap(); + assert_eq!( + index3.deleted_fragments(), + &RoaringBitmap::from_iter([3, 7, 12, 15]), + "deleted fragments should accumulate across updates" + ); + } + + #[tokio::test] + async fn test_update_with_rowid_filter_does_not_grow_deleted_fragments() { + let index_dir = TempDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + index_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + let batch = make_doc_batch("hello world", 0); + let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)])); + let stream = Box::pin(stream); + + let mut builder = InvertedIndexBuilder::new(InvertedIndexParams::default()); + builder.update(stream, store.as_ref(), None).await.unwrap(); + + let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache()) + .await + .unwrap(); + + let update_dir = TempDir::default(); + let update_store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + update_dir.obj_path(), + Arc::new(LanceCache::no_cache()), + )); + + let batch2 = make_doc_batch("new doc", 1); + let stream2 = + RecordBatchStreamAdapter::new(batch2.schema(), stream::iter(vec![Ok(batch2)])); + let stream2 = Box::pin(stream2); + + // Use RowIds filter instead of Fragments — should not affect deleted_fragments + let mut valid_ids = lance_core::utils::mask::RowAddrTreeMap::new(); + valid_ids.insert(0); + let old_data_filter = Some(crate::scalar::OldIndexDataFilter::RowIds(valid_ids)); + + use crate::scalar::ScalarIndex; + index + .update(stream2, update_store.as_ref(), old_data_filter) + .await + .unwrap(); + + let updated_index = + InvertedIndex::load(update_store.clone(), None, &LanceCache::no_cache()) + .await + .unwrap(); + assert!( + updated_index.deleted_fragments().is_empty(), + "RowIds filter should not add to deleted fragments" + ); + } } diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index 422d154b453..59d258975aa 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -109,6 +109,7 @@ pub const POSTING_TAIL_CODEC_VARINT_DELTA_V1: &str = "varint_delta_v1"; pub const POSITIONS_LAYOUT_SHARED_STREAM_V2: &str = "shared_stream_v2"; pub const POSITIONS_CODEC_VARINT_DOC_DELTA_V2: &str = "varint_doc_delta_v2"; pub const POSITIONS_CODEC_PACKED_DELTA_V1: &str = "packed_delta_v1"; +pub const DELETED_FRAGMENTS_COL: &str = "deleted_fragments"; // Just a heuristic when we need to pre-allocate memory for tokens pub const ESTIMATED_MAX_TOKENS_PER_ROW: usize = 4 * 1024; @@ -342,6 +343,9 @@ pub struct InvertedIndex { tokenizer: Box, token_set_format: TokenSetFormat, pub(crate) partitions: Vec>, + // Fragments which are contained in the index, but no longer in the dataset. + // These should be pruned at search time since we don't prune them at update time. + deleted_fragments: RoaringBitmap, } impl Debug for InvertedIndex { @@ -350,6 +354,7 @@ impl Debug for InvertedIndex { .field("params", &self.params) .field("token_set_format", &self.token_set_format) .field("partitions", &self.partitions) + .field("deleted_fragments", &self.deleted_fragments) .finish() } } @@ -399,6 +404,7 @@ impl InvertedIndex { Vec::new(), self.token_set_format, fragment_mask, + self.deleted_fragments.clone(), ) .with_posting_tail_codec(self.posting_tail_codec()) } else { @@ -421,6 +427,7 @@ impl InvertedIndex { partitions, self.token_set_format, fragment_mask, + self.deleted_fragments.clone(), ) .with_format_version(self.format_version()) } @@ -439,6 +446,15 @@ impl InvertedIndex { self.partitions.len() } + /// Returns the set of fragments which are contained in the index, but no longer in the dataset. + /// + /// Most other indices remove data from deleted fragments when the index updates (copy-on-write). + /// However, this would require an expensive copy of the FTS index. Instead, we track the deleted + /// fragments and prune them at search time (merge-on-read). + pub fn deleted_fragments(&self) -> &RoaringBitmap { + &self.deleted_fragments + } + // search the documents that contain the query // return the row ids of the documents sorted by bm25 score // ref: https://en.wikipedia.org/wiki/Okapi_BM25 @@ -607,6 +623,7 @@ impl InvertedIndex { docs, token_set_format: TokenSetFormat::Arrow, })], + deleted_fragments: RoaringBitmap::new(), })) } @@ -648,6 +665,19 @@ impl InvertedIndex { .transpose()? .unwrap_or(TokenSetFormat::Arrow); + // Load deleted_fragments if present (optional for backward compatibility) + let deleted_fragments = if reader.num_rows() > 0 { + let metadata_batch = reader.read_range(0..1, None).await?; + if let Some(col) = metadata_batch.column_by_name(DELETED_FRAGMENTS_COL) { + let arr = col.as_binary_opt::().expect_ok()?; + RoaringBitmap::deserialize_from(arr.value(0))? + } else { + RoaringBitmap::new() + } + } else { + RoaringBitmap::new() + }; + let format = token_set_format; let partitions = partitions.into_iter().map(|id| { let store = store.clone(); @@ -680,6 +710,7 @@ impl InvertedIndex { tokenizer, token_set_format, partitions, + deleted_fragments, })) } Err(_) => { @@ -826,9 +857,11 @@ impl ScalarIndex for InvertedIndex { &self, new_data: SendableRecordBatchStream, dest_store: &dyn IndexStore, - _old_data_filter: Option, + old_data_filter: Option, ) -> Result { - self.to_builder().update(new_data, dest_store).await?; + self.to_builder() + .update(new_data, dest_store, old_data_filter) + .await?; let details = pbold::InvertedIndexDetails::try_from(&self.params)?; @@ -5070,4 +5103,54 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_modern_index_without_deleted_col_has_empty_bitmap() { + // An index created before the deleted_fragments feature was added + // will have a metadata file with num_rows=0 (no record batch data). + // The load path should gracefully handle this with an empty bitmap. + let tmpdir = TempObjDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + tmpdir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default()); + builder.tokens.add("test".to_owned()); + builder.posting_lists.push(PostingListBuilder::new(false)); + builder.posting_lists[0].add(0, PositionRecorder::Count(1)); + builder.docs.append(100, 1); + builder.write(store.as_ref()).await.unwrap(); + + // Write a metadata file WITHOUT the deleted_fragments column + // (simulates an older index version) + let metadata = std::collections::HashMap::from_iter(vec![ + ( + "partitions".to_owned(), + serde_json::to_string(&vec![0u64]).unwrap(), + ), + ( + "params".to_owned(), + serde_json::to_string(&InvertedIndexParams::default()).unwrap(), + ), + ( + TOKEN_SET_FORMAT_KEY.to_owned(), + TokenSetFormat::default().to_string(), + ), + ]); + let mut writer = store + .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty())) + .await + .unwrap(); + writer.finish_with_metadata(metadata).await.unwrap(); + + let index = InvertedIndex::load(store, None, &LanceCache::no_cache()) + .await + .unwrap(); + assert!( + index.deleted_fragments().is_empty(), + "index without deleted_fragments column should have empty bitmap" + ); + } } diff --git a/rust/lance-table/src/format/index.rs b/rust/lance-table/src/format/index.rs index 09cf59ea64c..a8f4f3f0fd5 100644 --- a/rust/lance-table/src/format/index.rs +++ b/rust/lance-table/src/format/index.rs @@ -108,6 +108,16 @@ impl IndexMetadata { .as_ref() .map(|files| files.iter().map(|f| f.size_bytes).sum()) } + + /// Returns the set of fragments which are part of the fragment bitmap + /// but no longer in the dataset. + pub fn deleted_fragment_bitmap( + &self, + existing_fragments: &RoaringBitmap, + ) -> Option { + let fragment_bitmap = self.fragment_bitmap.as_ref()?; + Some(fragment_bitmap - existing_fragments) + } } impl DeepSizeOf for IndexMetadata { diff --git a/rust/lance/src/dataset/tests/dataset_merge_update.rs b/rust/lance/src/dataset/tests/dataset_merge_update.rs index ef64773cc3c..5990318ef5c 100644 --- a/rust/lance/src/dataset/tests/dataset_merge_update.rs +++ b/rust/lance/src/dataset/tests/dataset_merge_update.rs @@ -1705,3 +1705,369 @@ async fn test_data_replacement_invalidates_index_bitmap() { "Fragment 0 should be removed from index bitmap after DataReplacement on indexed column" ); } +/// Regression test: inverted (FTS) index should not carry stale data after +/// merge_insert + compact + optimize_indices. +/// +/// This is the FTS equivalent of test_merge_insert_with_reordered_columns_and_index. +/// The inverted index's update() ignores the valid_old_fragments filter, so stale +/// posting list entries from pruned fragments survive the merge and cause errors +/// when queries try to resolve the old row addresses. +#[tokio::test] +async fn test_fts_index_stale_data_after_merge_insert_compact_optimize() { + use lance_index::scalar::{FullTextSearchQuery, inverted::InvertedIndexParams}; + + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, false), + ArrowField::new("text", DataType::Utf8, true), + ])); + + // Step 1: Create dataset with 2 rows in separate fragments + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![0, 1])), + Arc::new(StringArray::from(vec![ + "the quick brown fox", + "the lazy dog", + ])), + ], + ) + .unwrap(); + let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); + let mut dataset = Dataset::write( + reader, + "memory://test_fts_stale", + Some(WriteParams { + max_rows_per_file: 1, // Force 2 fragments + ..Default::default() + }), + ) + .await + .unwrap(); + + // Step 2: Create FTS inverted index on 'text' + let params = InvertedIndexParams::default(); + dataset + .create_index(&["text"], IndexType::Inverted, None, ¶ms, true) + .await + .unwrap(); + + // Sanity check: searching "quick" should return 1 result + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("quick".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!(results.num_rows(), 1); + + // Step 3: merge_insert with reversed column order (text, id) + // This triggers the RewriteColumns/DataReplacement path, which prunes the + // index fragment bitmap for the 'text' column. + let reversed_schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("text", DataType::Utf8, true), + ArrowField::new("id", DataType::Int32, false), + ])); + let source_batch = RecordBatch::try_new( + reversed_schema.clone(), + vec![ + Arc::new(StringArray::from(vec![ + "updated fox text", + "new entry here", + ])), + Arc::new(Int32Array::from(vec![1, 2])), + ], + ) + .unwrap(); + + let merge_job = MergeInsertBuilder::try_new(Arc::new(dataset.clone()), vec!["id".to_string()]) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::InsertAll) + .try_build() + .unwrap(); + + let reader = Box::new(RecordBatchIterator::new( + vec![Ok(source_batch)], + reversed_schema.clone(), + )); + let (dataset, _stats) = merge_job.execute(reader_to_stream(reader)).await.unwrap(); + let mut dataset = dataset.as_ref().clone(); + + // Step 4: compact_files — moves rows to new fragment(s) + compact_files(&mut dataset, CompactionOptions::default(), None) + .await + .unwrap(); + + // Step 5: optimize_indices — should rebuild the FTS index without stale data. + // With the current bug, the inverted index ignores valid_old_fragments and + // merges stale posting list entries pointing at now-deleted fragments. + dataset + .optimize_indices(&OptimizeOptions::default()) + .await + .unwrap(); + + // Step 6: FTS search should not error and should return correct results. + // "quick" appeared in the original data for id=0 (never updated), so it + // should still be found. + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("quick".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!( + results.num_rows(), + 1, + "Expected 1 result for 'quick' after optimize, got {}", + results.num_rows() + ); + + // "lazy" was in the original text for id=1, but id=1 was updated to + // "updated fox text". The old posting for "lazy" should have been filtered + // out during the index update. + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("lazy".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!( + results.num_rows(), + 0, + "Expected 0 results for 'lazy' (stale data should be filtered), got {}", + results.num_rows() + ); + + // "updated" should be found (new text for id=1) + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("updated".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!(results.num_rows(), 1); + + // "entry" should be found (new row id=2) + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("entry".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!(results.num_rows(), 1); + + // Step 7: Another merge_insert should NOT error + let source_batch2 = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1])), + Arc::new(StringArray::from(vec!["final text"])), + ], + ) + .unwrap(); + + let merge_job2 = MergeInsertBuilder::try_new(Arc::new(dataset.clone()), vec!["id".to_string()]) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::InsertAll) + .try_build() + .unwrap(); + + let reader2 = Box::new(RecordBatchIterator::new( + vec![Ok(source_batch2)], + schema.clone(), + )); + let (final_dataset, _) = merge_job2.execute(reader_to_stream(reader2)).await.unwrap(); + final_dataset.validate().await.unwrap(); +} + +/// Regression test: when rows are updated in-place, the FTS index must +/// invalidate old entries and allow re-indexing incrementally. +/// +/// Sequence: +/// 1. Write fragments 1 and 2. +/// 2. Create FTS index covering fragments 1 and 2. +/// 3. Update fragment 1 in-place via merge_insert (DataReplacement path). +/// This removes fragment 1 from the index's fragment_bitmap. +/// 4. Call optimize_indices (append) to create a new index segment covering +/// the updated fragment 1. +/// 5. Call optimize_indices (merge) to merge both segments. The first segment +/// contains the old, invalidated values for fragment 1; the second segment +/// contains the new, valid values. We must keep only the new values. +#[tokio::test] +async fn test_fts_index_incremental_reindex_after_in_place_update() { + use lance_index::scalar::{FullTextSearchQuery, inverted::InvertedIndexParams}; + + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, false), + ArrowField::new("text", DataType::Utf8, true), + ])); + + // Step 1: Create dataset with 2 rows in separate fragments + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![0, 1])), + Arc::new(StringArray::from(vec![ + "the quick brown fox", + "the lazy dog", + ])), + ], + ) + .unwrap(); + let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); + let mut dataset = Dataset::write( + reader, + "memory://test_fts_incremental_reindex", + Some(WriteParams { + max_rows_per_file: 1, // Force 2 fragments + ..Default::default() + }), + ) + .await + .unwrap(); + + // Step 2: Create FTS inverted index on 'text' + let params = InvertedIndexParams::default(); + dataset + .create_index(&["text"], IndexType::Inverted, None, ¶ms, true) + .await + .unwrap(); + + // Sanity check: "quick" and "lazy" should each return 1 result + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("quick".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!(results.num_rows(), 1); + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("lazy".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!(results.num_rows(), 1); + + // Step 3: merge_insert with reversed column order to trigger + // RewriteColumns/DataReplacement path, which prunes the index + // fragment bitmap for the updated fragment. + // Update id=1 ("the lazy dog" -> "a speedy cat") + let reversed_schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("text", DataType::Utf8, true), + ArrowField::new("id", DataType::Int32, false), + ])); + let source_batch = RecordBatch::try_new( + reversed_schema.clone(), + vec![ + Arc::new(StringArray::from(vec!["a speedy cat"])), + Arc::new(Int32Array::from(vec![1])), + ], + ) + .unwrap(); + + let merge_job = MergeInsertBuilder::try_new(Arc::new(dataset.clone()), vec!["id".to_string()]) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::DoNothing) + .try_build() + .unwrap(); + + let reader = Box::new(RecordBatchIterator::new( + vec![Ok(source_batch)], + reversed_schema.clone(), + )); + let (dataset, _stats) = merge_job.execute(reader_to_stream(reader)).await.unwrap(); + let mut dataset = dataset.as_ref().clone(); + + // Step 4: First optimize_indices (append) — creates a new index segment + // covering the updated (previously unindexed) fragment. + dataset + .optimize_indices(&OptimizeOptions::append()) + .await + .unwrap(); + + // At this point we have two index segments: + // - Segment 1: original index (has old data for fragment with id=1) + // - Segment 2: new delta index (has new data for the updated fragment) + + // Step 5: Second optimize_indices (merge all) — merges both segments. + // The merge must discard old invalidated entries from segment 1 for + // the updated fragment and keep only the new entries from segment 2. + dataset + .optimize_indices(&OptimizeOptions::default()) + .await + .unwrap(); + + // Step 6: Verify search correctness after merge. + + // "quick" was in the original data for id=0 (not updated), should still be found. + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("quick".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!( + results.num_rows(), + 1, + "Expected 1 result for 'quick' (id=0 was not updated), got {}", + results.num_rows() + ); + + // "lazy" was in the old text for id=1 which was updated to "a speedy cat". + // The old posting for "lazy" must have been filtered out during the merge. + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("lazy".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!( + results.num_rows(), + 0, + "Expected 0 results for 'lazy' (stale data should be filtered), got {}", + results.num_rows() + ); + + // "speedy" is in the new text for id=1, should be found. + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("speedy".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!( + results.num_rows(), + 1, + "Expected 1 result for 'speedy' (new text for id=1), got {}", + results.num_rows() + ); + + // "cat" is in the new text for id=1, should be found. + let results = dataset + .scan() + .full_text_search(FullTextSearchQuery::new("cat".to_owned())) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!( + results.num_rows(), + 1, + "Expected 1 result for 'cat' (new text for id=1), got {}", + results.num_rows() + ); +} diff --git a/rust/lance/src/index/append.rs b/rust/lance/src/index/append.rs index b9944b9e656..01cc6ae0321 100644 --- a/rust/lance/src/index/append.rs +++ b/rust/lance/src/index/append.rs @@ -166,6 +166,13 @@ pub async fn merge_indices_with_unindexed_frags<'a>( acc |= &b; acc }); + let deleted_old_frags: RoaringBitmap = old_indices + .iter() + .filter_map(|idx| idx.deleted_fragment_bitmap(&dataset.fragment_bitmap)) + .fold(RoaringBitmap::new(), |mut acc, b| { + acc |= &b; + acc + }); frag_bitmap |= &effective_old_frags; let index = dataset @@ -223,7 +230,10 @@ pub async fn merge_indices_with_unindexed_frags<'a>( } else { // Address-style row IDs encode fragment_id in high 32 bits. // Fragment bitmap filtering is valid and cheaper in this mode. - Some(OldIndexDataFilter::Fragments(effective_old_frags)) + Some(OldIndexDataFilter::Fragments { + to_keep: effective_old_frags, + to_remove: deleted_old_frags, + }) }; index .update(new_data_stream, &new_store, old_data_filter) diff --git a/rust/lance/src/index/prefilter.rs b/rust/lance/src/index/prefilter.rs index a86660acfa0..de1d97e1e31 100644 --- a/rust/lance/src/index/prefilter.rs +++ b/rust/lance/src/index/prefilter.rs @@ -49,6 +49,9 @@ pub struct DatasetPreFilter { // these tasks only when we've done as much work as we can without them. pub(super) deleted_ids: Option>>>, pub(super) filtered_ids: Option>>, + // Fragment IDs whose data is still in the index but has been removed from the dataset. + // Used by FTS merge-on-read to prune stale fragments at search time. + pub(super) deleted_fragments: Option, // When the tasks are finished this is the combined filter pub(super) final_mask: Mutex>>, } @@ -74,6 +77,7 @@ impl DatasetPreFilter { Self { deleted_ids, filtered_ids, + deleted_fragments: None, final_mask: Mutex::new(OnceCell::new()), } } @@ -174,6 +178,14 @@ impl DatasetPreFilter { .await } + /// Sets the deleted fragment IDs to block during search. + /// + /// Used by FTS indices which track fragments that have been removed from the + /// dataset but whose data is still present in the index (merge-on-read). + pub fn set_deleted_fragments(&mut self, fragments: RoaringBitmap) { + self.deleted_fragments = Some(fragments); + } + /// Creates a task to load mask to filter out deleted rows. /// /// Sometimes this will be a block list of row ids that are deleted, based @@ -244,6 +256,13 @@ impl PreFilter for DatasetPreFilter { if let Some(deleted_ids) = &self.deleted_ids { combined = combined & (*deleted_ids.get_ready()).clone(); } + if let Some(deleted) = &self.deleted_fragments { + let mut block_list = RowAddrTreeMap::new(); + for frag_id in deleted.iter() { + block_list.insert_fragment(frag_id); + } + combined = combined & RowAddrMask::from_block(block_list); + } Arc::new(combined) }); @@ -251,7 +270,9 @@ impl PreFilter for DatasetPreFilter { } fn is_empty(&self) -> bool { - self.deleted_ids.is_none() && self.filtered_ids.is_none() + self.deleted_ids.is_none() + && self.filtered_ids.is_none() + && self.deleted_fragments.is_none() } /// Get the row id mask for this prefilter diff --git a/rust/lance/src/index/vector/fixture_test.rs b/rust/lance/src/index/vector/fixture_test.rs index bf4f122be4b..facfd1f0cb3 100644 --- a/rust/lance/src/index/vector/fixture_test.rs +++ b/rust/lance/src/index/vector/fixture_test.rs @@ -278,6 +278,7 @@ mod test { Arc::new(DatasetPreFilter { deleted_ids: None, filtered_ids: None, + deleted_fragments: None, final_mask: Mutex::new(OnceCell::new()), }), &NoOpMetricsCollector, diff --git a/rust/lance/src/io/exec/fts.rs b/rust/lance/src/io/exec/fts.rs index 6e129841c76..9de276c64ef 100644 --- a/rust/lance/src/io/exec/fts.rs +++ b/rust/lance/src/io/exec/fts.rs @@ -242,7 +242,7 @@ impl ExecutionPlan for MatchQueryExec { .open_generic_index(&column, &uuid, &metrics.index_metrics) .await?; - let pre_filter = build_prefilter( + let mut pre_filter = build_prefilter( context.clone(), partition, &prefilter_source, @@ -259,6 +259,11 @@ impl ExecutionPlan for MatchQueryExec { column, )) })?; + if !inverted_idx.deleted_fragments().is_empty() { + Arc::get_mut(&mut pre_filter) + .expect("prefilter just created") + .set_deleted_fragments(inverted_idx.deleted_fragments().clone()); + } metrics.record_parts_searched(inverted_idx.partition_count()); let is_fuzzy = matches!(query.fuzziness, Some(n) if n != 0); @@ -888,7 +893,7 @@ impl ExecutionPlan for PhraseQueryExec { .open_generic_index(&column, &uuid, &metrics.index_metrics) .await?; - let pre_filter = build_prefilter( + let mut pre_filter = build_prefilter( context.clone(), partition, &prefilter_source, @@ -905,6 +910,11 @@ impl ExecutionPlan for PhraseQueryExec { column, )) })?; + if !index.deleted_fragments().is_empty() { + Arc::get_mut(&mut pre_filter) + .expect("prefilter just created") + .set_deleted_fragments(index.deleted_fragments().clone()); + } metrics.record_parts_searched(index.partition_count()); let mut tokenizer = index.tokenizer(); From 75f01954f20e6491bec7f4509eeab1e1dbbc92e3 Mon Sep 17 00:00:00 2001 From: YueZhang <69956021+zhangyue19921010@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:48:42 +0800 Subject: [PATCH 075/206] feat(DirectoryNamespace): support index and transaction related operations (#6196) #### Index and transaction - `create_table_index` - `list_table_indices` - `describe_table_index_stats` - `describe_transaction` - `create_table_scalar_index` - `drop_table_index` --------- Co-authored-by: zhangyue19921010 --- Cargo.lock | 1 + java/lance-jni/Cargo.lock | 1 + python/Cargo.lock | 1 + rust/lance-namespace-impls/Cargo.toml | 1 + rust/lance-namespace-impls/src/dir.rs | 1086 ++++++++++++++++++++++++- 5 files changed, 1083 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a1b759fa6d5..951c11ddf37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5150,6 +5150,7 @@ dependencies = [ "lance-core", "lance-index", "lance-io", + "lance-linalg", "lance-namespace", "lance-table", "log", diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 0cd870eed82..df85f7c2759 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -3827,6 +3827,7 @@ dependencies = [ "lance-core", "lance-index", "lance-io", + "lance-linalg", "lance-namespace", "lance-table", "log", diff --git a/python/Cargo.lock b/python/Cargo.lock index 6221084213f..61586caef09 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -4265,6 +4265,7 @@ dependencies = [ "lance-core", "lance-index", "lance-io", + "lance-linalg", "lance-namespace", "lance-table", "log", diff --git a/rust/lance-namespace-impls/Cargo.toml b/rust/lance-namespace-impls/Cargo.toml index 258b09b90de..80c6ec4cb4d 100644 --- a/rust/lance-namespace-impls/Cargo.toml +++ b/rust/lance-namespace-impls/Cargo.toml @@ -43,6 +43,7 @@ reqwest = { version = "0.12", optional = true, default-features = false, feature url = { workspace = true } lance = { workspace = true } lance-index = { workspace = true } +lance-linalg = { workspace = true } lance-io = { workspace = true } lance-table = { workspace = true } object_store = { workspace = true } diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index e2cac3504aa..7718fc95a01 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -14,9 +14,18 @@ use async_trait::async_trait; use bytes::Bytes; use futures::TryStreamExt; use lance::dataset::builder::DatasetBuilder; +use lance::dataset::transaction::{Operation, Transaction}; use lance::dataset::{Dataset, WriteMode, WriteParams}; +use lance::index::{IndexParams, vector::VectorIndexParams}; use lance::session::Session; +use lance_index::scalar::{BuiltinIndexType, InvertedIndexParams, ScalarIndexParams}; +use lance_index::vector::{ + bq::RQBuildParams, hnsw::builder::HnswBuildParams, ivf::IvfBuildParams, pq::PQBuildParams, + sq::builder::SQBuildParams, +}; +use lance_index::{DatasetIndexExt, IndexType, is_system_index}; use lance_io::object_store::{ObjectStore, ObjectStoreParams, ObjectStoreRegistry}; +use lance_linalg::distance::MetricType; use lance_table::io::commit::ManifestNamingScheme; use object_store::path::Path; use object_store::{Error as ObjectStoreError, ObjectStore as OSObjectStore, PutMode, PutOptions}; @@ -27,14 +36,18 @@ use std::sync::Arc; use crate::context::DynamicContextProvider; use lance_namespace::models::{ BatchDeleteTableVersionsRequest, BatchDeleteTableVersionsResponse, CreateNamespaceRequest, - CreateNamespaceResponse, CreateTableRequest, CreateTableResponse, CreateTableVersionRequest, + CreateNamespaceResponse, CreateTableIndexRequest, CreateTableIndexResponse, CreateTableRequest, + CreateTableResponse, CreateTableScalarIndexResponse, CreateTableVersionRequest, CreateTableVersionResponse, DeclareTableRequest, DeclareTableResponse, - DescribeNamespaceRequest, DescribeNamespaceResponse, DescribeTableRequest, - DescribeTableResponse, DescribeTableVersionRequest, DescribeTableVersionResponse, - DropNamespaceRequest, DropNamespaceResponse, DropTableRequest, DropTableResponse, Identity, - ListNamespacesRequest, ListNamespacesResponse, ListTableVersionsRequest, - ListTableVersionsResponse, ListTablesRequest, ListTablesResponse, NamespaceExistsRequest, - TableExistsRequest, TableVersion, + DescribeNamespaceRequest, DescribeNamespaceResponse, DescribeTableIndexStatsRequest, + DescribeTableIndexStatsResponse, DescribeTableRequest, DescribeTableResponse, + DescribeTableVersionRequest, DescribeTableVersionResponse, DescribeTransactionRequest, + DescribeTransactionResponse, DropNamespaceRequest, DropNamespaceResponse, + DropTableIndexRequest, DropTableIndexResponse, DropTableRequest, DropTableResponse, Identity, + IndexContent, ListNamespacesRequest, ListNamespacesResponse, ListTableIndicesRequest, + ListTableIndicesResponse, ListTableVersionsRequest, ListTableVersionsResponse, + ListTablesRequest, ListTablesResponse, NamespaceExistsRequest, TableExistsRequest, + TableVersion, }; use lance_core::{Error, Result, box_error}; @@ -58,6 +71,35 @@ pub(crate) struct TableStatus { pub(crate) has_reserved_file: bool, } +enum DirectoryIndexParams { + Scalar { + index_type: IndexType, + params: ScalarIndexParams, + }, + Inverted(InvertedIndexParams), + Vector { + index_type: IndexType, + params: VectorIndexParams, + }, +} + +impl DirectoryIndexParams { + fn index_type(&self) -> IndexType { + match self { + Self::Scalar { index_type, .. } | Self::Vector { index_type, .. } => *index_type, + Self::Inverted(_) => IndexType::Inverted, + } + } + + fn params(&self) -> &dyn IndexParams { + match self { + Self::Scalar { params, .. } => params, + Self::Inverted(params) => params, + Self::Vector { params, .. } => params, + } + } +} + /// Builder for creating a DirectoryNamespace. /// /// This builder provides a fluent API for configuring and establishing @@ -753,6 +795,382 @@ impl DirectoryNamespace { }) } + async fn load_dataset( + &self, + table_uri: &str, + version: Option, + operation: &str, + ) -> Result { + if let Some(version) = version + && version < 0 + { + return Err(Error::invalid_input_source( + format!( + "Table version for {} must be non-negative, got {}", + operation, version + ) + .into(), + )); + } + + let mut builder = DatasetBuilder::from_uri(table_uri); + if let Some(opts) = &self.storage_options { + builder = builder.with_storage_options(opts.clone()); + } + if let Some(sess) = &self.session { + builder = builder.with_session(sess.clone()); + } + + let dataset = builder.load().await.map_err(|e| { + Error::namespace_source( + format!( + "Failed to open table at '{}' for {}: {}", + table_uri, operation, e + ) + .into(), + ) + })?; + + if let Some(version) = version { + return dataset.checkout_version(version as u64).await.map_err(|e| { + Error::namespace_source( + format!( + "Failed to checkout version {} for table at '{}' during {}: {}", + version, table_uri, operation, e + ) + .into(), + ) + }); + } + + Ok(dataset) + } + + fn parse_index_type(index_type: &str) -> Result { + match index_type.trim().to_ascii_uppercase().as_str() { + "SCALAR" | "BTREE" => Ok(IndexType::BTree), + "BITMAP" => Ok(IndexType::Bitmap), + "LABEL_LIST" | "LABELLIST" => Ok(IndexType::LabelList), + "INVERTED" | "FTS" => Ok(IndexType::Inverted), + "NGRAM" => Ok(IndexType::NGram), + "ZONEMAP" | "ZONE_MAP" => Ok(IndexType::ZoneMap), + "BLOOMFILTER" | "BLOOM_FILTER" => Ok(IndexType::BloomFilter), + "RTREE" | "R_TREE" => Ok(IndexType::RTree), + "VECTOR" | "IVF_PQ" => Ok(IndexType::IvfPq), + "IVF_FLAT" => Ok(IndexType::IvfFlat), + "IVF_SQ" => Ok(IndexType::IvfSq), + "IVF_RQ" => Ok(IndexType::IvfRq), + "IVF_HNSW_FLAT" => Ok(IndexType::IvfHnswFlat), + "IVF_HNSW_SQ" => Ok(IndexType::IvfHnswSq), + "IVF_HNSW_PQ" => Ok(IndexType::IvfHnswPq), + other => Err(Error::invalid_input_source( + format!("Unsupported index_type '{}'", other).into(), + )), + } + } + + fn parse_metric_type(distance_type: Option<&str>) -> Result { + let distance_type = distance_type.unwrap_or("l2"); + MetricType::try_from(distance_type).map_err(|e| { + Error::invalid_input_source( + format!( + "Unsupported distance_type '{}' for vector index: {}", + distance_type, e + ) + .into(), + ) + }) + } + + fn build_index_params(request: &CreateTableIndexRequest) -> Result { + let index_type = Self::parse_index_type(&request.index_type)?; + Ok(match index_type { + IndexType::BTree => DirectoryIndexParams::Scalar { + index_type, + params: ScalarIndexParams::for_builtin(BuiltinIndexType::BTree), + }, + IndexType::Bitmap => DirectoryIndexParams::Scalar { + index_type, + params: ScalarIndexParams::for_builtin(BuiltinIndexType::Bitmap), + }, + IndexType::LabelList => DirectoryIndexParams::Scalar { + index_type, + params: ScalarIndexParams::for_builtin(BuiltinIndexType::LabelList), + }, + IndexType::NGram => DirectoryIndexParams::Scalar { + index_type, + params: ScalarIndexParams::for_builtin(BuiltinIndexType::NGram), + }, + IndexType::ZoneMap => DirectoryIndexParams::Scalar { + index_type, + params: ScalarIndexParams::for_builtin(BuiltinIndexType::ZoneMap), + }, + IndexType::BloomFilter => DirectoryIndexParams::Scalar { + index_type, + params: ScalarIndexParams::for_builtin(BuiltinIndexType::BloomFilter), + }, + IndexType::RTree => DirectoryIndexParams::Scalar { + index_type, + params: ScalarIndexParams::for_builtin(BuiltinIndexType::RTree), + }, + IndexType::Inverted => { + let mut params = InvertedIndexParams::default(); + if let Some(with_position) = request.with_position { + params = params.with_position(with_position); + } + if let Some(base_tokenizer) = &request.base_tokenizer { + params = params.base_tokenizer(base_tokenizer.clone()); + } + if let Some(language) = &request.language { + params = params.language(language)?; + } + if let Some(max_token_length) = request.max_token_length { + if max_token_length < 0 { + return Err(Error::invalid_input_source( + format!( + "FTS max_token_length must be non-negative, got {}", + max_token_length + ) + .into(), + )); + } + params = params.max_token_length(Some(max_token_length as usize)); + } + if let Some(lower_case) = request.lower_case { + params = params.lower_case(lower_case); + } + if let Some(stem) = request.stem { + params = params.stem(stem); + } + if let Some(remove_stop_words) = request.remove_stop_words { + params = params.remove_stop_words(remove_stop_words); + } + if let Some(ascii_folding) = request.ascii_folding { + params = params.ascii_folding(ascii_folding); + } + DirectoryIndexParams::Inverted(params) + } + IndexType::IvfFlat => DirectoryIndexParams::Vector { + index_type, + params: VectorIndexParams::with_ivf_flat_params( + Self::parse_metric_type(request.distance_type.as_deref())?, + IvfBuildParams::default(), + ), + }, + IndexType::IvfPq => DirectoryIndexParams::Vector { + index_type, + params: VectorIndexParams::with_ivf_pq_params( + Self::parse_metric_type(request.distance_type.as_deref())?, + IvfBuildParams::default(), + PQBuildParams::default(), + ), + }, + IndexType::IvfSq => DirectoryIndexParams::Vector { + index_type, + params: VectorIndexParams::with_ivf_sq_params( + Self::parse_metric_type(request.distance_type.as_deref())?, + IvfBuildParams::default(), + SQBuildParams::default(), + ), + }, + IndexType::IvfRq => DirectoryIndexParams::Vector { + index_type, + params: VectorIndexParams::with_ivf_rq_params( + Self::parse_metric_type(request.distance_type.as_deref())?, + IvfBuildParams::default(), + RQBuildParams::default(), + ), + }, + IndexType::IvfHnswFlat => DirectoryIndexParams::Vector { + index_type, + params: VectorIndexParams::ivf_hnsw( + Self::parse_metric_type(request.distance_type.as_deref())?, + IvfBuildParams::default(), + HnswBuildParams::default(), + ), + }, + IndexType::IvfHnswSq => DirectoryIndexParams::Vector { + index_type, + params: VectorIndexParams::with_ivf_hnsw_sq_params( + Self::parse_metric_type(request.distance_type.as_deref())?, + IvfBuildParams::default(), + HnswBuildParams::default(), + SQBuildParams::default(), + ), + }, + IndexType::IvfHnswPq => DirectoryIndexParams::Vector { + index_type, + params: VectorIndexParams::with_ivf_hnsw_pq_params( + Self::parse_metric_type(request.distance_type.as_deref())?, + IvfBuildParams::default(), + HnswBuildParams::default(), + PQBuildParams::default(), + ), + }, + other => { + return Err(Error::invalid_input_source( + format!("Unsupported index type for namespace API: {}", other).into(), + )); + } + }) + } + + fn paginate_indices( + indices: &mut Vec, + page_token: Option, + limit: Option, + ) -> Option { + indices.sort_by(|a, b| a.index_name.cmp(&b.index_name)); + + if let Some(start_after) = page_token { + if let Some(index) = indices + .iter() + .position(|index| index.index_name.as_str() > start_after.as_str()) + { + indices.drain(0..index); + } else { + indices.clear(); + } + } + + let mut next_page_token = None; + if let Some(limit) = limit + && limit >= 0 + { + let limit = limit as usize; + if limit > 0 && indices.len() > limit { + next_page_token = Some(indices[limit - 1].index_name.clone()); + } + indices.truncate(limit); + } + if indices.is_empty() { + None + } else { + next_page_token + } + } + + fn transaction_operation_name(transaction: &Transaction) -> String { + match &transaction.operation { + Operation::CreateIndex { + new_indices, + removed_indices, + } if new_indices.is_empty() && !removed_indices.is_empty() => "DropIndex".to_string(), + _ => transaction.operation.to_string(), + } + } + + fn transaction_response( + version: u64, + transaction: &Transaction, + ) -> DescribeTransactionResponse { + let mut properties = transaction + .transaction_properties + .as_ref() + .map(|properties| (**properties).clone()) + .unwrap_or_default(); + properties.insert("uuid".to_string(), transaction.uuid.clone()); + properties.insert("version".to_string(), version.to_string()); + properties.insert( + "read_version".to_string(), + transaction.read_version.to_string(), + ); + properties.insert( + "operation".to_string(), + Self::transaction_operation_name(transaction), + ); + if let Some(tag) = &transaction.tag { + properties.insert("tag".to_string(), tag.clone()); + } + + DescribeTransactionResponse { + status: "SUCCEEDED".to_string(), + properties: Some(properties), + } + } + + fn describe_table_index_stats_response( + stats: &serde_json::Value, + ) -> DescribeTableIndexStatsResponse { + let get_i64 = |key: &str| { + stats.get(key).and_then(|value| { + value + .as_i64() + .or_else(|| value.as_u64().and_then(|v| i64::try_from(v).ok())) + }) + }; + + DescribeTableIndexStatsResponse { + distance_type: stats + .get("distance_type") + .and_then(|value| value.as_str()) + .map(str::to_string), + index_type: stats + .get("index_type") + .and_then(|value| value.as_str()) + .map(str::to_string), + num_indexed_rows: get_i64("num_indexed_rows"), + num_unindexed_rows: get_i64("num_unindexed_rows"), + num_indices: get_i64("num_indices").and_then(|value| i32::try_from(value).ok()), + } + } + + /// When transaction_id is not parseable as a version number (i.e. it's a UUID), + /// find_transaction iterates through every version in reverse, reading each + /// transaction file from storage. For tables with many versions this will + /// be extremely slow — each iteration is a separate I/O call. + async fn find_transaction(&self, dataset: &Dataset, id: &str) -> Result<(u64, Transaction)> { + if let Ok(version) = id.parse::() { + let transaction = dataset + .read_transaction_by_version(version) + .await + .map_err(|e| { + Error::namespace_source( + format!("Failed to read transaction for version {}: {}", version, e).into(), + ) + })? + .ok_or_else(|| { + Error::namespace_source( + format!("Transaction not found for version {}", version).into(), + ) + })?; + return Ok((version, transaction)); + } + + let versions = dataset.versions().await.map_err(|e| { + Error::namespace_source( + format!( + "Failed to list table versions while resolving transaction '{}': {}", + id, e + ) + .into(), + ) + })?; + + for version in versions.into_iter().rev() { + if let Some(transaction) = dataset + .read_transaction_by_version(version.version) + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to read transaction for version {} while resolving '{}': {}", + version.version, id, e + ) + .into(), + ) + })? + && transaction.uuid == id + { + return Ok((version.version, transaction)); + } + } + + Err(Error::namespace_source( + format!("Transaction not found: {}", id).into(), + )) + } + fn table_full_uri(&self, table_name: &str) -> String { format!("{}/{}.lance", &self.root, table_name) } @@ -1987,6 +2405,291 @@ impl LanceNamespace for DirectoryNamespace { }) } + async fn create_table_index( + &self, + request: CreateTableIndexRequest, + ) -> Result { + let table_uri = self.resolve_table_location(&request.id).await?; + let mut dataset = self + .load_dataset(&table_uri, None, "create_table_index") + .await?; + let index_request = Self::build_index_params(&request)?; + + dataset + .create_index( + &[request.column.as_str()], + index_request.index_type(), + request.name.clone(), + index_request.params(), + false, + ) + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to create {} index '{}' on column '{}' for table '{}': {}", + request.index_type, + request.name.as_deref().unwrap_or(""), + request.column, + table_uri, + e + ) + .into(), + ) + })?; + + let transaction_id = dataset + .read_transaction() + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to read committed transaction after creating index on '{}': {}", + table_uri, e + ) + .into(), + ) + })? + .map(|transaction| transaction.uuid); + + Ok(CreateTableIndexResponse { transaction_id }) + } + + async fn list_table_indices( + &self, + request: ListTableIndicesRequest, + ) -> Result { + let table_uri = self.resolve_table_location(&request.id).await?; + let dataset = self + .load_dataset(&table_uri, request.version, "list_table_indices") + .await?; + let mut indices = dataset + .describe_indices(None) + .await + .map_err(|e| { + Error::namespace_source( + format!("Failed to describe table indices for '{}': {}", table_uri, e).into(), + ) + })? + .into_iter() + .filter(|description| { + description + .metadata() + .first() + .map(|metadata| !is_system_index(metadata)) + .unwrap_or(false) + }) + .map(|description| { + let columns = description + .field_ids() + .iter() + .map(|field_id| { + dataset + .schema() + .field_path(i32::try_from(*field_id).map_err(|e| { + Error::namespace_source( + format!( + "Field id {} does not fit in i32 for table '{}': {}", + field_id, table_uri, e + ) + .into(), + ) + })?) + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to resolve field path for field_id {} in table '{}': {}", + field_id, table_uri, e + ) + .into(), + ) + }) + }) + .collect::>>()?; + + Ok(IndexContent { + index_name: description.name().to_string(), + index_uuid: description.metadata()[0].uuid.to_string(), + columns, + status: "SUCCEEDED".to_string(), + }) + }) + .collect::>>()?; + + let page_token = Self::paginate_indices(&mut indices, request.page_token, request.limit); + Ok(ListTableIndicesResponse { + indexes: indices, + page_token, + }) + } + + async fn describe_table_index_stats( + &self, + request: DescribeTableIndexStatsRequest, + ) -> Result { + let table_uri = self.resolve_table_location(&request.id).await?; + let dataset = self + .load_dataset(&table_uri, request.version, "describe_table_index_stats") + .await?; + let index_name = request.index_name.as_deref().ok_or_else(|| { + Error::invalid_input_source( + "Index name is required for describe_table_index_stats".into(), + ) + })?; + let metadatas = dataset + .load_indices_by_name(index_name) + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to load index '{}' metadata for table '{}': {}", + index_name, table_uri, e + ) + .into(), + ) + })?; + if metadatas.first().is_some_and(is_system_index) { + return Err(Error::not_supported_source( + format!("System index '{}' is not exposed by this API", index_name).into(), + )); + } + + let stats = + ::index_statistics(&dataset, index_name) + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to describe index statistics for '{}' on table '{}': {}", + index_name, table_uri, e + ) + .into(), + ) + })?; + let stats: serde_json::Value = serde_json::from_str(&stats).map_err(|e| { + Error::namespace_source( + format!( + "Failed to parse index statistics for '{}' on table '{}': {}", + index_name, table_uri, e + ) + .into(), + ) + })?; + + Ok(Self::describe_table_index_stats_response(&stats)) + } + + async fn describe_transaction( + &self, + request: DescribeTransactionRequest, + ) -> Result { + let mut request_id = request.id.ok_or_else(|| { + Error::invalid_input_source( + "Transaction id must include table id and transaction identifier".into(), + ) + })?; + if request_id.len() < 2 { + return Err(Error::invalid_input_source( + format!( + "Transaction request id must include table id and transaction identifier, got {:?}", + request_id + ) + .into(), + )); + } + + let id = request_id.pop().expect("request_id len checked above"); + let table_id = Some(request_id); + let table_uri = self.resolve_table_location(&table_id).await?; + let dataset = self + .load_dataset(&table_uri, None, "describe_transaction") + .await?; + let (version, transaction) = self.find_transaction(&dataset, &id).await?; + + Ok(Self::transaction_response(version, &transaction)) + } + + async fn create_table_scalar_index( + &self, + request: CreateTableIndexRequest, + ) -> Result { + let index_type = Self::parse_index_type(&request.index_type)?; + if !index_type.is_scalar() { + return Err(Error::invalid_input_source( + format!( + "create_table_scalar_index only supports scalar index types, got {}", + request.index_type + ) + .into(), + )); + } + + let response = self.create_table_index(request).await?; + Ok(CreateTableScalarIndexResponse { + transaction_id: response.transaction_id, + }) + } + + async fn drop_table_index( + &self, + request: DropTableIndexRequest, + ) -> Result { + let table_uri = self.resolve_table_location(&request.id).await?; + let index_name = request.index_name.as_deref().ok_or_else(|| { + Error::invalid_input_source("Index name is required for drop_table_index".into()) + })?; + let mut dataset = self + .load_dataset(&table_uri, None, "drop_table_index") + .await?; + let metadatas = dataset + .load_indices_by_name(index_name) + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to load index '{}' before dropping it from table '{}': {}", + index_name, table_uri, e + ) + .into(), + ) + })?; + if metadatas.first().is_some_and(is_system_index) { + return Err(Error::not_supported_source( + format!( + "System index '{}' cannot be dropped via this API", + index_name + ) + .into(), + )); + } + + dataset.drop_index(index_name).await.map_err(|e| { + Error::namespace_source( + format!( + "Failed to drop index '{}' from table '{}': {}", + index_name, table_uri, e + ) + .into(), + ) + })?; + + let transaction_id = dataset + .read_transaction() + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to read committed transaction after dropping index '{}' from '{}': {}", + index_name, table_uri, e + ) + .into(), + ) + })? + .map(|transaction| transaction.uuid); + + Ok(DropTableIndexResponse { transaction_id }) + } + fn namespace_id(&self) -> String { format!("DirectoryNamespace {{ root: {:?} }}", self.root) } @@ -1998,6 +2701,7 @@ mod tests { use arrow_ipc::reader::StreamReader; use lance::dataset::Dataset; use lance_core::utils::tempfile::{TempStdDir, TempStrDir}; + use lance_index::DatasetIndexExt; use lance_namespace::models::{ CreateTableRequest, JsonArrowDataType, JsonArrowField, JsonArrowSchema, ListTablesRequest, }; @@ -2032,6 +2736,23 @@ mod tests { buffer } + fn create_ipc_data_from_batches( + schema: Arc, + batches: Vec, + ) -> Vec { + use arrow::ipc::writer::StreamWriter; + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &schema).unwrap(); + for batch in &batches { + writer.write(batch).unwrap(); + } + writer.finish().unwrap(); + } + buffer + } + /// Helper to create a simple test schema fn create_test_schema() -> JsonArrowSchema { let int_type = JsonArrowDataType::new("int32".to_string()); @@ -2057,6 +2778,107 @@ mod tests { } } + fn create_scalar_table_ipc_data() -> Vec { + use arrow::array::{Int32Array, StringArray}; + use arrow::datatypes::{DataType, Field, Schema as ArrowSchema}; + + let schema = Arc::new(ArrowSchema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("name", DataType::Utf8, true), + ])); + let batch = arrow::record_batch::RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2, 3])), + Arc::new(StringArray::from(vec!["alice", "bob", "cory"])), + ], + ) + .unwrap(); + create_ipc_data_from_batches(schema, vec![batch]) + } + + fn create_vector_table_ipc_data() -> Vec { + use arrow::array::{FixedSizeListArray, Float32Array, Int32Array}; + use arrow::datatypes::{DataType, Field, Schema as ArrowSchema}; + + let schema = Arc::new(ArrowSchema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 2), + true, + ), + ])); + let vector_field = Arc::new(Field::new("item", DataType::Float32, true)); + let vectors = FixedSizeListArray::try_new( + vector_field, + 2, + Arc::new(Float32Array::from(vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6])), + None, + ) + .unwrap(); + let batch = arrow::record_batch::RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![1, 2, 3])), Arc::new(vectors)], + ) + .unwrap(); + create_ipc_data_from_batches(schema, vec![batch]) + } + + async fn create_scalar_table(namespace: &DirectoryNamespace, table_name: &str) { + let mut create_table_request = CreateTableRequest::new(); + create_table_request.id = Some(vec![table_name.to_string()]); + namespace + .create_table( + create_table_request, + Bytes::from(create_scalar_table_ipc_data()), + ) + .await + .unwrap(); + } + + async fn create_vector_table(namespace: &DirectoryNamespace, table_name: &str) { + let mut create_table_request = CreateTableRequest::new(); + create_table_request.id = Some(vec![table_name.to_string()]); + namespace + .create_table( + create_table_request, + Bytes::from(create_vector_table_ipc_data()), + ) + .await + .unwrap(); + } + + async fn open_dataset(namespace: &DirectoryNamespace, table_name: &str) -> Dataset { + let mut describe_request = DescribeTableRequest::new(); + describe_request.id = Some(vec![table_name.to_string()]); + let table_uri = namespace + .describe_table(describe_request) + .await + .unwrap() + .location + .expect("table location should exist"); + Dataset::open(&table_uri).await.unwrap() + } + + async fn create_scalar_index( + namespace: &DirectoryNamespace, + table_name: &str, + index_name: &str, + ) -> Option { + use lance_namespace::models::CreateTableIndexRequest; + + let mut create_index_request = + CreateTableIndexRequest::new("id".to_string(), "BTREE".to_string()); + create_index_request.id = Some(vec![table_name.to_string()]); + create_index_request.name = Some(index_name.to_string()); + namespace + .create_table_scalar_index(create_index_request) + .await + .unwrap() + .transaction_id + } + #[tokio::test] async fn test_create_table() { let (namespace, _temp_dir) = create_test_namespace().await; @@ -2199,6 +3021,256 @@ mod tests { ); } + #[tokio::test] + async fn test_create_scalar_index() { + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "users").await; + + let transaction_id = create_scalar_index(&namespace, "users", "users_id_idx").await; + let dataset = open_dataset(&namespace, "users").await; + let expected_transaction_id = dataset + .read_transaction() + .await + .unwrap() + .map(|transaction| transaction.uuid); + assert_eq!(transaction_id, expected_transaction_id); + let indices = dataset.load_indices().await.unwrap(); + assert!(indices.iter().any(|index| index.name == "users_id_idx")); + } + + #[tokio::test] + async fn test_create_vector_index() { + use lance_namespace::models::CreateTableIndexRequest; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_vector_table(&namespace, "vectors").await; + + let mut create_index_request = + CreateTableIndexRequest::new("vector".to_string(), "IVF_FLAT".to_string()); + create_index_request.id = Some(vec!["vectors".to_string()]); + create_index_request.name = Some("vector_idx".to_string()); + create_index_request.distance_type = Some("l2".to_string()); + let transaction_id = namespace + .create_table_index(create_index_request) + .await + .unwrap() + .transaction_id; + + let dataset = open_dataset(&namespace, "vectors").await; + let expected_transaction_id = dataset + .read_transaction() + .await + .unwrap() + .map(|transaction| transaction.uuid); + assert_eq!(transaction_id, expected_transaction_id); + let indices = dataset.load_indices().await.unwrap(); + assert!(indices.iter().any(|index| index.name == "vector_idx")); + } + + #[tokio::test] + async fn test_list_table_indices() { + use lance_namespace::models::ListTableIndicesRequest; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "users").await; + create_scalar_index(&namespace, "users", "a_idx").await; + create_scalar_index(&namespace, "users", "b_idx").await; + let transaction_id = create_scalar_index(&namespace, "users", "users_id_idx").await; + + let response = namespace + .list_table_indices(ListTableIndicesRequest { + id: Some(vec!["users".to_string()]), + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(response.indexes.len(), 3); + assert_eq!(response.indexes[0].index_name, "a_idx"); + assert_eq!(response.indexes[1].index_name, "b_idx"); + assert_eq!(response.indexes[2].index_name, "users_id_idx"); + assert!(response.page_token.is_none()); + let users_id_idx = response + .indexes + .iter() + .find(|index| index.index_name == "users_id_idx") + .unwrap(); + assert_eq!(users_id_idx.columns, vec!["id"]); + assert_eq!(users_id_idx.status, "SUCCEEDED"); + + let dataset = open_dataset(&namespace, "users").await; + let expected_transaction_id = dataset + .read_transaction() + .await + .unwrap() + .map(|transaction| transaction.uuid); + assert_eq!(transaction_id, expected_transaction_id); + let indices = dataset.load_indices().await.unwrap(); + assert_eq!( + indices + .iter() + .filter(|index| index.name == "users_id_idx") + .count(), + 1 + ); + + let first_page = namespace + .list_table_indices(ListTableIndicesRequest { + id: Some(vec!["users".to_string()]), + limit: Some(2), + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(first_page.indexes.len(), 2); + assert_eq!(first_page.indexes[0].index_name, "a_idx"); + assert_eq!(first_page.indexes[1].index_name, "b_idx"); + assert_eq!(first_page.page_token.as_deref(), Some("b_idx")); + + let second_page = namespace + .list_table_indices(ListTableIndicesRequest { + id: Some(vec!["users".to_string()]), + page_token: first_page.page_token.clone(), + limit: Some(2), + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(second_page.indexes.len(), 1); + assert_eq!(second_page.indexes[0].index_name, "users_id_idx"); + assert!(second_page.page_token.is_none()); + } + + #[tokio::test] + async fn test_describe_table_index_stats() { + use lance_namespace::models::DescribeTableIndexStatsRequest; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "users").await; + let transaction_id = create_scalar_index(&namespace, "users", "users_id_idx").await; + + let response = namespace + .describe_table_index_stats(DescribeTableIndexStatsRequest { + id: Some(vec!["users".to_string()]), + index_name: Some("users_id_idx".to_string()), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(response.index_type, Some("BTree".to_string())); + assert_eq!(response.num_indices, Some(1)); + assert_eq!(response.num_indexed_rows, Some(3)); + assert_eq!(response.num_unindexed_rows, Some(0)); + + let dataset = open_dataset(&namespace, "users").await; + let expected_transaction_id = dataset + .read_transaction() + .await + .unwrap() + .map(|transaction| transaction.uuid); + assert_eq!(transaction_id, expected_transaction_id); + let stats: serde_json::Value = + serde_json::from_str(&dataset.index_statistics("users_id_idx").await.unwrap()).unwrap(); + assert_eq!(stats["index_type"], "BTree"); + assert_eq!(stats["num_indices"], 1); + assert_eq!(stats["num_indexed_rows"], 3); + assert_eq!(stats["num_unindexed_rows"], 0); + } + + #[tokio::test] + async fn test_describe_transaction() { + use lance_namespace::models::DescribeTransactionRequest; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "users").await; + let transaction_id = create_scalar_index(&namespace, "users", "users_id_idx").await; + let dataset = open_dataset(&namespace, "users").await; + let latest_transaction = dataset.read_transaction().await.unwrap(); + assert_eq!( + transaction_id, + latest_transaction + .as_ref() + .map(|transaction| transaction.uuid.clone()) + ); + + if let Some(transaction_id) = transaction_id { + let response = namespace + .describe_transaction(DescribeTransactionRequest { + id: Some(vec!["users".to_string(), transaction_id.clone()]), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(response.status, "SUCCEEDED"); + assert_eq!( + response + .properties + .as_ref() + .and_then(|props| props.get("operation")), + Some(&"CreateIndex".to_string()) + ); + assert_eq!( + response + .properties + .as_ref() + .and_then(|props| props.get("uuid")), + Some(&transaction_id) + ); + } else { + assert!(latest_transaction.is_none()); + } + } + + #[tokio::test] + async fn test_drop_table_index() { + use lance_namespace::models::{DropTableIndexRequest, ListTableIndicesRequest}; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "users").await; + let create_transaction_id = create_scalar_index(&namespace, "users", "users_id_idx").await; + + let drop_transaction_id = namespace + .drop_table_index(DropTableIndexRequest { + id: Some(vec!["users".to_string()]), + index_name: Some("users_id_idx".to_string()), + ..Default::default() + }) + .await + .unwrap() + .transaction_id; + + let dataset = open_dataset(&namespace, "users").await; + let previous_dataset = dataset + .checkout_version(dataset.version().version - 1) + .await + .unwrap(); + let previous_transaction_id = previous_dataset + .read_transaction() + .await + .unwrap() + .map(|transaction| transaction.uuid); + assert_eq!(create_transaction_id, previous_transaction_id); + let expected_drop_transaction_id = dataset + .read_transaction() + .await + .unwrap() + .map(|transaction| transaction.uuid); + assert_eq!(drop_transaction_id, expected_drop_transaction_id); + let indices = dataset.load_indices().await.unwrap(); + assert!(!indices.iter().any(|index| index.name == "users_id_idx")); + + let list_response = namespace + .list_table_indices(ListTableIndicesRequest { + id: Some(vec!["users".to_string()]), + ..Default::default() + }) + .await + .unwrap(); + assert!(list_response.indexes.is_empty()); + } + #[tokio::test] async fn test_describe_table() { let (namespace, _temp_dir) = create_test_namespace().await; From e2231fde882b4359d070445fb7866b79eb3dae83 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Wed, 25 Mar 2026 07:08:39 -0700 Subject: [PATCH 076/206] feat: add aimd throttled object store (#6266) This does not hook the throttle up anywhere yet, that will come in a future PR. Closes #6237 Closes #6238 --- rust/lance-core/src/utils.rs | 1 + rust/lance-core/src/utils/aimd.rs | 623 +++++++++++ rust/lance-io/src/object_store.rs | 1 + rust/lance-io/src/object_store/throttle.rs | 1163 ++++++++++++++++++++ 4 files changed, 1788 insertions(+) create mode 100644 rust/lance-core/src/utils/aimd.rs create mode 100644 rust/lance-io/src/object_store/throttle.rs diff --git a/rust/lance-core/src/utils.rs b/rust/lance-core/src/utils.rs index 663454e001b..565036311f9 100644 --- a/rust/lance-core/src/utils.rs +++ b/rust/lance-core/src/utils.rs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors pub mod address; +pub mod aimd; pub mod assume; pub mod backoff; pub mod bit; diff --git a/rust/lance-core/src/utils/aimd.rs b/rust/lance-core/src/utils/aimd.rs new file mode 100644 index 00000000000..0cbae68ca71 --- /dev/null +++ b/rust/lance-core/src/utils/aimd.rs @@ -0,0 +1,623 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! AIMD (Additive Increase / Multiplicative Decrease) rate controller. +//! +//! This module provides a reusable AIMD algorithm for dynamically adjusting +//! request rates. On success windows, the rate increases additively. On +//! windows with throttle signals, the rate decreases multiplicatively. +//! +//! The algorithm operates in discrete time windows. At the end of each window, +//! the throttle ratio (throttled / total) is compared against a threshold: +//! - Above threshold: `rate = max(rate * decrease_factor, min_rate)` +//! - At or below threshold: `rate = min(rate + additive_increment, max_rate)` + +use std::sync::Mutex; +use std::time::Duration; + +use crate::Result; + +/// Configuration for the AIMD rate controller. +/// +/// Use builder methods to customize. Defaults are tuned for cloud object stores +/// and will start at about 40% of the max rate and require 10 seconds to reach +/// the max rate. +/// +/// - initial_rate: 2000 req/s +/// - min_rate: 1 req/s +/// - max_rate: 5000 req/s (0.0 disables ceiling) +/// - decrease_factor: 0.5 (halve on throttle) +/// - additive_increment: 300 req/s per success window +/// - window_duration: 1 second +/// - throttle_threshold: 0.0 (any throttle triggers decrease) +#[derive(Debug, Clone)] +pub struct AimdConfig { + pub initial_rate: f64, + pub min_rate: f64, + pub max_rate: f64, + pub decrease_factor: f64, + pub additive_increment: f64, + pub window_duration: Duration, + pub throttle_threshold: f64, +} + +impl Default for AimdConfig { + fn default() -> Self { + Self { + initial_rate: 2000.0, + min_rate: 1.0, + max_rate: 5000.0, + decrease_factor: 0.5, + additive_increment: 300.0, + window_duration: Duration::from_secs(1), + throttle_threshold: 0.0, + } + } +} + +impl AimdConfig { + pub fn with_initial_rate(self, initial_rate: f64) -> Self { + Self { + initial_rate, + ..self + } + } + + pub fn with_min_rate(self, min_rate: f64) -> Self { + Self { min_rate, ..self } + } + + pub fn with_max_rate(self, max_rate: f64) -> Self { + Self { max_rate, ..self } + } + + pub fn with_decrease_factor(self, decrease_factor: f64) -> Self { + Self { + decrease_factor, + ..self + } + } + + pub fn with_additive_increment(self, additive_increment: f64) -> Self { + Self { + additive_increment, + ..self + } + } + + pub fn with_window_duration(self, window_duration: Duration) -> Self { + Self { + window_duration, + ..self + } + } + + pub fn with_throttle_threshold(self, throttle_threshold: f64) -> Self { + Self { + throttle_threshold, + ..self + } + } + + /// Validate that the configuration values are sensible. + pub fn validate(&self) -> Result<()> { + if self.initial_rate <= 0.0 { + return Err(crate::Error::invalid_input(format!( + "initial_rate must be positive, got {}", + self.initial_rate + ))); + } + if self.min_rate <= 0.0 { + return Err(crate::Error::invalid_input(format!( + "min_rate must be positive, got {}", + self.min_rate + ))); + } + if self.max_rate < 0.0 { + return Err(crate::Error::invalid_input(format!( + "max_rate must be non-negative (0.0 = no ceiling), got {}", + self.max_rate + ))); + } + if self.max_rate > 0.0 && self.min_rate > self.max_rate { + return Err(crate::Error::invalid_input(format!( + "min_rate ({}) must not exceed max_rate ({})", + self.min_rate, self.max_rate + ))); + } + if self.decrease_factor <= 0.0 || self.decrease_factor >= 1.0 { + return Err(crate::Error::invalid_input(format!( + "decrease_factor must be in (0, 1), got {}", + self.decrease_factor + ))); + } + if self.additive_increment <= 0.0 { + return Err(crate::Error::invalid_input(format!( + "additive_increment must be positive, got {}", + self.additive_increment + ))); + } + if self.window_duration.is_zero() { + return Err(crate::Error::invalid_input( + "window_duration must be non-zero", + )); + } + if !(0.0..=1.0).contains(&self.throttle_threshold) { + return Err(crate::Error::invalid_input(format!( + "throttle_threshold must be in [0.0, 1.0], got {}", + self.throttle_threshold + ))); + } + if self.max_rate > 0.0 && self.initial_rate > self.max_rate { + return Err(crate::Error::invalid_input(format!( + "initial_rate ({}) must not exceed max_rate ({})", + self.initial_rate, self.max_rate + ))); + } + if self.initial_rate < self.min_rate { + return Err(crate::Error::invalid_input(format!( + "initial_rate ({}) must not be below min_rate ({})", + self.initial_rate, self.min_rate + ))); + } + Ok(()) + } +} + +/// Outcome of a single request, used to feed the AIMD controller. +/// +/// Non-throttle errors (e.g. 404, network timeout) should be mapped to +/// `Success` since they don't indicate capacity problems. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestOutcome { + Success, + Throttled, +} + +struct AimdState { + rate: f64, + window_start: std::time::Instant, + success_count: u64, + throttle_count: u64, +} + +/// AIMD rate controller. +/// +/// Thread-safe: uses an internal `Mutex` to protect state. The lock is held +/// only briefly during `record_outcome` and `current_rate`. +pub struct AimdController { + config: AimdConfig, + state: Mutex, +} + +impl std::fmt::Debug for AimdController { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AimdController") + .field("config", &self.config) + .field("rate", &self.current_rate()) + .finish() + } +} + +impl AimdController { + /// Create a new AIMD controller with the given configuration. + pub fn new(config: AimdConfig) -> Result { + config.validate()?; + let rate = config.initial_rate; + Ok(Self { + config, + state: Mutex::new(AimdState { + rate, + window_start: std::time::Instant::now(), + success_count: 0, + throttle_count: 0, + }), + }) + } + + /// Record a request outcome and return the current rate. + /// + /// If the current time window has expired, the rate is adjusted before + /// recording the new outcome in a fresh window. + pub fn record_outcome(&self, outcome: RequestOutcome) -> f64 { + let mut state = self.state.lock().unwrap(); + self.record_outcome_inner(&mut state, outcome, std::time::Instant::now()) + } + + fn record_outcome_inner( + &self, + state: &mut AimdState, + outcome: RequestOutcome, + now: std::time::Instant, + ) -> f64 { + // Check if the window has expired + let elapsed = now.duration_since(state.window_start); + if elapsed >= self.config.window_duration { + let total = state.success_count + state.throttle_count; + if total > 0 { + let throttle_ratio = state.throttle_count as f64 / total as f64; + if throttle_ratio > self.config.throttle_threshold { + // Multiplicative decrease + state.rate = + (state.rate * self.config.decrease_factor).max(self.config.min_rate); + } else { + // Additive increase + state.rate += self.config.additive_increment; + if self.config.max_rate > 0.0 { + state.rate = state.rate.min(self.config.max_rate); + } + } + } + // Reset window + state.window_start = now; + state.success_count = 0; + state.throttle_count = 0; + } + + // Record this outcome + match outcome { + RequestOutcome::Success => state.success_count += 1, + RequestOutcome::Throttled => state.throttle_count += 1, + } + + state.rate + } + + /// Get the current rate without recording an outcome. + pub fn current_rate(&self) -> f64 { + self.state.lock().unwrap().rate + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::zero_initial_rate( + AimdConfig::default().with_initial_rate(0.0), + "initial_rate must be positive" + )] + #[case::negative_min_rate( + AimdConfig::default().with_min_rate(-1.0), + "min_rate must be positive" + )] + #[case::negative_max_rate( + AimdConfig::default().with_max_rate(-1.0), + "max_rate must be non-negative" + )] + #[case::min_exceeds_max( + AimdConfig::default().with_min_rate(100.0).with_max_rate(10.0), + "min_rate (100) must not exceed max_rate (10)" + )] + #[case::decrease_factor_zero( + AimdConfig::default().with_decrease_factor(0.0), + "decrease_factor must be in (0, 1)" + )] + #[case::decrease_factor_one( + AimdConfig::default().with_decrease_factor(1.0), + "decrease_factor must be in (0, 1)" + )] + #[case::decrease_factor_over_one( + AimdConfig::default().with_decrease_factor(1.5), + "decrease_factor must be in (0, 1)" + )] + #[case::zero_additive_increment( + AimdConfig::default().with_additive_increment(0.0), + "additive_increment must be positive" + )] + #[case::zero_window_duration( + AimdConfig::default().with_window_duration(Duration::ZERO), + "window_duration must be non-zero" + )] + #[case::threshold_over_one( + AimdConfig::default().with_throttle_threshold(1.1), + "throttle_threshold must be in [0.0, 1.0]" + )] + #[case::threshold_negative( + AimdConfig::default().with_throttle_threshold(-0.1), + "throttle_threshold must be in [0.0, 1.0]" + )] + #[case::initial_exceeds_max( + AimdConfig::default().with_initial_rate(6000.0), + "initial_rate (6000) must not exceed max_rate (5000)" + )] + #[case::initial_below_min( + AimdConfig::default().with_initial_rate(0.5).with_min_rate(1.0), + "initial_rate (0.5) must not be below min_rate (1)" + )] + fn test_config_validation_rejects_invalid( + #[case] config: AimdConfig, + #[case] expected_msg: &str, + ) { + let err = config.validate().unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains(expected_msg), + "Expected error containing '{}', got: {}", + expected_msg, + msg + ); + } + + #[test] + fn test_default_config_is_valid() { + AimdConfig::default().validate().unwrap(); + } + + #[test] + fn test_no_ceiling_config_is_valid() { + AimdConfig::default().with_max_rate(0.0).validate().unwrap(); + } + + #[test] + fn test_additive_increase_on_success_window() { + let config = AimdConfig::default() + .with_initial_rate(100.0) + .with_additive_increment(10.0) + .with_window_duration(Duration::from_millis(100)); + let controller = AimdController::new(config).unwrap(); + + // Record some successes in the first window + let start = std::time::Instant::now(); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, start); + } + + // Advance past the window boundary and record another success + let after_window = start + Duration::from_millis(150); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, after_window); + } + + // Rate should have increased by additive_increment + assert_eq!(controller.current_rate(), 110.0); + } + + #[test] + fn test_multiplicative_decrease_on_throttle_window() { + let config = AimdConfig::default() + .with_initial_rate(100.0) + .with_decrease_factor(0.5) + .with_window_duration(Duration::from_millis(100)); + let controller = AimdController::new(config).unwrap(); + + let start = std::time::Instant::now(); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start); + } + + // Advance past window + let after_window = start + Duration::from_millis(150); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, after_window); + } + + assert_eq!(controller.current_rate(), 50.0); + } + + #[test] + fn test_floor_enforcement() { + let config = AimdConfig::default() + .with_initial_rate(2.0) + .with_min_rate(1.0) + .with_decrease_factor(0.5) + .with_window_duration(Duration::from_millis(100)); + let controller = AimdController::new(config).unwrap(); + + let start = std::time::Instant::now(); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start); + } + + // After decrease: 2.0 * 0.5 = 1.0 (at floor) + let t1 = start + Duration::from_millis(150); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, t1); + } + assert_eq!(controller.current_rate(), 1.0); + + // Another decrease should stay at floor + let t2 = t1 + Duration::from_millis(150); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, t2); + } + assert_eq!(controller.current_rate(), 1.0); + } + + #[test] + fn test_ceiling_enforcement() { + let config = AimdConfig::default() + .with_initial_rate(4990.0) + .with_max_rate(5000.0) + .with_additive_increment(20.0) + .with_window_duration(Duration::from_millis(100)); + let controller = AimdController::new(config).unwrap(); + + let start = std::time::Instant::now(); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, start); + } + + let t1 = start + Duration::from_millis(150); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, t1); + } + // 4990 + 20 = 5010, clamped to 5000 + assert_eq!(controller.current_rate(), 5000.0); + } + + #[test] + fn test_no_ceiling_allows_unbounded_growth() { + let config = AimdConfig::default() + .with_initial_rate(100.0) + .with_max_rate(0.0) + .with_additive_increment(50.0) + .with_window_duration(Duration::from_millis(100)); + let controller = AimdController::new(config).unwrap(); + + let start = std::time::Instant::now(); + let mut t = start; + + for _ in 0..5 { + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, t); + } + t += Duration::from_millis(150); + } + + // Trigger final window evaluation + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, t); + } + + // 100 + 50*5 = 350 + assert_eq!(controller.current_rate(), 350.0); + } + + #[test] + fn test_empty_window_no_adjustment() { + let config = AimdConfig::default() + .with_initial_rate(100.0) + .with_window_duration(Duration::from_millis(100)); + let controller = AimdController::new(config).unwrap(); + + // Don't record anything in the first window, just advance time + let start = std::time::Instant::now(); + let after = start + Duration::from_millis(150); + { + let mut state = controller.state.lock().unwrap(); + // First outcome in a new window after empty window + controller.record_outcome_inner(&mut state, RequestOutcome::Success, after); + } + // No adjustment because the expired window had 0 total + assert_eq!(controller.current_rate(), 100.0); + } + + #[test] + fn test_throttle_threshold_filtering() { + // With threshold 0.5, less than 50% throttles should still increase + let config = AimdConfig::default() + .with_initial_rate(100.0) + .with_throttle_threshold(0.5) + .with_additive_increment(10.0) + .with_window_duration(Duration::from_millis(100)); + let controller = AimdController::new(config).unwrap(); + + let start = std::time::Instant::now(); + { + let mut state = controller.state.lock().unwrap(); + // 1 throttle out of 3 = 33% < 50% threshold + controller.record_outcome_inner(&mut state, RequestOutcome::Success, start); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, start); + controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start); + } + + // Advance past window + let t1 = start + Duration::from_millis(150); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, t1); + } + + // Should have increased because 33% <= 50% + assert_eq!(controller.current_rate(), 110.0); + } + + #[test] + fn test_throttle_threshold_triggers_decrease() { + // With threshold 0.5, >= 50% throttles should decrease + let config = AimdConfig::default() + .with_initial_rate(100.0) + .with_throttle_threshold(0.5) + .with_decrease_factor(0.5) + .with_window_duration(Duration::from_millis(100)); + let controller = AimdController::new(config).unwrap(); + + let start = std::time::Instant::now(); + { + let mut state = controller.state.lock().unwrap(); + // 2 throttle out of 3 = 67% > 50% threshold + controller.record_outcome_inner(&mut state, RequestOutcome::Success, start); + controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start); + controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start); + } + + let t1 = start + Duration::from_millis(150); + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, t1); + } + + assert_eq!(controller.current_rate(), 50.0); + } + + #[test] + fn test_recovery_after_decrease() { + let config = AimdConfig::default() + .with_initial_rate(100.0) + .with_decrease_factor(0.5) + .with_additive_increment(10.0) + .with_window_duration(Duration::from_millis(100)); + let controller = AimdController::new(config).unwrap(); + + let start = std::time::Instant::now(); + + // Window 1: throttle → decrease to 50 + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start); + } + let t1 = start + Duration::from_millis(150); + + // Window 2: success → increase to 60 + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, t1); + } + let t2 = t1 + Duration::from_millis(150); + + // Window 3: success → increase to 70 + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, t2); + } + let t3 = t2 + Duration::from_millis(150); + + // Trigger final evaluation + { + let mut state = controller.state.lock().unwrap(); + controller.record_outcome_inner(&mut state, RequestOutcome::Success, t3); + } + + assert_eq!(controller.current_rate(), 70.0); + } + + #[test] + fn test_within_window_no_adjustment() { + let config = AimdConfig::default() + .with_initial_rate(100.0) + .with_window_duration(Duration::from_secs(10)); + let controller = AimdController::new(config).unwrap(); + + // Record many outcomes but all within the same window + for _ in 0..100 { + controller.record_outcome(RequestOutcome::Throttled); + } + + // Rate should still be initial since window hasn't expired + assert_eq!(controller.current_rate(), 100.0); + } +} diff --git a/rust/lance-io/src/object_store.rs b/rust/lance-io/src/object_store.rs index 02128143705..c0d0acf51a2 100644 --- a/rust/lance-io/src/object_store.rs +++ b/rust/lance-io/src/object_store.rs @@ -35,6 +35,7 @@ use super::local::LocalObjectReader; mod list_retry; pub mod providers; pub mod storage_options; +pub mod throttle; mod tracing; use crate::object_reader::SmallReader; use crate::object_writer::{LocalWriter, WriteResult}; diff --git a/rust/lance-io/src/object_store/throttle.rs b/rust/lance-io/src/object_store/throttle.rs new file mode 100644 index 00000000000..378bcff4c7d --- /dev/null +++ b/rust/lance-io/src/object_store/throttle.rs @@ -0,0 +1,1163 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! AIMD-controlled token bucket rate limiter for ObjectStore operations. +//! +//! Wraps any [`object_store::ObjectStore`] with per-category token buckets +//! whose fill rates are dynamically adjusted by AIMD controllers. When cloud +//! stores return HTTP 429/503, the fill rate decreases multiplicatively. During +//! sustained success windows, it increases additively. +//! +//! Operations are split into four independent categories — **read**, **write**, +//! **delete**, **list** — each with its own AIMD controller and token bucket. +//! This prevents a burst of reads from starving writes, and vice versa. +//! +//! # Example +//! +//! ```ignore +//! use lance_io::object_store::throttle::{AimdThrottleConfig, AimdThrottledStore}; +//! +//! let throttled = AimdThrottledStore::new(target, AimdThrottleConfig::default()).unwrap(); +//! ``` + +use std::fmt::{Debug, Display, Formatter}; +use std::ops::Range; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use futures::StreamExt; +use futures::stream::BoxStream; +use lance_core::utils::aimd::{AimdConfig, AimdController, RequestOutcome}; +use object_store::path::Path; +use object_store::{ + GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, + PutMultipartOptions, PutOptions, PutPayload, PutResult, Result as OSResult, UploadPart, +}; +use tokio::sync::Mutex; +use tracing::debug; + +/// Check whether an `object_store::Error` represents a throttle response +/// (HTTP 429 / 503) from a cloud object store. +/// +/// Regrettably, this information is not fully exposed by the `object_store` crate. +/// There is no generic mechanism for a custom object store to return a throttle error. +/// +/// However, the builtin object stores all use RetryError when retries are configured and +/// throttle errors are returned. Sadly, RetryError is not a public type, so we have to +/// infer it from the error message. This is potentially dangerous because these errors +/// often include the URI itself and that URI could have any characters in it (e.g. if we +/// look for 429 then we might match a 429 in a UUID).These error messages currently look like: +/// +/// ", after ... retries, max_retries: ..., retry_timeout: ..." +/// +/// So, as a crude heuristic, which should work for the builtin object stores, but won't +/// work for custom object stores, we simply look for the string "retries, max_retries" +/// in the error message. +pub fn is_throttle_error(err: &object_store::Error) -> bool { + // Only Generic errors can carry throttle responses + if let object_store::Error::Generic { source, .. } = err { + source.to_string().contains("retries, max_retries") + } else { + false + } +} + +/// Configuration for the AIMD-throttled ObjectStore wrapper. +/// +/// Each operation category (read, write, delete, list) has its own AIMD config. +/// Use [`with_aimd`](AimdThrottleConfig::with_aimd) to set all categories at +/// once, or per-category methods like [`with_read_aimd`](AimdThrottleConfig::with_read_aimd) +/// for fine-grained control. +#[derive(Debug, Clone)] +pub struct AimdThrottleConfig { + /// AIMD configuration for read operations (get, get_opts, get_range, get_ranges, head). + pub read: AimdConfig, + /// AIMD configuration for write operations (put, put_opts, put_multipart, copy, rename, etc.). + pub write: AimdConfig, + /// AIMD configuration for delete operations. + pub delete: AimdConfig, + /// AIMD configuration for list operations (list_with_delimiter). + pub list: AimdConfig, + /// Maximum tokens that can accumulate for bursts (shared across all categories). + pub burst_capacity: u32, +} + +impl Default for AimdThrottleConfig { + fn default() -> Self { + let aimd = AimdConfig::default(); + Self { + read: aimd.clone(), + write: aimd.clone(), + delete: aimd.clone(), + list: aimd, + burst_capacity: 100, + } + } +} + +impl AimdThrottleConfig { + /// Set the AIMD configuration for all four operation categories at once. + pub fn with_aimd(self, aimd: AimdConfig) -> Self { + Self { + read: aimd.clone(), + write: aimd.clone(), + delete: aimd.clone(), + list: aimd, + ..self + } + } + + /// Set the AIMD configuration for read operations. + pub fn with_read_aimd(self, aimd: AimdConfig) -> Self { + Self { read: aimd, ..self } + } + + /// Set the AIMD configuration for write operations. + pub fn with_write_aimd(self, aimd: AimdConfig) -> Self { + Self { + write: aimd, + ..self + } + } + + /// Set the AIMD configuration for delete operations. + pub fn with_delete_aimd(self, aimd: AimdConfig) -> Self { + Self { + delete: aimd, + ..self + } + } + + /// Set the AIMD configuration for list operations. + pub fn with_list_aimd(self, aimd: AimdConfig) -> Self { + Self { list: aimd, ..self } + } + + pub fn with_burst_capacity(self, burst_capacity: u32) -> Self { + Self { + burst_capacity, + ..self + } + } +} + +struct TokenBucketState { + tokens: f64, + last_refill: std::time::Instant, + rate: f64, +} + +/// Per-category throttle state: an AIMD controller paired with a token bucket. +struct OperationThrottle { + controller: AimdController, + bucket: Mutex, + burst_capacity: f64, +} + +impl OperationThrottle { + fn new(aimd_config: AimdConfig, burst_capacity: f64) -> lance_core::Result { + let initial_rate = aimd_config.initial_rate; + let controller = AimdController::new(aimd_config)?; + Ok(Self { + controller, + bucket: Mutex::new(TokenBucketState { + tokens: burst_capacity, + last_refill: std::time::Instant::now(), + rate: initial_rate, + }), + burst_capacity, + }) + } + + /// Acquire a token from the bucket, sleeping if none are available. + /// + /// Each caller reserves a token immediately (allowing `tokens` to go + /// negative) so that concurrent waiters queue behind each other instead + /// of all waking at the same instant (thundering herd). + async fn acquire_token(&self) { + let sleep_duration = { + let mut bucket = self.bucket.lock().await; + let now = std::time::Instant::now(); + let elapsed = now.duration_since(bucket.last_refill).as_secs_f64(); + bucket.tokens = (bucket.tokens + elapsed * bucket.rate).min(self.burst_capacity); + bucket.last_refill = now; + + // Reserve a token (may go negative to queue behind other waiters) + bucket.tokens -= 1.0; + + if bucket.tokens >= 0.0 { + // Had a token available, no need to sleep + return; + } + + // Sleep proportional to our position in the queue + std::time::Duration::from_secs_f64(-bucket.tokens / bucket.rate) + }; + + tokio::time::sleep(sleep_duration).await; + } + + /// Update the bucket's fill rate from the controller. + async fn update_bucket_rate(&self, new_rate: f64) { + let mut bucket = self.bucket.lock().await; + bucket.rate = new_rate; + } + + /// Classify a result and feed it back to the AIMD controller without + /// acquiring a token. Uses `try_lock` for the bucket update so that if the + /// bucket lock is contended the rate update is deferred to the next + /// `throttled()` call. + fn observe_outcome(&self, result: &OSResult) { + let outcome = match result { + Ok(_) => RequestOutcome::Success, + Err(err) if is_throttle_error(err) => { + debug!("Throttle error detected in stream, decreasing rate"); + RequestOutcome::Throttled + } + Err(_) => RequestOutcome::Success, + }; + let new_rate = self.controller.record_outcome(outcome); + if let Ok(mut bucket) = self.bucket.try_lock() { + bucket.rate = new_rate; + } + } + + /// Execute an operation with throttling: acquire token, run, classify result. + async fn throttled(&self, f: F) -> OSResult + where + F: FnOnce() -> Fut, + Fut: std::future::Future>, + { + self.acquire_token().await; + let result = f().await; + let outcome = match &result { + Ok(_) => RequestOutcome::Success, + Err(err) if is_throttle_error(err) => { + debug!("Throttle error detected, decreasing rate"); + RequestOutcome::Throttled + } + Err(_) => RequestOutcome::Success, // Non-throttle errors don't indicate capacity problems + }; + let new_rate = self.controller.record_outcome(outcome); + self.update_bucket_rate(new_rate).await; + result + } +} + +impl Debug for OperationThrottle { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OperationThrottle") + .field("controller", &self.controller) + .field("burst_capacity", &self.burst_capacity) + .finish() + } +} + +/// A [`MultipartUpload`] wrapper that throttles `put_part` and observes +/// outcomes from `put_part` and `complete`, feeding them back to the write +/// AIMD controller. +struct ThrottledMultipartUpload { + target: Box, + write: Arc, +} + +impl Debug for ThrottledMultipartUpload { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ThrottledMultipartUpload").finish() + } +} + +#[async_trait] +impl MultipartUpload for ThrottledMultipartUpload { + fn put_part(&mut self, data: PutPayload) -> UploadPart { + let write = Arc::clone(&self.write); + let fut = self.target.put_part(data); + Box::pin(async move { + write.acquire_token().await; + let result = fut.await; + write.observe_outcome(&result); + result + }) + } + + async fn complete(&mut self) -> OSResult { + let result = self.target.complete().await; + self.write.observe_outcome(&result); + result + } + + async fn abort(&mut self) -> OSResult<()> { + self.target.abort().await + } +} + +/// An ObjectStore wrapper that rate-limits operations using per-category token +/// buckets whose fill rates are controlled by AIMD algorithms. +/// +/// Operations are split into four independent categories: +/// - **read**: `get`, `get_opts`, `get_range`, `get_ranges`, `head` +/// - **write**: `put`, `put_opts`, `put_multipart`, `put_multipart_opts`, `copy`, `copy_if_not_exists`, `rename`, `rename_if_not_exists` +/// - **delete**: `delete` +/// - **list**: `list_with_delimiter` +/// +/// Streaming operations (`list`, `list_with_offset`, `delete_stream`) do not acquire tokens, +/// but observe each yielded item and feed the result back to the AIMD controller so it can +/// adjust the rate for other operations in the same category. +/// +/// This is not perfect but probably as close as we can get without moving the throttle into +/// the object_store crate itself. +pub struct AimdThrottledStore { + target: Arc, + read: Arc, + write: Arc, + delete: Arc, + list: Arc, +} + +impl Debug for AimdThrottledStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AimdThrottledStore") + .field("target", &self.target) + .field("read", &self.read) + .field("write", &self.write) + .field("delete", &self.delete) + .field("list", &self.list) + .finish() + } +} + +impl Display for AimdThrottledStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "AimdThrottledStore({})", self.target) + } +} + +impl AimdThrottledStore { + pub fn new( + target: Arc, + config: AimdThrottleConfig, + ) -> lance_core::Result { + let burst = config.burst_capacity as f64; + Ok(Self { + target, + read: Arc::new(OperationThrottle::new(config.read, burst)?), + write: Arc::new(OperationThrottle::new(config.write, burst)?), + delete: Arc::new(OperationThrottle::new(config.delete, burst)?), + list: Arc::new(OperationThrottle::new(config.list, burst)?), + }) + } +} + +#[async_trait] +#[deny(clippy::missing_trait_methods)] +impl ObjectStore for AimdThrottledStore { + async fn put(&self, location: &Path, bytes: PutPayload) -> OSResult { + self.write + .throttled(|| self.target.put(location, bytes)) + .await + } + + async fn put_opts( + &self, + location: &Path, + bytes: PutPayload, + opts: PutOptions, + ) -> OSResult { + self.write + .throttled(|| self.target.put_opts(location, bytes, opts)) + .await + } + + async fn put_multipart(&self, location: &Path) -> OSResult> { + let target = self + .write + .throttled(|| self.target.put_multipart(location)) + .await?; + Ok(Box::new(ThrottledMultipartUpload { + target, + write: Arc::clone(&self.write), + })) + } + + async fn put_multipart_opts( + &self, + location: &Path, + opts: PutMultipartOptions, + ) -> OSResult> { + let target = self + .write + .throttled(|| self.target.put_multipart_opts(location, opts)) + .await?; + Ok(Box::new(ThrottledMultipartUpload { + target, + write: Arc::clone(&self.write), + })) + } + + async fn get(&self, location: &Path) -> OSResult { + self.read.throttled(|| self.target.get(location)).await + } + + async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult { + self.read + .throttled(|| self.target.get_opts(location, options)) + .await + } + + async fn get_range(&self, location: &Path, range: Range) -> OSResult { + self.read + .throttled(|| self.target.get_range(location, range.clone())) + .await + } + + async fn get_ranges(&self, location: &Path, ranges: &[Range]) -> OSResult> { + self.read + .throttled(|| self.target.get_ranges(location, ranges)) + .await + } + + async fn head(&self, location: &Path) -> OSResult { + self.read.throttled(|| self.target.head(location)).await + } + + async fn delete(&self, location: &Path) -> OSResult<()> { + self.delete.throttled(|| self.target.delete(location)).await + } + + fn delete_stream<'a>( + &'a self, + locations: BoxStream<'a, OSResult>, + ) -> BoxStream<'a, OSResult> { + self.target + .delete_stream(locations) + .map(|item| { + self.delete.observe_outcome(&item); + item + }) + .boxed() + } + + fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult> { + let throttle = Arc::clone(&self.list); + self.target + .list(prefix) + .map(move |item| { + throttle.observe_outcome(&item); + item + }) + .boxed() + } + + fn list_with_offset( + &self, + prefix: Option<&Path>, + offset: &Path, + ) -> BoxStream<'static, OSResult> { + let throttle = Arc::clone(&self.list); + self.target + .list_with_offset(prefix, offset) + .map(move |item| { + throttle.observe_outcome(&item); + item + }) + .boxed() + } + + async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult { + self.list + .throttled(|| self.target.list_with_delimiter(prefix)) + .await + } + + async fn copy(&self, from: &Path, to: &Path) -> OSResult<()> { + self.write.throttled(|| self.target.copy(from, to)).await + } + + async fn rename(&self, from: &Path, to: &Path) -> OSResult<()> { + self.write.throttled(|| self.target.rename(from, to)).await + } + + async fn rename_if_not_exists(&self, from: &Path, to: &Path) -> OSResult<()> { + self.write + .throttled(|| self.target.rename_if_not_exists(from, to)) + .await + } + + async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> OSResult<()> { + self.write + .throttled(|| self.target.copy_if_not_exists(from, to)) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use object_store::memory::InMemory; + use rstest::rstest; + use std::collections::VecDeque; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn make_generic_error(msg: &str) -> object_store::Error { + object_store::Error::Generic { + store: "test", + source: msg.into(), + } + } + + #[rstest] + #[case::retry_error("Error after 10 retries, max_retries: 10, retry_timeout: 180s", true)] + #[case::retries_in_message( + "request failed, after 3 retries, max_retries: 5, retry_timeout: 60s", + true + )] + #[case::not_found("Object not found", false)] + #[case::permission_denied("Access denied", false)] + #[case::timeout("Connection timed out", false)] + #[case::http_429_without_retries("HTTP 429 Too Many Requests", false)] + #[case::slowdown_without_retries("SlowDown: Please reduce your request rate", false)] + fn test_is_throttle_error(#[case] msg: &str, #[case] expected: bool) { + let err = make_generic_error(msg); + assert_eq!( + is_throttle_error(&err), + expected, + "is_throttle_error for '{}' should be {}", + msg, + expected + ); + } + + #[test] + fn test_non_generic_errors_are_not_throttle() { + let err = object_store::Error::NotFound { + path: "test".to_string(), + source: "not found".into(), + }; + assert!(!is_throttle_error(&err)); + } + + #[tokio::test] + async fn test_basic_put_get_through_wrapper() { + let store = Arc::new(InMemory::new()); + let config = AimdThrottleConfig::default(); + let throttled = AimdThrottledStore::new(store, config).unwrap(); + + let path = Path::from("test/file.txt"); + let data = PutPayload::from_static(b"hello world"); + throttled.put(&path, data).await.unwrap(); + + let result = throttled.get(&path).await.unwrap(); + let bytes = result.bytes().await.unwrap(); + assert_eq!(bytes.as_ref(), b"hello world"); + } + + #[tokio::test] + async fn test_rate_decreases_on_throttle() { + let store = Arc::new(InMemory::new()); + let config = AimdThrottleConfig::default().with_aimd( + AimdConfig::default() + .with_initial_rate(100.0) + .with_decrease_factor(0.5) + .with_window_duration(std::time::Duration::from_millis(10)), + ); + let throttled = AimdThrottledStore::new(store, config).unwrap(); + + let initial_rate = throttled.read.controller.current_rate(); + assert_eq!(initial_rate, 100.0); + + // Simulate a throttle outcome directly + throttled + .read + .controller + .record_outcome(RequestOutcome::Throttled); + + // Wait for window to expire and trigger evaluation + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + throttled + .read + .controller + .record_outcome(RequestOutcome::Success); + + let new_rate = throttled.read.controller.current_rate(); + assert!( + new_rate < initial_rate, + "Rate should decrease after throttle: {} < {}", + new_rate, + initial_rate + ); + } + + #[tokio::test] + async fn test_rate_recovers_on_success() { + let store = Arc::new(InMemory::new()); + let config = AimdThrottleConfig::default().with_aimd( + AimdConfig::default() + .with_initial_rate(100.0) + .with_decrease_factor(0.5) + .with_additive_increment(10.0) + .with_window_duration(std::time::Duration::from_millis(10)), + ); + let throttled = AimdThrottledStore::new(store, config).unwrap(); + + // First decrease via throttle + throttled + .read + .controller + .record_outcome(RequestOutcome::Throttled); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + throttled + .read + .controller + .record_outcome(RequestOutcome::Success); + let decreased_rate = throttled.read.controller.current_rate(); + assert_eq!(decreased_rate, 50.0); + + // Now recover via success + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + throttled + .read + .controller + .record_outcome(RequestOutcome::Success); + let recovered_rate = throttled.read.controller.current_rate(); + assert_eq!(recovered_rate, 60.0); + } + + #[tokio::test] + async fn test_as_dyn_object_store() { + let store: Arc = Arc::new(InMemory::new()); + let throttled: Arc = + Arc::new(AimdThrottledStore::new(store, AimdThrottleConfig::default()).unwrap()); + + let path = Path::from("test/data.bin"); + let data = PutPayload::from_static(b"test data"); + throttled.put(&path, data).await.unwrap(); + + let result = throttled.get(&path).await.unwrap(); + let bytes = result.bytes().await.unwrap(); + assert_eq!(bytes.as_ref(), b"test data"); + } + + #[tokio::test] + async fn test_token_bucket_delays_when_exhausted() { + let store = Arc::new(InMemory::new()); + // Very low rate and burst capacity to force waiting + let config = AimdThrottleConfig::default() + .with_burst_capacity(1) + .with_aimd(AimdConfig::default().with_initial_rate(10.0)); + let throttled = Arc::new(AimdThrottledStore::new(store, config).unwrap()); + + let path = Path::from("test/file.txt"); + let data = PutPayload::from_static(b"data"); + throttled.put(&path, data).await.unwrap(); + + // After consuming the burst token, the next request should take ~100ms + // (1 token / 10 tokens-per-sec). We verify it takes at least 50ms. + let start = std::time::Instant::now(); + let data2 = PutPayload::from_static(b"data2"); + throttled.put(&path, data2).await.unwrap(); + let elapsed = start.elapsed(); + + assert!( + elapsed >= std::time::Duration::from_millis(50), + "Expected delay for token refill, but elapsed was {:?}", + elapsed + ); + } + + #[tokio::test] + async fn test_list_observes_outcomes() { + let store = Arc::new(InMemory::new()); + let config = AimdThrottleConfig::default(); + let throttled = AimdThrottledStore::new(store.clone(), config).unwrap(); + + let path = Path::from("prefix/file.txt"); + let data = PutPayload::from_static(b"data"); + store.put(&path, data).await.unwrap(); + + let items: Vec<_> = throttled.list(Some(&Path::from("prefix"))).collect().await; + assert_eq!(items.len(), 1); + assert!(items[0].is_ok()); + } + + /// A mock store whose `list` stream yields a configurable sequence of + /// Ok / throttle-error items. Used to verify that the AIMD wrapper + /// observes errors surfaced inside list streams. + struct ThrottlingListMockStore { + inner: InMemory, + /// Number of throttle errors to inject at the start of each list call. + throttle_count: usize, + } + + impl Display for ThrottlingListMockStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "ThrottlingListMockStore") + } + } + + impl Debug for ThrottlingListMockStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ThrottlingListMockStore").finish() + } + } + + #[async_trait] + impl ObjectStore for ThrottlingListMockStore { + async fn put(&self, location: &Path, bytes: PutPayload) -> OSResult { + self.inner.put(location, bytes).await + } + async fn put_opts( + &self, + location: &Path, + bytes: PutPayload, + opts: PutOptions, + ) -> OSResult { + self.inner.put_opts(location, bytes, opts).await + } + async fn put_multipart(&self, location: &Path) -> OSResult> { + self.inner.put_multipart(location).await + } + async fn put_multipart_opts( + &self, + location: &Path, + opts: PutMultipartOptions, + ) -> OSResult> { + self.inner.put_multipart_opts(location, opts).await + } + async fn get(&self, location: &Path) -> OSResult { + self.inner.get(location).await + } + async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult { + self.inner.get_opts(location, options).await + } + async fn get_range(&self, location: &Path, range: Range) -> OSResult { + self.inner.get_range(location, range).await + } + async fn get_ranges(&self, location: &Path, ranges: &[Range]) -> OSResult> { + self.inner.get_ranges(location, ranges).await + } + async fn head(&self, location: &Path) -> OSResult { + self.inner.head(location).await + } + async fn delete(&self, location: &Path) -> OSResult<()> { + self.inner.delete(location).await + } + fn delete_stream<'a>( + &'a self, + locations: BoxStream<'a, OSResult>, + ) -> BoxStream<'a, OSResult> { + self.inner.delete_stream(locations) + } + fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult> { + let n = self.throttle_count; + let inner_stream = self.inner.list(prefix); + let errors = futures::stream::iter((0..n).map(|_| { + Err(object_store::Error::Generic { + store: "ThrottlingListMock", + source: "request failed, after 3 retries, max_retries: 5, retry_timeout: 60s" + .into(), + }) + })); + errors.chain(inner_stream).boxed() + } + fn list_with_offset( + &self, + prefix: Option<&Path>, + offset: &Path, + ) -> BoxStream<'static, OSResult> { + self.inner.list_with_offset(prefix, offset) + } + async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult { + self.inner.list_with_delimiter(prefix).await + } + async fn copy(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.copy(from, to).await + } + async fn rename(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.rename(from, to).await + } + async fn rename_if_not_exists(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.rename_if_not_exists(from, to).await + } + async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.copy_if_not_exists(from, to).await + } + } + + #[tokio::test] + async fn test_list_stream_throttle_errors_decrease_rate() { + let mock = Arc::new(ThrottlingListMockStore { + inner: InMemory::new(), + throttle_count: 5, + }); + + // Seed a file so the real items come through after the errors. + mock.put( + &Path::from("prefix/file.txt"), + PutPayload::from_static(b"data"), + ) + .await + .unwrap(); + + let config = AimdThrottleConfig::default().with_list_aimd( + AimdConfig::default() + .with_initial_rate(100.0) + .with_decrease_factor(0.5) + .with_window_duration(std::time::Duration::from_millis(10)), + ); + let throttled = AimdThrottledStore::new(mock as Arc, config).unwrap(); + + let initial_rate = throttled.list.controller.current_rate(); + assert_eq!(initial_rate, 100.0); + + let items: Vec<_> = throttled.list(Some(&Path::from("prefix"))).collect().await; + + // 5 errors + 1 real item + assert_eq!(items.len(), 6); + assert!(items[0].is_err()); + assert!(items[5].is_ok()); + + // Wait for the AIMD window to expire and trigger evaluation. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + throttled + .list + .controller + .record_outcome(RequestOutcome::Success); + + let new_rate = throttled.list.controller.current_rate(); + assert!( + new_rate < initial_rate, + "List rate should decrease after stream throttle errors: {} < {}", + new_rate, + initial_rate + ); + } + + #[tokio::test] + async fn test_per_category_independence() { + let store = Arc::new(InMemory::new()); + let config = AimdThrottleConfig::default().with_aimd( + AimdConfig::default() + .with_initial_rate(100.0) + .with_decrease_factor(0.5) + .with_window_duration(std::time::Duration::from_millis(10)), + ); + let throttled = AimdThrottledStore::new(store, config).unwrap(); + + // Push the read controller into a throttled state + throttled + .read + .controller + .record_outcome(RequestOutcome::Throttled); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + throttled + .read + .controller + .record_outcome(RequestOutcome::Success); + + let read_rate = throttled.read.controller.current_rate(); + let write_rate = throttled.write.controller.current_rate(); + let delete_rate = throttled.delete.controller.current_rate(); + let list_rate = throttled.list.controller.current_rate(); + + assert_eq!(read_rate, 50.0, "Read rate should have decreased"); + assert_eq!(write_rate, 100.0, "Write rate should be unaffected"); + assert_eq!(delete_rate, 100.0, "Delete rate should be unaffected"); + assert_eq!(list_rate, 100.0, "List rate should be unaffected"); + } + + #[tokio::test] + async fn test_per_category_config() { + let store = Arc::new(InMemory::new()); + let config = AimdThrottleConfig::default() + .with_read_aimd(AimdConfig::default().with_initial_rate(200.0)) + .with_write_aimd(AimdConfig::default().with_initial_rate(100.0)) + .with_delete_aimd(AimdConfig::default().with_initial_rate(50.0)) + .with_list_aimd(AimdConfig::default().with_initial_rate(25.0)); + let throttled = AimdThrottledStore::new(store, config).unwrap(); + + assert_eq!(throttled.read.controller.current_rate(), 200.0); + assert_eq!(throttled.write.controller.current_rate(), 100.0); + assert_eq!(throttled.delete.controller.current_rate(), 50.0); + assert_eq!(throttled.list.controller.current_rate(), 25.0); + } + + /// A mock [`ObjectStore`] that measures request rate over a sliding window + /// and returns 503 errors when the rate exceeds a configurable threshold. + /// Write and metadata-only operations are not rate-limited. + struct RateLimitingMockStore { + inner: InMemory, + /// Timestamps of recent successful (admitted) requests. + timestamps: std::sync::Mutex>, + /// Maximum requests allowed within `window`. + max_per_window: usize, + /// Sliding window duration. + window: std::time::Duration, + success_count: AtomicU64, + throttle_count: AtomicU64, + } + + impl RateLimitingMockStore { + fn new(max_per_window: usize, window: std::time::Duration) -> Self { + Self { + inner: InMemory::new(), + timestamps: std::sync::Mutex::new(VecDeque::new()), + max_per_window, + window, + success_count: AtomicU64::new(0), + throttle_count: AtomicU64::new(0), + } + } + + /// Returns `true` if the request is admitted, `false` if throttled. + fn check_rate(&self) -> bool { + let mut ts = self.timestamps.lock().unwrap(); + let now = std::time::Instant::now(); + while let Some(&front) = ts.front() { + if now.duration_since(front) > self.window { + ts.pop_front(); + } else { + break; + } + } + if ts.len() >= self.max_per_window { + self.throttle_count.fetch_add(1, Ordering::Relaxed); + false + } else { + ts.push_back(now); + self.success_count.fetch_add(1, Ordering::Relaxed); + true + } + } + + fn throttle_error() -> object_store::Error { + object_store::Error::Generic { + store: "RateLimitingMock", + source: "request failed, after 10 retries, max_retries: 10, retry_timeout: 180s" + .into(), + } + } + } + + impl Display for RateLimitingMockStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "RateLimitingMockStore") + } + } + + impl Debug for RateLimitingMockStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RateLimitingMockStore").finish() + } + } + + #[async_trait] + impl ObjectStore for RateLimitingMockStore { + async fn put(&self, location: &Path, bytes: PutPayload) -> OSResult { + self.inner.put(location, bytes).await + } + + async fn put_opts( + &self, + location: &Path, + bytes: PutPayload, + opts: PutOptions, + ) -> OSResult { + self.inner.put_opts(location, bytes, opts).await + } + + async fn put_multipart(&self, location: &Path) -> OSResult> { + self.inner.put_multipart(location).await + } + + async fn put_multipart_opts( + &self, + location: &Path, + opts: PutMultipartOptions, + ) -> OSResult> { + self.inner.put_multipart_opts(location, opts).await + } + + async fn get(&self, location: &Path) -> OSResult { + if self.check_rate() { + self.inner.get(location).await + } else { + Err(Self::throttle_error()) + } + } + + async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult { + if self.check_rate() { + self.inner.get_opts(location, options).await + } else { + Err(Self::throttle_error()) + } + } + + async fn get_range(&self, location: &Path, range: Range) -> OSResult { + if self.check_rate() { + self.inner.get_range(location, range).await + } else { + Err(Self::throttle_error()) + } + } + + async fn get_ranges(&self, location: &Path, ranges: &[Range]) -> OSResult> { + if self.check_rate() { + self.inner.get_ranges(location, ranges).await + } else { + Err(Self::throttle_error()) + } + } + + async fn head(&self, location: &Path) -> OSResult { + if self.check_rate() { + self.inner.head(location).await + } else { + Err(Self::throttle_error()) + } + } + + async fn delete(&self, location: &Path) -> OSResult<()> { + self.inner.delete(location).await + } + + fn delete_stream<'a>( + &'a self, + locations: BoxStream<'a, OSResult>, + ) -> BoxStream<'a, OSResult> { + self.inner.delete_stream(locations) + } + + fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult> { + self.inner.list(prefix) + } + + fn list_with_offset( + &self, + prefix: Option<&Path>, + offset: &Path, + ) -> BoxStream<'static, OSResult> { + self.inner.list_with_offset(prefix, offset) + } + + async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult { + self.inner.list_with_delimiter(prefix).await + } + + async fn copy(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.copy(from, to).await + } + + async fn rename(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.rename(from, to).await + } + + async fn rename_if_not_exists(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.rename_if_not_exists(from, to).await + } + + async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.copy_if_not_exists(from, to).await + } + } + + /// Verify that multiple concurrent readers sharing an AIMD-throttled store + /// converge to the backend's actual capacity. + /// + /// Setup: + /// - Mock backend allows 30 requests per 100ms (= 300 req/s). + /// - 5 reader tasks, each with their own [`AimdThrottledStore`] wrapping + /// the shared mock. + /// - AIMD: 100ms window, initial rate 100 req/s, decrease 0.5, increase 2. + /// - Readers issue `head()` requests as fast as the throttle allows for 2s. + /// + /// Expected behaviour: + /// - Initial burst (100 burst tokens × 5 readers) overshoots the mock + /// capacity, causing many 503s. Each reader's AIMD halves its rate. + /// - After the transient, each reader converges to ~60 req/s (300/5). + /// - Over 2 seconds, total successful requests should be in the range + /// [300, 900] (theoretical max ≈ 600). + #[tokio::test(flavor = "multi_thread", worker_threads = 8)] + async fn test_aimd_throttle_under_concurrent_load() { + let mock = Arc::new(RateLimitingMockStore::new( + 30, + std::time::Duration::from_millis(100), + )); + + // Seed a test file so head() succeeds when admitted. + let path = Path::from("test/data.bin"); + mock.put(&path, PutPayload::from_static(b"test data")) + .await + .unwrap(); + + let aimd = AimdConfig::default() + .with_initial_rate(100.0) + .with_decrease_factor(0.5) + .with_additive_increment(2.0) + .with_window_duration(std::time::Duration::from_millis(100)); + let throttle_config = AimdThrottleConfig::default() + .with_aimd(aimd) + .with_burst_capacity(100); + + let num_readers = 5; + let test_duration = std::time::Duration::from_secs(2); + let mut handles = Vec::new(); + + for _ in 0..num_readers { + let store = Arc::new( + AimdThrottledStore::new( + mock.clone() as Arc, + throttle_config.clone(), + ) + .unwrap(), + ); + let p = path.clone(); + handles.push(tokio::spawn(async move { + let deadline = std::time::Instant::now() + test_duration; + let mut count = 0u64; + while std::time::Instant::now() < deadline { + let _ = store.head(&p).await; + count += 1; + } + count + })); + } + + let mut total_reader_requests = 0u64; + for handle in handles { + total_reader_requests += handle.await.unwrap(); + } + + let successes = mock.success_count.load(Ordering::Relaxed); + let throttled = mock.throttle_count.load(Ordering::Relaxed); + let total_mock = successes + throttled; + + // Reader-side count must match mock-side count. + assert_eq!( + total_reader_requests, total_mock, + "Reader-side count ({total_reader_requests}) != mock-side count ({total_mock})" + ); + + // Mock capacity is 30/100ms = 300 req/s. Over 2s the theoretical max is + // ~600 successful requests. With AIMD ramp-up, expect somewhat fewer. + assert!( + successes >= 300, + "Expected >= 300 successful requests over 2s, got {successes}" + ); + assert!( + successes <= 900, + "Expected <= 900 successful requests, got {successes}" + ); + + // The initial burst exceeds mock capacity, so throttling must occur. + assert!(throttled > 0, "Expected some throttled requests but got 0"); + + // Without AIMD, raw tokio tasks against InMemory would fire 100k+ req/s. + // AIMD should keep the total well under 5000 over 2s. + assert!( + total_mock <= 5000, + "AIMD should limit total requests, got {total_mock}" + ); + } +} From d016a82f75d578cbd53e079aeca449f733ef153a Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Wed, 25 Mar 2026 23:14:33 +0800 Subject: [PATCH 077/206] feat: support stop-word gaps in phrase queries (#6277) This change enables phrase queries to match across stop-word gaps. Example: For `doc="love the format"` indexed with `remove_stop_words=True`, the index does not store the stop word the. With this change, users can still match the document with the phrase query `q="love the format"`. In this mode, all stop words are treated as equivalent placeholders for phrase matching, so `q="love a format"` will also match the same document. This makes queries that containing stop words 3x~10x faster in the cost of a lit bit accuracy --------- Signed-off-by: BubbleCal --- rust/lance-index/src/scalar/inverted/index.rs | 23 ++-- rust/lance-index/src/scalar/inverted/query.rs | 16 ++- rust/lance-index/src/scalar/inverted/wand.rs | 38 ++++++ rust/lance/src/dataset/tests/dataset_index.rs | 119 ++++++++++++++++++ rust/lance/src/io/exec/fts.rs | 13 +- 5 files changed, 195 insertions(+), 14 deletions(-) diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index 59d258975aa..c07d26c74c5 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -492,7 +492,12 @@ impl InvertedIndex { if postings.is_empty() { return Result::Ok(PartitionCandidates::empty()); } - let mut tokens_by_position = vec![String::new(); postings.len()]; + let max_position = postings + .iter() + .map(|posting| posting.term_index() as usize) + .max() + .unwrap_or_default(); + let mut tokens_by_position = vec![String::new(); max_position + 1]; for posting in &postings { let idx = posting.term_index() as usize; tokens_by_position[idx] = posting.token().to_owned(); @@ -1011,11 +1016,14 @@ impl InvertedPartition { true => self.expand_fuzzy(tokens, params)?, false => tokens.clone(), }; + let token_positions = (0..tokens.len()) + .map(|index| tokens.position(index)) + .collect::>(); let mut token_ids = Vec::with_capacity(tokens.len()); - for token in tokens { + for (index, token) in tokens.into_iter().enumerate() { let token_id = self.map(&token); if let Some(token_id) = token_id { - token_ids.push((token_id, token)); + token_ids.push((token_id, token, token_positions[index])); } else if is_phrase_query { // if the token is not found, we can't do phrase query return Ok(Vec::new()); @@ -1025,14 +1033,13 @@ impl InvertedPartition { return Ok(Vec::new()); } if !is_phrase_query { - token_ids.sort_unstable_by_key(|(token_id, _)| *token_id); - token_ids.dedup_by_key(|(token_id, _)| *token_id); + token_ids.sort_unstable_by_key(|(token_id, _, _)| *token_id); + token_ids.dedup_by_key(|(token_id, _, _)| *token_id); } let num_docs = self.docs.len(); stream::iter(token_ids) - .enumerate() - .map(|(position, (token_id, token))| async move { + .map(|(token_id, token, position)| async move { let posting = self .inverted_list .posting_list(token_id, is_phrase_query, metrics) @@ -1043,7 +1050,7 @@ impl InvertedPartition { Result::Ok(PostingIterator::with_query_weight( token, token_id, - position as u32, + position, query_weight, posting, num_docs, diff --git a/rust/lance-index/src/scalar/inverted/query.rs b/rust/lance-index/src/scalar/inverted/query.rs index 4c207d83134..6a8ebb07840 100644 --- a/rust/lance-index/src/scalar/inverted/query.rs +++ b/rust/lance-index/src/scalar/inverted/query.rs @@ -719,12 +719,19 @@ impl FtsQueryNode for BooleanQuery { #[derive(Clone)] pub struct Tokens { tokens: Vec, + positions: Vec, tokens_map: HashMap, token_type: DocType, } impl Tokens { pub fn new(tokens: Vec, token_type: DocType) -> Self { + let positions = (0..tokens.len() as u32).collect(); + Self::with_positions(tokens, positions, token_type) + } + + pub fn with_positions(tokens: Vec, positions: Vec, token_type: DocType) -> Self { + debug_assert_eq!(tokens.len(), positions.len()); let mut tokens_vec = vec![]; let mut tokens_map = HashMap::new(); for (idx, token) in tokens.into_iter().enumerate() { @@ -734,6 +741,7 @@ impl Tokens { Self { tokens: tokens_vec, + positions, tokens_map, token_type, } @@ -762,6 +770,10 @@ impl Tokens { pub fn get_token(&self, index: usize) -> &str { &self.tokens[index] } + + pub fn position(&self, index: usize) -> u32 { + self.positions[index] + } } impl IntoIterator for Tokens { @@ -786,10 +798,12 @@ pub fn collect_query_tokens(text: &str, tokenizer: &mut Box) let token_type = tokenizer.doc_type(); let mut stream = tokenizer.token_stream_for_search(text); let mut tokens = Vec::new(); + let mut positions = Vec::new(); while let Some(token) = stream.next() { tokens.push(token.text.clone()); + positions.push(token.position as u32); } - Tokens::new(tokens, token_type) + Tokens::with_positions(tokens, positions, token_type) } pub fn has_query_token( diff --git a/rust/lance-index/src/scalar/inverted/wand.rs b/rust/lance-index/src/scalar/inverted/wand.rs index 7da5ddfb0fb..b06c75c0021 100644 --- a/rust/lance-index/src/scalar/inverted/wand.rs +++ b/rust/lance-index/src/scalar/inverted/wand.rs @@ -1993,6 +1993,44 @@ mod tests { assert!(wand.check_positions(0)); } + #[rstest] + fn test_exact_phrase_respects_query_position_gaps(#[values(false, true)] is_compressed: bool) { + let mut docs = DocSet::default(); + docs.append(0, 16); + + let postings = vec![ + PostingIterator::new( + String::from("want"), + 0, + 0, + generate_posting_list_with_positions( + vec![0], + vec![vec![0_u32]], + 1.0, + is_compressed, + ), + docs.len(), + ), + PostingIterator::new( + String::from("apple"), + 1, + 2, + generate_posting_list_with_positions( + vec![0], + vec![vec![2_u32]], + 1.0, + is_compressed, + ), + docs.len(), + ), + ]; + + let bm25 = IndexBM25Scorer::new(std::iter::empty()); + let wand = Wand::new(Operator::And, postings.into_iter(), &docs, bm25); + assert!(wand.check_exact_positions()); + assert!(wand.check_positions(0)); + } + #[rstest] fn test_and_phrase_miss_advances_to_next_candidate(#[values(false, true)] is_compressed: bool) { let mut docs = DocSet::default(); diff --git a/rust/lance/src/dataset/tests/dataset_index.rs b/rust/lance/src/dataset/tests/dataset_index.rs index c3aac4493d4..87c17cdba32 100644 --- a/rust/lance/src/dataset/tests/dataset_index.rs +++ b/rust/lance/src/dataset/tests/dataset_index.rs @@ -1776,6 +1776,125 @@ async fn test_fts_phrase_query() { assert_eq!(result.num_rows(), 0); } +#[tokio::test] +async fn test_fts_phrase_query_with_removed_stop_words() { + let tmpdir = TempStrDir::default(); + let uri = tmpdir.to_owned(); + drop(tmpdir); + + let doc_col: Arc = Arc::new(GenericStringArray::::from(vec![ + "want the apple", + "want an apple", + "want green apple", + "apple want the", + ])); + let ids = UInt64Array::from_iter_values(0..doc_col.len() as u64); + let batch = RecordBatch::try_new( + arrow_schema::Schema::new(vec![ + arrow_schema::Field::new("doc", doc_col.data_type().to_owned(), true), + arrow_schema::Field::new("id", DataType::UInt64, false), + ]) + .into(), + vec![Arc::new(doc_col) as ArrayRef, Arc::new(ids) as ArrayRef], + ) + .unwrap(); + let schema = batch.schema(); + let batches = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema); + let mut dataset = Dataset::write(batches, &uri, None).await.unwrap(); + + dataset + .create_index( + &["doc"], + IndexType::Inverted, + None, + &InvertedIndexParams::default() + .with_position(true) + .remove_stop_words(true), + true, + ) + .await + .unwrap(); + + for query in ["want the apple", "want an apple"] { + let result = dataset + .scan() + .project(&["id"]) + .unwrap() + .full_text_search(FullTextSearchQuery::new_query( + PhraseQuery::new(query.to_owned()).into(), + )) + .unwrap() + .try_into_batch() + .await + .unwrap(); + + let ids = result["id"].as_primitive::().values(); + assert_eq!(result.num_rows(), 3, "query={query}, ids={ids:?}"); + assert!(ids.contains(&0), "query={query}, ids={ids:?}"); + assert!(ids.contains(&1), "query={query}, ids={ids:?}"); + assert!(ids.contains(&2), "query={query}, ids={ids:?}"); + } +} + +#[tokio::test] +async fn test_fts_phrase_query_preserves_stop_word_gaps() { + let tmpdir = TempStrDir::default(); + let uri = tmpdir.to_owned(); + drop(tmpdir); + + let doc_col: Arc = Arc::new(GenericStringArray::::from(vec![ + "the united states of america", + "the united states and america", + "united states america", + "the united states of north america", + ])); + let ids = UInt64Array::from_iter_values(0..doc_col.len() as u64); + let batch = RecordBatch::try_new( + arrow_schema::Schema::new(vec![ + arrow_schema::Field::new("doc", doc_col.data_type().to_owned(), true), + arrow_schema::Field::new("id", DataType::UInt64, false), + ]) + .into(), + vec![Arc::new(doc_col) as ArrayRef, Arc::new(ids) as ArrayRef], + ) + .unwrap(); + let schema = batch.schema(); + let batches = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema); + let mut dataset = Dataset::write(batches, &uri, None).await.unwrap(); + + dataset + .create_index( + &["doc"], + IndexType::Inverted, + None, + &InvertedIndexParams::default() + .with_position(true) + .remove_stop_words(true), + true, + ) + .await + .unwrap(); + + let result = dataset + .scan() + .project(&["id"]) + .unwrap() + .full_text_search(FullTextSearchQuery::new_query( + PhraseQuery::new("the united states of america".to_owned()).into(), + )) + .unwrap() + .try_into_batch() + .await + .unwrap(); + + let ids = result["id"].as_primitive::().values(); + assert_eq!(result.num_rows(), 2, "ids={ids:?}"); + assert!(ids.contains(&0), "ids={ids:?}"); + assert!(ids.contains(&1), "ids={ids:?}"); + assert!(!ids.contains(&2), "ids={ids:?}"); + assert!(!ids.contains(&3), "ids={ids:?}"); +} + async fn prepare_json_dataset() -> (Dataset, String) { let text_col = Arc::new(StringArray::from(vec![ r#"{ diff --git a/rust/lance/src/io/exec/fts.rs b/rust/lance/src/io/exec/fts.rs index 9de276c64ef..6b2293d3b13 100644 --- a/rust/lance/src/io/exec/fts.rs +++ b/rust/lance/src/io/exec/fts.rs @@ -877,10 +877,13 @@ impl ExecutionPlan for PhraseQueryExec { let metrics = Arc::new(FtsIndexMetrics::new(&self.metrics, partition)); let stream = stream::once(async move { let _timer = metrics.baseline_metrics.elapsed_compute().timer(); - let column = query.column.ok_or(DataFusionError::Execution(format!( - "column not set for PhraseQuery {}", - query.terms - )))?; + let column = query + .column + .clone() + .ok_or(DataFusionError::Execution(format!( + "column not set for PhraseQuery {}", + query.terms + )))?; let index_meta = ds .load_scalar_index(IndexCriteria::default().for_column(&column).supports_fts()) .await? @@ -897,7 +900,7 @@ impl ExecutionPlan for PhraseQueryExec { context.clone(), partition, &prefilter_source, - ds, + ds.clone(), &[index_meta], )?; From a6b033ccd1af99993120f661fe5c383a8851c6dd Mon Sep 17 00:00:00 2001 From: Jonathan Hsieh Date: Wed, 25 Mar 2026 11:28:28 -0700 Subject: [PATCH 078/206] fix: support hamming distance in IndicesBuilder (#6295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `IndicesBuilder` rejected uint8 vector columns and didn't include "hamming" in its allowed distance types, even though the underlying Rust `train_ivf_model` supports hamming via k-modes - Relaxes `_normalize_column` to accept unsigned integer value types alongside floats - Adds "hamming" to `_normalize_distance_type`'s allowed list - Adds `test_ivf_centroids_hamming` test with uint8 vectors ## Test plan - [ ] `test_ivf_centroids_hamming` — end-to-end IVF training with uint8 vectors and hamming distance - [ ] Existing `test_ivf_centroids` tests still pass (float path unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- python/python/lance/indices/builder.py | 15 ++++++++++----- python/python/tests/test_indices.py | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/python/python/lance/indices/builder.py b/python/python/lance/indices/builder.py index ca033780a0e..7105c9234bb 100644 --- a/python/python/lance/indices/builder.py +++ b/python/python/lance/indices/builder.py @@ -51,7 +51,8 @@ def __init__(self, dataset, column: str): the dataset containing the data column: str The vector column to index, must be a fixed size list of floats - or 1-dimensional fixed-shape tensor column. + (or unsigned integers for hamming distance) or 1-dimensional + fixed-shape tensor column. """ self.dataset = dataset self.column = self._normalize_column(column) @@ -89,7 +90,7 @@ def train_ivf( overtraining, reduced recall, and require large nprobes values. If not specified the default will be the integer nearest the square root of the number of rows. - distance_type: "l2" | "dot" | "cosine" + distance_type: "l2" | "dot" | "cosine" | "hamming" The distance type to used. This is defined in more detail in the LanceDB documentation on creating indices. accelerator: str | torch.Device @@ -529,6 +530,7 @@ def _normalize_distance_type(self, distance_type): "cosine", "euclidean", "dot", + "hamming", ]: raise ValueError(f"Distance type {distance_type} not supported.") return distance_type.lower() @@ -555,10 +557,13 @@ def _normalize_column(self, column): f"Vector column {c} must be FixedSizeListArray " f"1-dimensional FixedShapeTensorArray, got {field.type}" ) - if not pa.types.is_floating(field.type.value_type): + if not ( + pa.types.is_floating(field.type.value_type) + or pa.types.is_unsigned_integer(field.type.value_type) + ): raise TypeError( - f"Vector column {c} must have floating value type, " - f"got {field.type.value_type}" + f"Vector column {c} must have floating or unsigned integer " + f"value type, got {field.type.value_type}" ) return column diff --git a/python/python/tests/test_indices.py b/python/python/tests/test_indices.py index e29f02705e2..f0444e4abcb 100644 --- a/python/python/tests/test_indices.py +++ b/python/python/tests/test_indices.py @@ -77,6 +77,31 @@ def test_ivf_centroids(tmpdir, rand_dataset): assert ivf.centroids == reloaded.centroids +def test_ivf_centroids_hamming(tmpdir): + num_rows = NUM_ROWS + vectors = np.random.randint(0, 256, size=(num_rows, DIMENSION), dtype=np.uint8) + vectors_flat = vectors.reshape(-1) + vectors_arr = pa.FixedSizeListArray.from_arrays( + pa.array(vectors_flat, type=pa.uint8()), DIMENSION + ) + table = pa.Table.from_arrays([vectors_arr], names=["vectors"]) + uri = str(tmpdir / "hamming_dataset") + ds = lance.write_dataset(table, uri, max_rows_per_file=NUM_ROWS_PER_FRAGMENT) + + ivf = IndicesBuilder(ds, "vectors").train_ivf( + sample_rate=16, distance_type="hamming" + ) + + assert ivf.distance_type == "hamming" + expected_partitions = round(math.sqrt(num_rows)) + assert len(ivf.centroids) == expected_partitions + + ivf.save(str(tmpdir / "ivf_hamming")) + reloaded = IvfModel.load(str(tmpdir / "ivf_hamming")) + assert reloaded.distance_type == "hamming" + assert ivf.centroids == reloaded.centroids + + @pytest.mark.parametrize("distance_type", ["l2", "cosine", "dot"]) def test_ivf_centroids_mostly_null(mostly_null_dataset, distance_type): ivf = IndicesBuilder(mostly_null_dataset, "vectors").train_ivf( From 305b7294a21a33f75d812ae4405b6b294875a44a Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 26 Mar 2026 17:55:14 +0800 Subject: [PATCH 079/206] perf: add benchmark for distributed vector merge finalization (#6176) This adds a dedicated benchmark for distributed vector index finalization and a small query-side metric to count `find_partitions` calls. Together they give us a baseline for analyzing the current single-node merge bottleneck and for evaluating future segmented-index work. As context, the new `distributed_merge_only_ivf_pq` benchmark already shows that finalize cost grows much faster than input bytes as shard count and partition count increase. In the local filesystem benchmark, the mean finalize time grows from about `64 ms` at `8 shards / 256 partitions` to about `2.87 s` at `128 shards / 1024 partitions`. --- Based on this benchmark, I noticed that our current logic performs poorly as the number of shards increases. 2026-03-12-distributed-merge-trend-matplotlib --- rust/lance-datafusion/src/exec.rs | 10 + rust/lance-datafusion/src/utils.rs | 9 + rust/lance/Cargo.toml | 4 + .../lance/benches/distributed_vector_build.rs | 449 ++++++++++++++++++ rust/lance/src/io/exec/knn.rs | 34 +- 5 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 rust/lance/benches/distributed_vector_build.rs diff --git a/rust/lance-datafusion/src/exec.rs b/rust/lance-datafusion/src/exec.rs index 5eb83b8c19e..b3a98275853 100644 --- a/rust/lance-datafusion/src/exec.rs +++ b/rust/lance-datafusion/src/exec.rs @@ -489,6 +489,9 @@ pub struct ExecutionSummaryCounts { /// Additional metrics for more detailed statistics. These are subject to change in the future /// and should only be used for debugging purposes. pub all_counts: HashMap, + /// Additional time metrics for more detailed statistics, stored in nanoseconds. + /// These are subject to change in the future and should only be used for debugging purposes. + pub all_times: HashMap, } pub fn collect_execution_metrics(node: &dyn ExecutionPlan, counts: &mut ExecutionSummaryCounts) { @@ -510,6 +513,13 @@ pub fn collect_execution_metrics(node: &dyn ExecutionPlan, counts: &mut Executio } } } + for (metric_name, time) in metrics.iter_times() { + let existing = counts + .all_times + .entry(metric_name.as_ref().to_string()) + .or_insert(0); + *existing += time.value(); + } // Include gauge-based I/O metrics (some nodes record I/O as gauges) for (metric_name, gauge) in metrics.iter_gauges() { match metric_name.as_ref() { diff --git a/rust/lance-datafusion/src/utils.rs b/rust/lance-datafusion/src/utils.rs index f6f28f766f0..3e1d2db2d79 100644 --- a/rust/lance-datafusion/src/utils.rs +++ b/rust/lance-datafusion/src/utils.rs @@ -155,6 +155,7 @@ pub fn reader_to_stream(batches: Box) -> SendableR pub trait MetricsExt { fn find_count(&self, name: &str) -> Option; fn iter_counts(&self) -> impl Iterator, &Count)>; + fn iter_times(&self) -> impl Iterator, &Time)>; fn iter_gauges(&self) -> impl Iterator, &Gauge)>; } @@ -179,6 +180,13 @@ impl MetricsExt for MetricsSet { }) } + fn iter_times(&self) -> impl Iterator, &Time)> { + self.iter().filter_map(|m| match m.value() { + MetricValue::Time { name, time } => Some((name, time)), + _ => None, + }) + } + fn iter_gauges(&self) -> impl Iterator, &Gauge)> { self.iter().filter_map(|m| match m.value() { MetricValue::Gauge { name, gauge } => Some((name, gauge)), @@ -242,5 +250,6 @@ pub const ROWS_SCANNED_METRIC: &str = "rows_scanned"; pub const TASK_WAIT_TIME_METRIC: &str = "task_wait_time"; pub const DELTAS_SEARCHED_METRIC: &str = "deltas_searched"; pub const PARTITIONS_SEARCHED_METRIC: &str = "partitions_searched"; +pub const FIND_PARTITIONS_ELAPSED_METRIC: &str = "find_partitions_elapsed"; pub const SCALAR_INDEX_SEARCH_TIME_METRIC: &str = "search_time"; pub const SCALAR_INDEX_SER_TIME_METRIC: &str = "ser_time"; diff --git a/rust/lance/Cargo.toml b/rust/lance/Cargo.toml index 8b9e88d12c7..e2755822b88 100644 --- a/rust/lance/Cargo.toml +++ b/rust/lance/Cargo.toml @@ -183,6 +183,10 @@ harness = false name = "vector_throughput" harness = false +[[bench]] +name = "distributed_vector_build" +harness = false + [[bench]] name = "mem_wal_write" harness = false diff --git a/rust/lance/benches/distributed_vector_build.rs b/rust/lance/benches/distributed_vector_build.rs new file mode 100644 index 00000000000..831ee8c8299 --- /dev/null +++ b/rust/lance/benches/distributed_vector_build.rs @@ -0,0 +1,449 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use arrow_array::{ArrayRef, FixedSizeListArray, RecordBatch, RecordBatchIterator}; +use arrow_array::{cast::AsArray, types::Float32Type}; +use arrow_schema::{DataType, Field, FieldRef, Schema as ArrowSchema}; +use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +#[cfg(target_os = "linux")] +use pprof::criterion::{Output, PProfProfiler}; +use serde::Serialize; +use uuid::Uuid; + +use lance::dataset::{Dataset, WriteMode, WriteParams}; +use lance::index::vector::VectorIndexParams; +use lance_arrow::FixedSizeListArrayExt; +use lance_index::vector::kmeans::{KMeansParams, train_kmeans}; +use lance_index::{ + DatasetIndexExt, IndexType, + vector::{ivf::IvfBuildParams, pq::PQBuildParams}, +}; +use lance_linalg::distance::DistanceType; +use lance_testing::datagen::generate_random_array; +use tokio::runtime::Runtime; + +const NUM_FRAGMENTS: usize = 128; +const ROWS_PER_FRAGMENT: usize = 1024; +const DIM: i32 = 128; +const NUM_SUB_VECTORS: usize = 16; +const NUM_BITS: usize = 8; +const MAX_ITERS: usize = 20; +const SAMPLE_RATE: usize = 8; + +#[derive(Clone, Copy, Debug)] +struct BenchCase { + num_shards: usize, + num_partitions: usize, +} + +impl BenchCase { + fn label(&self) -> String { + format!( + "pq_shards_{}_partitions_{}", + self.num_shards, self.num_partitions + ) + } +} + +#[derive(Clone, Debug)] +struct MergeFixture { + index_dir: PathBuf, + partial_aux_bytes: u64, + partial_dir_count: usize, +} + +#[derive(Debug, Serialize)] +struct CaseMetadata { + label: String, + num_shards: usize, + num_partitions: usize, + partial_dir_count: usize, + partial_aux_bytes: u64, + partial_aux_bytes_per_shard: u64, + total_rows: usize, + rows_per_shard: usize, +} + +fn dataset_root() -> PathBuf { + std::env::temp_dir().join(format!( + "lance_bench_distributed_build_{}_{}_{}", + NUM_FRAGMENTS, ROWS_PER_FRAGMENT, DIM + )) +} + +fn dataset_uri() -> String { + format!("file://{}", dataset_root().display()) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap() + .to_path_buf() +} + +fn criterion_group_root() -> PathBuf { + workspace_root() + .join("target") + .join("criterion") + .join("distributed_merge_only_ivf_pq") +} + +fn bench_cases() -> [BenchCase; 6] { + [ + BenchCase { + num_shards: 8, + num_partitions: 256, + }, + BenchCase { + num_shards: 32, + num_partitions: 256, + }, + BenchCase { + num_shards: 128, + num_partitions: 256, + }, + BenchCase { + num_shards: 8, + num_partitions: 1024, + }, + BenchCase { + num_shards: 32, + num_partitions: 1024, + }, + BenchCase { + num_shards: 128, + num_partitions: 1024, + }, + ] +} + +fn fixture_uuid(bench_case: BenchCase) -> Uuid { + Uuid::from_u128( + 0x733a_0000_0000_0000_0000_0000_0000_0000 + | ((bench_case.num_shards as u128) << 64) + | bench_case.num_partitions as u128, + ) +} + +fn working_uuid(bench_case: BenchCase) -> Uuid { + Uuid::from_u128( + 0x733b_0000_0000_0000_0000_0000_0000_0000 + | ((bench_case.num_shards as u128) << 64) + | bench_case.num_partitions as u128, + ) +} + +fn create_batches() -> (Arc, Vec) { + let schema = Arc::new(ArrowSchema::new(vec![Field::new( + "vector", + DataType::FixedSizeList( + FieldRef::new(Field::new("item", DataType::Float32, true)), + DIM, + ), + false, + )])); + + let batches = (0..NUM_FRAGMENTS) + .map(|_| { + RecordBatch::try_new( + schema.clone(), + vec![Arc::new( + FixedSizeListArray::try_new_from_values( + generate_random_array(ROWS_PER_FRAGMENT * DIM as usize), + DIM, + ) + .unwrap(), + )], + ) + .unwrap() + }) + .collect::>(); + + (schema, batches) +} + +async fn create_or_open_dataset() -> Dataset { + let uri = dataset_uri(); + if let Ok(dataset) = Dataset::open(&uri).await + && dataset.get_fragments().len() == NUM_FRAGMENTS + { + return dataset; + } + + let dataset_path = dataset_root(); + if dataset_path.exists() { + fs::remove_dir_all(&dataset_path).unwrap(); + } + + let (schema, batches) = create_batches(); + let reader = RecordBatchIterator::new(batches.into_iter().map(Ok), schema); + let write_params = WriteParams { + max_rows_per_file: ROWS_PER_FRAGMENT, + max_rows_per_group: ROWS_PER_FRAGMENT, + mode: WriteMode::Overwrite, + ..Default::default() + }; + + let dataset = Dataset::write(reader, &uri, Some(write_params)) + .await + .unwrap(); + assert_eq!(dataset.get_fragments().len(), NUM_FRAGMENTS); + dataset +} + +async fn train_shared_ivf_pq( + dataset: &Dataset, + num_partitions: usize, +) -> (IvfBuildParams, PQBuildParams) { + let batch = dataset + .scan() + .project(&["vector".to_string()]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let vectors = batch.column_by_name("vector").unwrap().as_fixed_size_list(); + let dim = vectors.value_length() as usize; + let values = vectors.values().as_primitive::(); + + let kmeans = train_kmeans::( + values, + KMeansParams::new(None, MAX_ITERS as u32, 1, DistanceType::L2), + dim, + num_partitions, + SAMPLE_RATE, + ) + .unwrap(); + + let centroids = Arc::new( + FixedSizeListArray::try_new_from_values( + kmeans.centroids.as_primitive::().clone(), + dim as i32, + ) + .unwrap(), + ); + let mut ivf_params = IvfBuildParams::try_with_centroids(num_partitions, centroids).unwrap(); + ivf_params.max_iters = MAX_ITERS; + ivf_params.sample_rate = SAMPLE_RATE; + + let mut pq_train_params = PQBuildParams::new(NUM_SUB_VECTORS, NUM_BITS); + pq_train_params.max_iters = MAX_ITERS; + pq_train_params.sample_rate = SAMPLE_RATE; + + let pq = pq_train_params.build(vectors, DistanceType::L2).unwrap(); + let codebook: ArrayRef = Arc::new(pq.codebook.values().as_primitive::().clone()); + + let mut pq_params = PQBuildParams::with_codebook(NUM_SUB_VECTORS, NUM_BITS, codebook); + pq_params.max_iters = MAX_ITERS; + pq_params.sample_rate = SAMPLE_RATE; + + (ivf_params, pq_params) +} + +fn contiguous_fragment_groups(dataset: &Dataset, num_shards: usize) -> Vec> { + assert_eq!(NUM_FRAGMENTS % num_shards, 0); + let fragments = dataset.get_fragments(); + let group_size = fragments.len() / num_shards; + fragments + .chunks(group_size) + .map(|group| { + group + .iter() + .map(|frag| frag.id() as u32) + .collect::>() + }) + .collect() +} + +async fn build_partial_fixture(dataset: &mut Dataset, bench_case: BenchCase) -> MergeFixture { + let fixture_uuid = fixture_uuid(bench_case); + let index_dir = dataset_root() + .join("_indices") + .join(fixture_uuid.to_string()); + + if has_partial_dirs(&index_dir) { + return MergeFixture { + partial_aux_bytes: sum_partial_auxiliary_bytes(&index_dir), + partial_dir_count: count_partial_dirs(&index_dir), + index_dir, + }; + } + + if index_dir.exists() { + fs::remove_dir_all(&index_dir).unwrap(); + } + + let fragment_groups = contiguous_fragment_groups(dataset, bench_case.num_shards); + let (ivf_params, pq_params) = train_shared_ivf_pq(dataset, bench_case.num_partitions).await; + let params = VectorIndexParams::with_ivf_pq_params(DistanceType::L2, ivf_params, pq_params); + + for fragments in fragment_groups { + let mut builder = dataset.create_index_builder(&["vector"], IndexType::Vector, ¶ms); + builder = builder + .name("distributed_merge_only".to_string()) + .fragments(fragments) + .index_uuid(fixture_uuid.to_string()); + builder.execute_uncommitted().await.unwrap(); + } + + MergeFixture { + partial_aux_bytes: sum_partial_auxiliary_bytes(&index_dir), + partial_dir_count: count_partial_dirs(&index_dir), + index_dir, + } +} + +fn has_partial_dirs(index_dir: &Path) -> bool { + fs::read_dir(index_dir) + .ok() + .into_iter() + .flatten() + .flatten() + .any(|entry| { + entry.file_type().map(|t| t.is_dir()).unwrap_or(false) + && entry.file_name().to_string_lossy().starts_with("partial_") + }) +} + +fn count_partial_dirs(index_dir: &Path) -> usize { + fs::read_dir(index_dir) + .unwrap() + .flatten() + .filter(|entry| { + entry.file_type().map(|t| t.is_dir()).unwrap_or(false) + && entry.file_name().to_string_lossy().starts_with("partial_") + }) + .count() +} + +fn sum_partial_auxiliary_bytes(index_dir: &Path) -> u64 { + fs::read_dir(index_dir) + .unwrap() + .flatten() + .filter(|entry| { + entry.file_type().map(|t| t.is_dir()).unwrap_or(false) + && entry.file_name().to_string_lossy().starts_with("partial_") + }) + .map(|entry| entry.path().join("auxiliary.idx")) + .filter_map(|path| fs::metadata(path).ok()) + .map(|metadata| metadata.len()) + .sum() +} + +fn copy_dir_recursive(source: &Path, target: &Path) { + fs::create_dir_all(target).unwrap(); + for entry in fs::read_dir(source).unwrap().flatten() { + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + let file_type = entry.file_type().unwrap(); + if file_type.is_dir() { + copy_dir_recursive(&source_path, &target_path); + } else { + fs::copy(&source_path, &target_path).unwrap(); + } + } +} + +fn prepare_iteration_target(source: &Path, target: &Path) { + if target.exists() { + fs::remove_dir_all(target).unwrap(); + } + copy_dir_recursive(source, target); +} + +fn write_case_metadata(fixtures: &[(BenchCase, MergeFixture)]) { + let output_dir = criterion_group_root(); + fs::create_dir_all(&output_dir).unwrap(); + let metadata = fixtures + .iter() + .map(|(bench_case, fixture)| CaseMetadata { + label: bench_case.label(), + num_shards: bench_case.num_shards, + num_partitions: bench_case.num_partitions, + partial_dir_count: fixture.partial_dir_count, + partial_aux_bytes: fixture.partial_aux_bytes, + partial_aux_bytes_per_shard: fixture.partial_aux_bytes + / fixture.partial_dir_count as u64, + total_rows: NUM_FRAGMENTS * ROWS_PER_FRAGMENT, + rows_per_shard: (NUM_FRAGMENTS * ROWS_PER_FRAGMENT) / bench_case.num_shards, + }) + .collect::>(); + let payload = serde_json::to_vec_pretty(&metadata).unwrap(); + fs::write(output_dir.join("case_metadata.json"), payload).unwrap(); +} + +fn bench_distributed_merge_only(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut dataset = rt.block_on(create_or_open_dataset()); + let mut fixtures = Vec::new(); + + for bench_case in bench_cases() { + fixtures.push(( + bench_case, + rt.block_on(build_partial_fixture(&mut dataset, bench_case)), + )); + } + write_case_metadata(&fixtures); + + let dataset = Arc::new(dataset); + let mut group = c.benchmark_group("distributed_merge_only_ivf_pq"); + group.sample_size(10); + + for (bench_case, fixture) in fixtures { + let target_uuid = working_uuid(bench_case); + let target_index_dir_fs = dataset_root() + .join("_indices") + .join(target_uuid.to_string()); + let source_index_dir_fs = fixture.index_dir.clone(); + + group.throughput(Throughput::Bytes(fixture.partial_aux_bytes)); + group.bench_with_input( + BenchmarkId::new("finalize_only", bench_case.label()), + &bench_case, + |b, _| { + let dataset = dataset.clone(); + let target_index_dir_fs = target_index_dir_fs.clone(); + let source_index_dir_fs = source_index_dir_fs.clone(); + b.iter_batched( + || prepare_iteration_target(&source_index_dir_fs, &target_index_dir_fs), + |_| { + rt.block_on(dataset.merge_index_metadata( + &target_uuid.to_string(), + IndexType::IvfPq, + None, + )) + .unwrap(); + }, + BatchSize::PerIteration, + ); + }, + ); + } + + group.finish(); +} + +#[cfg(target_os = "linux")] +criterion_group!( + name = benches; + config = Criterion::default() + .significance_level(0.1) + .sample_size(10) + .with_profiler(PProfProfiler::new(100, Output::Flamegraph(None))); + targets = bench_distributed_merge_only +); + +#[cfg(not(target_os = "linux"))] +criterion_group!( + name = benches; + config = Criterion::default().significance_level(0.1).sample_size(10); + targets = bench_distributed_merge_only +); + +criterion_main!(benches); diff --git a/rust/lance/src/io/exec/knn.rs b/rust/lance/src/io/exec/knn.rs index ab7589ce1a8..ac512aeba67 100644 --- a/rust/lance/src/io/exec/knn.rs +++ b/rust/lance/src/io/exec/knn.rs @@ -32,15 +32,15 @@ use datafusion::{ physical_plan::metrics::MetricsSet, }; use datafusion_physical_expr::{Distribution, EquivalenceProperties}; -use datafusion_physical_plan::metrics::{BaselineMetrics, Count}; +use datafusion_physical_plan::metrics::{BaselineMetrics, Count, Time}; use futures::{Stream, StreamExt, TryFutureExt, TryStreamExt, future, stream}; use itertools::Itertools; use lance_core::ROW_ID; use lance_core::utils::futures::FinallyStreamExt; use lance_core::{ROW_ID_FIELD, utils::tokio::get_num_compute_intensive_cpus}; use lance_datafusion::utils::{ - DELTAS_SEARCHED_METRIC, ExecutionPlanMetricsSetExt, PARTITIONS_RANKED_METRIC, - PARTITIONS_SEARCHED_METRIC, + DELTAS_SEARCHED_METRIC, ExecutionPlanMetricsSetExt, FIND_PARTITIONS_ELAPSED_METRIC, + PARTITIONS_RANKED_METRIC, PARTITIONS_SEARCHED_METRIC, }; use lance_index::prefilter::PreFilter; use lance_index::vector::{ @@ -68,6 +68,7 @@ pub struct AnnPartitionMetrics { index_metrics: IndexMetrics, partitions_ranked: Count, deltas_searched: Count, + find_partitions_elapsed: Time, baseline_metrics: BaselineMetrics, } @@ -77,6 +78,7 @@ impl AnnPartitionMetrics { index_metrics: IndexMetrics::new(metrics, partition), partitions_ranked: metrics.new_count(PARTITIONS_RANKED_METRIC, partition), deltas_searched: metrics.new_count(DELTAS_SEARCHED_METRIC, partition), + find_partitions_elapsed: metrics.new_time(FIND_PARTITIONS_ELAPSED_METRIC, partition), baseline_metrics: BaselineMetrics::new(metrics, partition), } } @@ -509,9 +511,12 @@ impl ExecutionPlan for ANNIvfPartitionExec { metrics.partitions_ranked.add(index.total_partitions()); - let (partitions, dist_q_c) = index.find_partitions(&query).map_err(|e| { - DataFusionError::Execution(format!("Failed to find partitions: {}", e)) - })?; + let (partitions, dist_q_c) = { + let _timer = metrics.find_partitions_elapsed.timer(); + index.find_partitions(&query).map_err(|e| { + DataFusionError::Execution(format!("Failed to find partitions: {}", e)) + })? + }; let mut part_list_builder = ListBuilder::new(UInt32Builder::new()) .with_field(Field::new("item", DataType::UInt32, false)); @@ -1352,6 +1357,7 @@ mod tests { use arrow_schema::{Field as ArrowField, Schema as ArrowSchema}; use lance_core::utils::tempfile::TempStrDir; use lance_datafusion::exec::{ExecutionStatsCallback, ExecutionSummaryCounts}; + use lance_datafusion::utils::FIND_PARTITIONS_ELAPSED_METRIC; use lance_datagen::{BatchCount, RowCount, array}; use lance_index::optimize::OptimizeOptions; use lance_index::vector::ivf::IvfBuildParams; @@ -1731,6 +1737,17 @@ mod tests { } } + fn assert_find_partitions_elapsed_recorded(stats: &ExecutionSummaryCounts) { + assert!( + stats + .all_times + .get(FIND_PARTITIONS_ELAPSED_METRIC) + .copied() + .unwrap_or_default() + > 0 + ); + } + #[rstest] #[tokio::test] async fn test_no_max_nprobes(#[values(1, 20)] num_deltas: usize) { @@ -1766,6 +1783,7 @@ mod tests { if get_num_compute_intensive_cpus() <= 32 { assert!(*stats.all_counts.get(PARTITIONS_SEARCHED_METRIC).unwrap() < 100 * num_deltas); } + assert_find_partitions_elapsed_recorded(&stats); } #[rstest] @@ -1802,6 +1820,7 @@ mod tests { stats.all_counts.get(PARTITIONS_SEARCHED_METRIC).unwrap(), &(10 * num_deltas) ); + assert_find_partitions_elapsed_recorded(&stats); } #[rstest] @@ -1841,6 +1860,7 @@ mod tests { stats.all_counts.get(PARTITIONS_RANKED_METRIC).unwrap(), &(100 * num_deltas) ); + assert_find_partitions_elapsed_recorded(&stats); } } @@ -1876,6 +1896,7 @@ mod tests { stats.all_counts.get(PARTITIONS_SEARCHED_METRIC).unwrap(), &(10 * num_deltas) ); + assert_find_partitions_elapsed_recorded(&stats); assert_eq!(results.num_rows(), 20); // 15 of the results come from beyond the closest 10 partitions and these will have infinite @@ -1947,6 +1968,7 @@ mod tests { stats.all_counts.get(PARTITIONS_SEARCHED_METRIC).unwrap(), &(100 * num_deltas) ); + assert_find_partitions_elapsed_recorded(&stats); assert_eq!(results.num_rows(), 10000); } } From ee324f4b3312df4cd9ecd969c334b22ceac24def Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 26 Mar 2026 18:35:13 +0800 Subject: [PATCH 080/206] refactor: move DatasetIndexExt out of lance-index (#6280) This moves `DatasetIndexExt` and the dataset-facing index segment types out of `lance-index` and into `lance`, so the public dataset index management API lives in the dataset layer instead of the lower-level index implementation crate. Close #6221 --- java/lance-jni/src/blocking_dataset.rs | 4 +- python/src/dataset.rs | 4 +- python/src/indices.rs | 3 +- python/src/lib.rs | 2 +- rust/examples/src/full_text_search.rs | 2 +- rust/examples/src/ivf_hnsw.rs | 3 +- rust/lance-index/src/lib.rs | 2 - rust/lance-index/src/traits.rs | 195 +----------- rust/lance-index/src/types.rs | 124 -------- .../lance-namespace-impls/src/dir/manifest.rs | 2 +- rust/lance/benches/ivf_pq.rs | 3 +- rust/lance/benches/mem_wal_write.rs | 3 +- rust/lance/benches/memtable_read.rs | 3 +- rust/lance/benches/vector_index.rs | 3 +- rust/lance/benches/vector_throughput.rs | 3 +- rust/lance/src/bin/lq.rs | 2 +- rust/lance/src/dataset.rs | 3 +- rust/lance/src/dataset/cleanup.rs | 6 +- rust/lance/src/dataset/index.rs | 2 +- rust/lance/src/dataset/index/frag_reuse.rs | 3 +- rust/lance/src/dataset/mem_wal/api.rs | 2 +- .../src/dataset/mem_wal/memtable/flush.rs | 6 +- rust/lance/src/dataset/mem_wal/write.rs | 3 +- rust/lance/src/dataset/optimize.rs | 2 +- rust/lance/src/dataset/optimize/remapping.rs | 2 +- rust/lance/src/dataset/rowids.rs | 3 +- rust/lance/src/dataset/scanner.rs | 3 +- rust/lance/src/dataset/schema_evolution.rs | 3 +- .../src/dataset/tests/dataset_aggregate.rs | 3 +- .../tests/dataset_concurrency_store.rs | 2 +- rust/lance/src/dataset/tests/dataset_geo.rs | 3 +- rust/lance/src/dataset/tests/dataset_index.rs | 2 +- rust/lance/src/dataset/tests/dataset_io.rs | 3 +- .../src/dataset/tests/dataset_merge_update.rs | 3 +- .../src/dataset/tests/dataset_migrations.rs | 2 +- .../src/dataset/tests/dataset_scanner.rs | 3 +- .../src/dataset/tests/dataset_transactions.rs | 2 +- rust/lance/src/dataset/udtf.rs | 3 +- rust/lance/src/dataset/write/delete.rs | 3 +- rust/lance/src/dataset/write/merge_insert.rs | 3 +- rust/lance/src/dataset/write/update.rs | 2 +- rust/lance/src/index.rs | 11 +- rust/lance/src/index/api.rs | 294 ++++++++++++++++++ rust/lance/src/index/append.rs | 3 +- rust/lance/src/index/create.rs | 4 +- rust/lance/src/index/frag_reuse.rs | 2 +- rust/lance/src/index/mem_wal.rs | 2 +- rust/lance/src/index/scalar.rs | 13 +- rust/lance/src/index/vector.rs | 8 +- rust/lance/src/index/vector/ivf.rs | 5 +- rust/lance/src/index/vector/ivf/v2.rs | 4 +- rust/lance/src/io/commit.rs | 3 +- rust/lance/src/io/commit/conflict_resolver.rs | 2 +- rust/lance/src/io/exec/filtered_read.rs | 3 +- rust/lance/src/io/exec/fts.rs | 6 +- rust/lance/src/io/exec/knn.rs | 3 +- rust/lance/src/io/exec/scalar_index.rs | 7 +- rust/lance/src/session/index_extension.rs | 4 +- rust/lance/tests/query/inverted.rs | 3 +- rust/lance/tests/query/primitives.rs | 3 +- rust/lance/tests/utils/mod.rs | 3 +- 61 files changed, 415 insertions(+), 398 deletions(-) delete mode 100644 rust/lance-index/src/types.rs create mode 100644 rust/lance/src/index/api.rs diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index 203ffcdffa8..e705c0eda1d 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -38,6 +38,7 @@ use lance::dataset::{ ColumnAlteration, CommitBuilder, Dataset, NewColumnTransform, ProjectionRequest, ReadParams, Version, WriteParams, }; +use lance::index::{DatasetIndexExt, IndexSegment}; use lance::io::commit::namespace_manifest::LanceNamespaceExternalManifestStore; use lance::io::{ObjectStore, ObjectStoreParams}; use lance::session::Session as LanceSession; @@ -45,11 +46,10 @@ use lance::table::format::IndexMetadata; use lance::table::format::{BasePath, Fragment}; use lance_core::datatypes::Schema as LanceSchema; use lance_file::version::LanceFileVersion; -use lance_index::DatasetIndexExt; use lance_index::IndexCriteria as RustIndexCriteria; use lance_index::optimize::OptimizeOptions; use lance_index::scalar::btree::BTreeParameters; -use lance_index::{IndexParams, IndexSegment, IndexType}; +use lance_index::{IndexParams, IndexType}; use lance_io::object_store::ObjectStoreRegistry; use lance_io::object_store::StorageOptionsProvider; use lance_namespace::LanceNamespace; diff --git a/python/src/dataset.rs b/python/src/dataset.rs index 695d8b317c5..402bc010268 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -55,7 +55,7 @@ use lance::dataset::{ transaction::{Operation, Transaction}, }; use lance::index::vector::utils::get_vector_type; -use lance::index::{DatasetIndexInternalExt, vector::VectorIndexParams}; +use lance::index::{DatasetIndexExt, DatasetIndexInternalExt, vector::VectorIndexParams}; use lance::{dataset::builder::DatasetBuilder, index::vector::IndexFileVersion}; use lance_arrow::as_fixed_size_list_array; use lance_core::Error; @@ -67,7 +67,7 @@ use lance_index::scalar::inverted::query::{ BooleanQuery, BoostQuery, FtsQuery, MatchQuery, MultiMatchQuery, Operator, PhraseQuery, }; use lance_index::{ - DatasetIndexExt, IndexParams, IndexType, + IndexParams, IndexType, optimize::OptimizeOptions, scalar::{FullTextSearchQuery, InvertedIndexParams, ScalarIndexParams}, vector::{ diff --git a/python/src/indices.rs b/python/src/indices.rs index f1d42918962..9651c6cc00e 100644 --- a/python/src/indices.rs +++ b/python/src/indices.rs @@ -11,6 +11,7 @@ use arrow_data::ArrayData; use chrono::{DateTime, Utc}; use lance::dataset::Dataset as LanceDataset; use lance::index::vector::ivf::builder::write_vector_storage; +use lance::index::{DatasetIndexExt, IndexSegment, IndexSegmentPlan}; use lance::io::ObjectStore; use lance_index::progress::NoopIndexBuildProgress; use lance_index::vector::ivf::shuffler::{IvfShuffler, shuffle_vectors}; @@ -37,7 +38,7 @@ use crate::{ dataset::Dataset, error::PythonErrorExt, file::object_store_from_uri_or_path_no_options, rt, }; use lance::index::vector::ivf::write_ivf_pq_file_from_existing_index; -use lance_index::{DatasetIndexExt, IndexDescription, IndexSegment, IndexSegmentPlan, IndexType}; +use lance_index::{IndexDescription, IndexType}; use uuid::Uuid; #[pyclass(name = "IndexConfig", module = "lance.indices", get_all)] diff --git a/python/src/lib.rs b/python/src/lib.rs index f7d233cf117..9730f2ba1c5 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -34,6 +34,7 @@ use ::arrow::pyarrow::PyArrowType; use ::arrow_schema::Schema as ArrowSchema; use ::lance::arrow::json::ArrowJsonExt; use ::lance::datafusion::LanceTableProvider; +use ::lance::index::DatasetIndexExt; use datafusion_ffi::proto::logical_extension_codec::FFI_LogicalExtensionCodec; use datafusion_ffi::table_provider::FFI_TableProvider; #[cfg(feature = "datagen")] @@ -52,7 +53,6 @@ use file::{ LanceBufferDescriptor, LanceColumnMetadata, LanceFileMetadata, LanceFileReader, LanceFileStatistics, LanceFileWriter, LancePageMetadata, stable_version, }; -use lance_index::DatasetIndexExt; use log::Level; use pyo3::exceptions::PyIOError; use pyo3::prelude::*; diff --git a/rust/examples/src/full_text_search.rs b/rust/examples/src/full_text_search.rs index 22e701c5863..8269f590ee8 100644 --- a/rust/examples/src/full_text_search.rs +++ b/rust/examples/src/full_text_search.rs @@ -15,8 +15,8 @@ use arrow::datatypes::UInt64Type; use arrow_schema::{DataType, Field, Schema}; use itertools::Itertools; use lance::Dataset; +use lance::index::DatasetIndexExt; use lance_datagen::{RowCount, array}; -use lance_index::DatasetIndexExt; use lance_index::scalar::inverted::flat_full_text_search; use lance_index::scalar::{FullTextSearchQuery, InvertedIndexParams}; use object_store::path::Path; diff --git a/rust/examples/src/ivf_hnsw.rs b/rust/examples/src/ivf_hnsw.rs index 296c03b19d6..c1898e10682 100644 --- a/rust/examples/src/ivf_hnsw.rs +++ b/rust/examples/src/ivf_hnsw.rs @@ -11,11 +11,12 @@ use clap::Parser; use futures::TryStreamExt; use lance::Dataset; use lance::dataset::ProjectionRequest; +use lance::index::DatasetIndexExt; use lance::index::vector::VectorIndexParams; +use lance_index::IndexType; use lance_index::vector::hnsw::builder::HnswBuildParams; use lance_index::vector::ivf::IvfBuildParams; use lance_index::vector::sq::builder::SQBuildParams; -use lance_index::{DatasetIndexExt, IndexType}; use lance_linalg::distance::MetricType; #[derive(Parser, Debug)] diff --git a/rust/lance-index/src/lib.rs b/rust/lance-index/src/lib.rs index bb00a716173..62ae68414a6 100644 --- a/rust/lance-index/src/lib.rs +++ b/rust/lance-index/src/lib.rs @@ -29,11 +29,9 @@ pub mod progress; pub mod registry; pub mod scalar; pub mod traits; -pub mod types; pub mod vector; pub use crate::traits::*; -pub use crate::types::{IndexSegment, IndexSegmentPlan}; pub const INDEX_FILE_NAME: &str = "index.idx"; /// The name of the auxiliary index file. diff --git a/rust/lance-index/src/traits.rs b/rust/lance-index/src/traits.rs index a883c3ce494..130e59cad81 100644 --- a/rust/lance-index/src/traits.rs +++ b/rust/lance-index/src/traits.rs @@ -1,13 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::sync::Arc; +use lance_core::Result; -use async_trait::async_trait; -use datafusion::execution::SendableRecordBatchStream; -use lance_core::{Error, Result}; - -use crate::{IndexParams, IndexType, optimize::OptimizeOptions, types::IndexSegment}; use lance_table::format::IndexMetadata; /// A set of criteria used to filter potential indices to use for a query @@ -121,191 +116,3 @@ pub trait IndexDescription: Send + Sync { /// (for backward compatibility with indices created before file tracking was added). fn total_size_bytes(&self) -> Option; } - -// Extends Lance Dataset with secondary index. -#[async_trait] -pub trait DatasetIndexExt { - type IndexBuilder<'a> - where - Self: 'a; - type IndexSegmentBuilder<'a> - where - Self: 'a; - - /// Create a builder for creating an index on columns. - /// - /// This returns a builder that can be configured with additional options - /// like `name()`, `replace()`, and `train()` before awaiting to execute. - /// - /// # Parameters - /// - `columns`: the columns to build the indices on. - /// - `index_type`: specify [`IndexType`]. - /// - `params`: index parameters. - fn create_index_builder<'a>( - &'a mut self, - columns: &'a [&'a str], - index_type: IndexType, - params: &'a dyn IndexParams, - ) -> Self::IndexBuilder<'a>; - - /// Create a builder for building physical index segments from uncommitted - /// vector index outputs. - /// - /// The caller supplies the uncommitted index metadata returned by - /// `execute_uncommitted()` so the builder can plan segment grouping without - /// rediscovering fragment coverage. - /// - /// This is the canonical entry point for distributed vector segment build. - /// After building the physical segments, publish them as a - /// logical index with [`Self::commit_existing_index_segments`]. - fn create_index_segment_builder<'a>(&'a self) -> Self::IndexSegmentBuilder<'a>; - - /// Create indices on columns. - /// - /// Upon finish, a new dataset version is generated. - /// - /// Parameters: - /// - /// - `columns`: the columns to build the indices on. - /// - `index_type`: specify [`IndexType`]. - /// - `name`: optional index name. Must be unique in the dataset. - /// if not provided, it will auto-generate one. - /// - `params`: index parameters. - /// - `replace`: replace the existing index if it exists. - /// - /// Returns the metadata of the created index. - async fn create_index( - &mut self, - columns: &[&str], - index_type: IndexType, - name: Option, - params: &dyn IndexParams, - replace: bool, - ) -> Result; - - /// Drop indices by name. - /// - /// Upon finish, a new dataset version is generated. - /// - /// Parameters: - /// - /// - `name`: the name of the index to drop. - async fn drop_index(&mut self, name: &str) -> Result<()>; - - /// Prewarm an index by name. - /// - /// This will load the index into memory and cache it. - /// - /// Generally, this should only be called when it is known the entire index will - /// fit into the index cache. - /// - /// This is a hint that is not enforced by all indices today. Some indices may choose - /// to ignore this hint. - async fn prewarm_index(&self, name: &str) -> Result<()>; - - /// Read all indices of this Dataset version. - /// - /// The indices are lazy loaded and cached in memory within the `Dataset` instance. - /// The cache is invalidated when the dataset version (Manifest) is changed. - async fn load_indices(&self) -> Result>>; - - /// Loads all the indies of a given UUID. - /// - /// Note that it is possible to have multiple indices with the same UUID, - /// as they are the deltas of the same index. - async fn load_index(&self, uuid: &str) -> Result> { - self.load_indices().await.map(|indices| { - indices - .iter() - .find(|idx| idx.uuid.to_string() == uuid) - .cloned() - }) - } - - /// Loads a specific index with the given index name - /// - /// Returns - /// ------- - /// - `Ok(indices)`: if the index exists, returns the index. - /// - `Ok(vec![])`: if the index does not exist. - /// - `Err(e)`: if there is an error loading indices. - /// - async fn load_indices_by_name(&self, name: &str) -> Result> { - self.load_indices().await.map(|indices| { - indices - .iter() - .filter(|idx| idx.name == name) - .cloned() - .collect() - }) - } - - /// Loads a specific index with the given index name. - /// This function only works for indices that are unique. - /// If there are multiple indices sharing the same name, please use [`Self::load_indices_by_name`] - /// - /// Returns - /// ------- - /// - `Ok(Some(index))`: if the index exists, returns the index. - /// - `Ok(None)`: if the index does not exist. - /// - `Err(e)`: Index error if there are multiple indexes sharing the same name. - /// - async fn load_index_by_name(&self, name: &str) -> Result> { - let indices = self.load_indices_by_name(name).await?; - if indices.is_empty() { - Ok(None) - } else if indices.len() == 1 { - Ok(Some(indices[0].clone())) - } else { - Err(Error::index(format!( - "Found multiple indices of the same name: {:?}, please use load_indices_by_name", - indices.iter().map(|idx| &idx.name).collect::>() - ))) - } - } - - /// Describes indexes in a dataset - /// - /// This method should only access the index metadata and should not load the index into memory. - /// - /// More detailed information may be available from `index_statistics` but that will require - /// loading the index into memory. - async fn describe_indices<'a, 'b>( - &'a self, - criteria: Option>, - ) -> Result>>; - - /// Loads a specific index with the given index name. - async fn load_scalar_index<'a, 'b>( - &'a self, - criteria: IndexCriteria<'b>, - ) -> Result>; - - /// Optimize indices. - async fn optimize_indices(&mut self, options: &OptimizeOptions) -> Result<()>; - - /// Find index with a given index_name and return its serialized statistics. - /// - /// If the index does not exist, return Error. - async fn index_statistics(&self, index_name: &str) -> Result; - - /// Commit one or more existing physical index segments as a logical index. - /// - /// This publishes already-built physical segments. It does not build - /// or merge index data; callers should first build segments with - /// [`Self::create_index_segment_builder`] or another index-specific build - /// path and then pass the resulting segments here. - async fn commit_existing_index_segments( - &mut self, - index_name: &str, - column: &str, - segments: Vec, - ) -> Result<()>; - - async fn read_index_partition( - &self, - index_name: &str, - partition_id: usize, - with_vector: bool, - ) -> Result; -} diff --git a/rust/lance-index/src/types.rs b/rust/lance-index/src/types.rs deleted file mode 100644 index 4d653f21f13..00000000000 --- a/rust/lance-index/src/types.rs +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -use std::sync::Arc; - -use crate::IndexType; -use lance_table::format::IndexMetadata; -use roaring::RoaringBitmap; -use uuid::Uuid; - -/// A single physical segment of a logical index. -/// -/// Each segment is stored independently and will become one manifest entry when committed. -/// The logical index identity (name / target column / dataset version) is provided separately -/// by the commit API. -#[derive(Debug, Clone, PartialEq)] -pub struct IndexSegment { - /// Unique ID of the physical segment. - uuid: Uuid, - /// The fragments covered by this segment. - fragment_bitmap: RoaringBitmap, - /// Metadata specific to the index type. - index_details: Arc, - /// The on-disk index version for this segment. - index_version: i32, -} - -impl IndexSegment { - /// Create a fully described segment with the given UUID, fragment coverage, and index - /// metadata. - pub fn new( - uuid: Uuid, - fragment_bitmap: I, - index_details: Arc, - index_version: i32, - ) -> Self - where - I: IntoIterator, - { - Self { - uuid, - fragment_bitmap: fragment_bitmap.into_iter().collect(), - index_details, - index_version, - } - } - - /// Return the UUID of this segment. - pub fn uuid(&self) -> Uuid { - self.uuid - } - - /// Return the fragment coverage of this segment. - pub fn fragment_bitmap(&self) -> &RoaringBitmap { - &self.fragment_bitmap - } - - /// Return the serialized index details for this segment. - pub fn index_details(&self) -> &Arc { - &self.index_details - } - - /// Return the on-disk index version for this segment. - pub fn index_version(&self) -> i32 { - self.index_version - } - - /// Consume the segment and return its component parts. - pub fn into_parts(self) -> (Uuid, RoaringBitmap, Arc, i32) { - ( - self.uuid, - self.fragment_bitmap, - self.index_details, - self.index_version, - ) - } -} - -/// A plan for building one physical segment from one or more existing -/// vector index segments. -#[derive(Debug, Clone, PartialEq)] -pub struct IndexSegmentPlan { - segment: IndexSegment, - segments: Vec, - estimated_bytes: u64, - requested_index_type: Option, -} - -impl IndexSegmentPlan { - /// Create a plan for one built segment. - pub fn new( - segment: IndexSegment, - segments: Vec, - estimated_bytes: u64, - requested_index_type: Option, - ) -> Self { - Self { - segment, - segments, - estimated_bytes, - requested_index_type, - } - } - - /// Return the segment metadata that should be committed after this plan is built. - pub fn segment(&self) -> &IndexSegment { - &self.segment - } - - /// Return the input segment metadata that should be combined into the segment. - pub fn segments(&self) -> &[IndexMetadata] { - &self.segments - } - - /// Return the estimated number of bytes covered by this plan. - pub fn estimated_bytes(&self) -> u64 { - self.estimated_bytes - } - - /// Return the requested logical index type, if one was supplied to the planner. - pub fn requested_index_type(&self) -> Option { - self.requested_index_type - } -} diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index 902c5462acc..9c8bf337712 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -18,6 +18,7 @@ use lance::dataset::{ DeleteBuilder, MergeInsertBuilder, ReadParams, WhenMatched, WhenNotMatched, WriteParams, builder::DatasetBuilder, }; +use lance::index::DatasetIndexExt; use lance::session::Session; use lance::{Dataset, dataset::scanner::Scanner}; use lance_core::Error as LanceError; @@ -26,7 +27,6 @@ use lance_core::{Error, Result, box_error}; use lance_index::IndexType; use lance_index::optimize::OptimizeOptions; use lance_index::scalar::{BuiltinIndexType, ScalarIndexParams}; -use lance_index::traits::DatasetIndexExt; use lance_io::object_store::{ObjectStore, ObjectStoreParams}; use lance_namespace::LanceNamespace; use lance_namespace::error::NamespaceError; diff --git a/rust/lance/benches/ivf_pq.rs b/rust/lance/benches/ivf_pq.rs index ebe70a32d4d..ae92b406168 100644 --- a/rust/lance/benches/ivf_pq.rs +++ b/rust/lance/benches/ivf_pq.rs @@ -9,13 +9,14 @@ use arrow_array::{FixedSizeListArray, RecordBatch, RecordBatchIterator}; use arrow_schema::{DataType, Field, FieldRef, Schema}; use criterion::{Criterion, criterion_group, criterion_main}; +use lance::index::DatasetIndexExt; use lance::{ Dataset, dataset::{WriteMode, WriteParams}, index::vector::VectorIndexParams, }; use lance_arrow::*; -use lance_index::{DatasetIndexExt, IndexType}; +use lance_index::IndexType; use lance_linalg::distance::MetricType; use lance_testing::datagen::generate_random_array; #[cfg(target_os = "linux")] diff --git a/rust/lance/benches/mem_wal_write.rs b/rust/lance/benches/mem_wal_write.rs index 363767b519e..31f855fd0ad 100644 --- a/rust/lance/benches/mem_wal_write.rs +++ b/rust/lance/benches/mem_wal_write.rs @@ -54,12 +54,13 @@ use arrow_schema::{DataType, Field, Schema as ArrowSchema}; use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use lance::dataset::mem_wal::{DatasetMemWalExt, MemWalConfig, RegionWriterConfig}; use lance::dataset::{Dataset, WriteParams}; +use lance::index::DatasetIndexExt; use lance::index::vector::VectorIndexParams; use lance_arrow::FixedSizeListArrayExt; +use lance_index::IndexType; use lance_index::scalar::ScalarIndexParams; use lance_index::vector::ivf::IvfBuildParams; use lance_index::vector::pq::PQBuildParams; -use lance_index::{DatasetIndexExt, IndexType}; use lance_linalg::distance::DistanceType; #[cfg(target_os = "linux")] use pprof::criterion::{Output, PProfProfiler}; diff --git a/rust/lance/benches/memtable_read.rs b/rust/lance/benches/memtable_read.rs index 240c90294e9..efd16dbb62a 100644 --- a/rust/lance/benches/memtable_read.rs +++ b/rust/lance/benches/memtable_read.rs @@ -38,15 +38,16 @@ use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_m use futures::TryStreamExt; use lance::dataset::mem_wal::write::{CacheConfig, IndexStore, MemTable}; use lance::dataset::{Dataset, WriteParams}; +use lance::index::DatasetIndexExt; use lance::index::vector::VectorIndexParams; use lance_arrow::FixedSizeListArrayExt; +use lance_index::IndexType; use lance_index::scalar::FullTextSearchQuery; use lance_index::scalar::inverted::tokenizer::InvertedIndexParams; use lance_index::vector::ivf::IvfBuildParams; use lance_index::vector::ivf::storage::IvfModel; use lance_index::vector::kmeans::{KMeansParams, train_kmeans}; use lance_index::vector::pq::builder::PQBuildParams; -use lance_index::{DatasetIndexExt, IndexType}; use lance_linalg::distance::{DistanceType, MetricType}; #[cfg(target_os = "linux")] use pprof::criterion::{Output, PProfProfiler}; diff --git a/rust/lance/benches/vector_index.rs b/rust/lance/benches/vector_index.rs index cf4106db8b4..21c9aa4e4aa 100644 --- a/rust/lance/benches/vector_index.rs +++ b/rust/lance/benches/vector_index.rs @@ -15,10 +15,11 @@ use pprof::criterion::{Output, PProfProfiler}; use rand::Rng; use lance::dataset::{Dataset, WriteMode, WriteParams, builder::DatasetBuilder}; +use lance::index::DatasetIndexExt; use lance::index::vector::VectorIndexParams; use lance_arrow::{FixedSizeListArrayExt, as_fixed_size_list_array}; use lance_index::{ - DatasetIndexExt, IndexType, + IndexType, vector::{ivf::IvfBuildParams, pq::PQBuildParams}, }; use lance_linalg::distance::MetricType; diff --git a/rust/lance/benches/vector_throughput.rs b/rust/lance/benches/vector_throughput.rs index ff24c7771ba..9a04971684b 100644 --- a/rust/lance/benches/vector_throughput.rs +++ b/rust/lance/benches/vector_throughput.rs @@ -19,10 +19,11 @@ use pprof::criterion::{Output, PProfProfiler}; use rand::Rng; use lance::dataset::{Dataset, WriteMode, WriteParams}; +use lance::index::DatasetIndexExt; use lance::index::vector::VectorIndexParams; use lance_arrow::FixedSizeListArrayExt; use lance_index::{ - DatasetIndexExt, IndexType, + IndexType, vector::{ivf::IvfBuildParams, pq::PQBuildParams}, }; use lance_linalg::distance::MetricType; diff --git a/rust/lance/src/bin/lq.rs b/rust/lance/src/bin/lq.rs index d28121bbf55..afaa15c7f10 100644 --- a/rust/lance/src/bin/lq.rs +++ b/rust/lance/src/bin/lq.rs @@ -10,9 +10,9 @@ use futures::TryStreamExt; use futures::stream::StreamExt; use lance::dataset::Dataset; +use lance::index::DatasetIndexExt; use lance::index::vector::VectorIndexParams; use lance::{Error, Result}; -use lance_index::DatasetIndexExt; use lance_linalg::distance::MetricType; #[derive(Parser)] diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 02f4b28e047..8f3deb2bef6 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -15,6 +15,7 @@ use futures::{FutureExt, Stream}; use crate::dataset::metadata::UpdateFieldMetadataBuilder; use crate::dataset::transaction::translate_schema_metadata_updates; +use crate::index::DatasetIndexExt; use crate::session::caches::{DSMetadataCache, ManifestKey, TransactionKey}; use crate::session::index_caches::DSIndexCache; use itertools::Itertools; @@ -30,7 +31,7 @@ use lance_datafusion::projection::ProjectionPlan; use lance_file::datatypes::populate_schema_dictionary; use lance_file::reader::FileReaderOptions; use lance_file::version::LanceFileVersion; -use lance_index::{DatasetIndexExt, IndexType}; +use lance_index::IndexType; use lance_io::object_store::{ LanceNamespaceStorageOptionsProvider, ObjectStore, ObjectStoreParams, StorageOptions, StorageOptionsAccessor, StorageOptionsProvider, diff --git a/rust/lance/src/dataset/cleanup.rs b/rust/lance/src/dataset/cleanup.rs index 18380458eeb..f5162bba191 100644 --- a/rust/lance/src/dataset/cleanup.rs +++ b/rust/lance/src/dataset/cleanup.rs @@ -1180,6 +1180,7 @@ mod tests { use super::*; use crate::blob::{BlobArrayBuilder, blob_field}; + use crate::index::DatasetIndexExt; use crate::{ dataset::{ReadParams, WriteMode, WriteParams, builder::DatasetBuilder}, index::vector::VectorIndexParams, @@ -1193,7 +1194,7 @@ mod tests { use datafusion::common::assert_contains; use lance_core::utils::tempfile::TempStrDir; use lance_core::utils::testing::{ProxyObjectStore, ProxyObjectStorePolicy}; - use lance_index::{DatasetIndexExt, IndexType}; + use lance_index::IndexType; use lance_io::object_store::{ ObjectStore, ObjectStoreParams, ObjectStoreRegistry, WrappingObjectStore, }; @@ -2642,8 +2643,9 @@ mod tests { // Create a full-text index (Inverted) on the "text" column once. // We only create this on main during dataset creation. Branches inherit the index configuration. async fn create_text_index(&mut self) -> Result<()> { + use crate::index::DatasetIndexExt; + use lance_index::IndexType; use lance_index::scalar::InvertedIndexParams; - use lance_index::{DatasetIndexExt, IndexType}; let params = InvertedIndexParams::default(); self.dataset .create_index(&["text"], IndexType::Inverted, None, ¶ms, true) diff --git a/rust/lance/src/dataset/index.rs b/rust/lance/src/dataset/index.rs index d4de4d0be6f..91c535468e5 100644 --- a/rust/lance/src/dataset/index.rs +++ b/rust/lance/src/dataset/index.rs @@ -9,13 +9,13 @@ use std::sync::Arc; use crate::Dataset; use crate::dataset::optimize::RemappedIndex; use crate::dataset::optimize::remapping::RemapResult; +use crate::index::DatasetIndexExt; use crate::index::remap_index; use crate::index::scalar::infer_scalar_index_details; use arrow_schema::DataType; use async_trait::async_trait; use lance_core::{Error, Result}; use lance_encoding::version::LanceFileVersion; -use lance_index::DatasetIndexExt; use lance_index::frag_reuse::FRAG_REUSE_INDEX_NAME; use lance_index::scalar::lance_format::LanceIndexStore; use lance_table::format::IndexMetadata; diff --git a/rust/lance/src/dataset/index/frag_reuse.rs b/rust/lance/src/dataset/index/frag_reuse.rs index cc7b845313d..4fbefcd4725 100644 --- a/rust/lance/src/dataset/index/frag_reuse.rs +++ b/rust/lance/src/dataset/index/frag_reuse.rs @@ -149,12 +149,13 @@ fn is_index_remap_caught_up( mod tests { use super::*; use crate::dataset::optimize::{CompactionOptions, compact_files, remapping}; + use crate::index::DatasetIndexExt; use crate::utils::test::{DatagenExt, FragmentCount, FragmentRowCount}; use all_asserts::{assert_false, assert_true}; use arrow_array::types::{Float32Type, Int32Type}; use lance_datagen::Dimension; + use lance_index::IndexType; use lance_index::scalar::ScalarIndexParams; - use lance_index::{DatasetIndexExt, IndexType}; #[tokio::test] async fn test_cleanup_frag_reuse_index() { diff --git a/rust/lance/src/dataset/mem_wal/api.rs b/rust/lance/src/dataset/mem_wal/api.rs index 99bb415d6a0..30c6a10811a 100644 --- a/rust/lance/src/dataset/mem_wal/api.rs +++ b/rust/lance/src/dataset/mem_wal/api.rs @@ -8,9 +8,9 @@ use std::sync::Arc; +use crate::index::DatasetIndexExt; use async_trait::async_trait; use lance_core::{Error, Result}; -use lance_index::DatasetIndexExt; use lance_index::mem_wal::{MEM_WAL_INDEX_NAME, MemWalIndexDetails, RegionSpec}; use lance_index::vector::ivf::storage::IvfModel; use lance_index::vector::pq::ProductQuantizer; diff --git a/rust/lance/src/dataset/mem_wal/memtable/flush.rs b/rust/lance/src/dataset/mem_wal/memtable/flush.rs index c3b955fc265..9ff133413ba 100644 --- a/rust/lance/src/dataset/mem_wal/memtable/flush.rs +++ b/rust/lance/src/dataset/mem_wal/memtable/flush.rs @@ -962,7 +962,7 @@ mod tests { #[tokio::test] async fn test_flusher_with_btree_index() { use super::super::super::index::{BTreeIndexConfig, IndexStore}; - use lance_index::DatasetIndexExt; + use crate::index::DatasetIndexExt; let (store, base_path, base_uri, _temp_dir) = create_local_store().await; let region_id = Uuid::new_v4(); @@ -1060,9 +1060,9 @@ mod tests { #[tokio::test] async fn test_flusher_with_ivf_pq_index() { use super::super::super::index::{IndexStore, IvfPqIndexConfig}; + use crate::index::DatasetIndexExt; use arrow_array::{FixedSizeListArray, Float32Array}; use lance_arrow::FixedSizeListArrayExt; - use lance_index::DatasetIndexExt; use lance_index::vector::ivf::storage::IvfModel; use lance_index::vector::kmeans::{KMeansParams, train_kmeans}; use lance_index::vector::pq::PQBuildParams; @@ -1284,9 +1284,9 @@ mod tests { #[tokio::test] async fn test_flusher_with_fts_index() { use super::super::super::index::{FtsIndexConfig, IndexStore}; + use crate::index::DatasetIndexExt; use arrow_array::StringArray; use arrow_schema::{DataType, Field, Schema as ArrowSchema}; - use lance_index::DatasetIndexExt; use std::sync::Arc; let (store, base_path, base_uri, _temp_dir) = create_local_store().await; diff --git a/rust/lance/src/dataset/mem_wal/write.rs b/rust/lance/src/dataset/mem_wal/write.rs index 65aa1d97586..dda06a7c9d9 100644 --- a/rust/lance/src/dataset/mem_wal/write.rs +++ b/rust/lance/src/dataset/mem_wal/write.rs @@ -2128,16 +2128,17 @@ mod tests { mod region_writer_tests { use std::sync::Arc; + use crate::index::DatasetIndexExt; use arrow_array::{ FixedSizeListArray, Float32Array, Int64Array, RecordBatch, RecordBatchIterator, StringArray, }; use arrow_schema::{DataType, Field, Schema as ArrowSchema}; use lance_arrow::FixedSizeListArrayExt; + use lance_index::IndexType; use lance_index::scalar::ScalarIndexParams; use lance_index::scalar::inverted::InvertedIndexParams; use lance_index::vector::ivf::IvfBuildParams; use lance_index::vector::pq::builder::PQBuildParams; - use lance_index::{DatasetIndexExt, IndexType}; use lance_linalg::distance::MetricType; use uuid::Uuid; diff --git a/rust/lance/src/dataset/optimize.rs b/rust/lance/src/dataset/optimize.rs index 7cec534e171..c70eb93bbcd 100644 --- a/rust/lance/src/dataset/optimize.rs +++ b/rust/lance/src/dataset/optimize.rs @@ -97,6 +97,7 @@ use super::{WriteMode, WriteParams, write_fragments_internal}; use crate::Dataset; use crate::Result; use crate::dataset::utils::CapturedRowIds; +use crate::index::DatasetIndexExt; use crate::io::commit::{commit_transaction, migrate_fragments}; use datafusion::physical_plan::SendableRecordBatchStream; use datafusion::physical_plan::stream::RecordBatchStreamAdapter; @@ -105,7 +106,6 @@ use lance_core::Error; use lance_core::datatypes::BlobHandling; use lance_core::utils::tokio::get_num_compute_intensive_cpus; use lance_core::utils::tracing::{DATASET_COMPACTING_EVENT, TRACE_DATASET_EVENTS}; -use lance_index::DatasetIndexExt; use lance_index::frag_reuse::FragReuseGroup; use lance_table::format::{Fragment, RowIdMeta}; use roaring::{RoaringBitmap, RoaringTreemap}; diff --git a/rust/lance/src/dataset/optimize/remapping.rs b/rust/lance/src/dataset/optimize/remapping.rs index 253bd63ff15..dab62bf6166 100644 --- a/rust/lance/src/dataset/optimize/remapping.rs +++ b/rust/lance/src/dataset/optimize/remapping.rs @@ -6,12 +6,12 @@ use crate::Result; use crate::dataset::transaction::{Operation, Transaction}; +use crate::index::DatasetIndexExt; use crate::index::frag_reuse::{load_frag_reuse_index_details, open_frag_reuse_index}; use crate::{Dataset, index}; use async_trait::async_trait; use lance_core::Error; use lance_core::utils::address::RowAddress; -use lance_index::DatasetIndexExt; use lance_index::frag_reuse::{FRAG_REUSE_INDEX_NAME, FragDigest}; use lance_table::format::{Fragment, IndexFile, IndexMetadata}; use lance_table::io::manifest::read_manifest_indexes; diff --git a/rust/lance/src/dataset/rowids.rs b/rust/lance/src/dataset/rowids.rs index b6b4b106798..447868fca99 100644 --- a/rust/lance/src/dataset/rowids.rs +++ b/rust/lance/src/dataset/rowids.rs @@ -130,6 +130,7 @@ mod test { use super::*; use crate::dataset::optimize::{CompactionOptions, compact_files}; + use crate::index::DatasetIndexExt; use crate::utils::test::{DatagenExt, FragmentCount, FragmentRowCount}; use arrow_array::cast::AsArray; use arrow_array::types::{Float32Type, Int32Type, UInt64Type}; @@ -139,7 +140,7 @@ mod test { use lance_core::datatypes::Schema; use lance_core::{ROW_ADDR, ROW_ID, utils::address::RowAddress}; use lance_datagen::Dimension; - use lance_index::{DatasetIndexExt, IndexType, scalar::ScalarIndexParams}; + use lance_index::{IndexType, scalar::ScalarIndexParams}; use std::collections::HashMap; use std::collections::HashSet; diff --git a/rust/lance/src/dataset/scanner.rs b/rust/lance/src/dataset/scanner.rs index 63bd7884879..34483bfd847 100644 --- a/rust/lance/src/dataset/scanner.rs +++ b/rust/lance/src/dataset/scanner.rs @@ -6,6 +6,7 @@ use std::pin::Pin; use std::sync::{Arc, LazyLock}; use std::task::{Context, Poll}; +use crate::index::DatasetIndexExt; use arrow::array::AsArray; use arrow_array::{Array, Float32Array, Int64Array, RecordBatch}; use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema, SchemaRef, SortOptions}; @@ -60,13 +61,13 @@ use lance_datafusion::projection::ProjectionPlan; use lance_file::reader::FileReaderOptions; use lance_index::IndexCriteria; use lance_index::scalar::FullTextSearchQuery; +use lance_index::scalar::expression::ScalarIndexExpr; use lance_index::scalar::expression::{INDEX_EXPR_RESULT_SCHEMA, IndexExprResult, PlannerIndexExt}; use lance_index::scalar::inverted::query::{ FtsQuery, FtsQueryNode, FtsSearchParams, MatchQuery, PhraseQuery, fill_fts_query_column, }; use lance_index::scalar::inverted::{SCORE_COL, SCORE_FIELD}; use lance_index::vector::{DIST_COL, Query}; -use lance_index::{DatasetIndexExt, scalar::expression::ScalarIndexExpr}; use lance_index::{metrics::NoOpMetricsCollector, scalar::inverted::FTS_SCHEMA}; use lance_io::stream::RecordBatchStream; use lance_linalg::distance::MetricType; diff --git a/rust/lance/src/dataset/schema_evolution.rs b/rust/lance/src/dataset/schema_evolution.rs index b79cb283956..269093fc98b 100644 --- a/rust/lance/src/dataset/schema_evolution.rs +++ b/rust/lance/src/dataset/schema_evolution.rs @@ -1783,11 +1783,12 @@ mod test { ) -> Result<()> { // Create a table with 2 scalar columns, 1 vector column + use crate::index::DatasetIndexExt; use arrow::datatypes::{Int32Type, Int64Type}; use arrow_array::{Float16Array, Float32Array, Int64Array, ListArray}; use half::f16; use lance_arrow::FixedSizeListArrayExt; - use lance_index::{DatasetIndexExt, IndexType, scalar::ScalarIndexParams}; + use lance_index::{IndexType, scalar::ScalarIndexParams}; use lance_linalg::distance::MetricType; use lance_testing::datagen::generate_random_array; diff --git a/rust/lance/src/dataset/tests/dataset_aggregate.rs b/rust/lance/src/dataset/tests/dataset_aggregate.rs index cdc954a9997..ef2a90e6315 100644 --- a/rust/lance/src/dataset/tests/dataset_aggregate.rs +++ b/rust/lance/src/dataset/tests/dataset_aggregate.rs @@ -38,12 +38,13 @@ use tempfile::tempdir; use crate::Dataset; use crate::dataset::scanner::AggregateExpr; +use crate::index::DatasetIndexExt; use crate::index::vector::VectorIndexParams; use crate::utils::test::{DatagenExt, FragmentCount, FragmentRowCount, assert_plan_node_equals}; use lance_arrow::FixedSizeListArrayExt; +use lance_index::IndexType; use lance_index::scalar::FullTextSearchQuery; use lance_index::scalar::inverted::InvertedIndexParams; -use lance_index::{DatasetIndexExt, IndexType}; use lance_linalg::distance::MetricType; /// Helper to create a field reference expression for a column index diff --git a/rust/lance/src/dataset/tests/dataset_concurrency_store.rs b/rust/lance/src/dataset/tests/dataset_concurrency_store.rs index 7ce57a4c0f4..a9c2aa44c38 100644 --- a/rust/lance/src/dataset/tests/dataset_concurrency_store.rs +++ b/rust/lance/src/dataset/tests/dataset_concurrency_store.rs @@ -8,12 +8,12 @@ use crate::dataset::WriteDestination; use crate::{Dataset, Error, Result}; use crate::dataset::write::{WriteMode, WriteParams}; +use crate::index::DatasetIndexExt; use arrow_array::RecordBatch; use arrow_array::{Int32Array, RecordBatchIterator}; use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; use futures::TryStreamExt; use lance_core::utils::tempfile::TempStrDir; -use lance_index::DatasetIndexExt; use lance_index::{IndexType, scalar::ScalarIndexParams}; #[tokio::test] diff --git a/rust/lance/src/dataset/tests/dataset_geo.rs b/rust/lance/src/dataset/tests/dataset_geo.rs index dc1e79dc455..a43718dd7d4 100644 --- a/rust/lance/src/dataset/tests/dataset_geo.rs +++ b/rust/lance/src/dataset/tests/dataset_geo.rs @@ -7,6 +7,7 @@ use std::vec; use crate::Dataset; use crate::dataset::tests::dataset_transactions::execute_sql; +use crate::index::DatasetIndexExt; use arrow_array::RecordBatch; use arrow_array::RecordBatchIterator; use arrow_array::cast::AsArray; @@ -19,8 +20,8 @@ use geoarrow_array::{ }; use geoarrow_schema::{Dimension, LineStringType, PointType, PolygonType}; use lance_core::utils::tempfile::TempStrDir; +use lance_index::IndexType; use lance_index::scalar::ScalarIndexParams; -use lance_index::{DatasetIndexExt, IndexType}; #[tokio::test] async fn test_geo_types() { diff --git a/rust/lance/src/dataset/tests/dataset_index.rs b/rust/lance/src/dataset/tests/dataset_index.rs index 87c17cdba32..abde3995984 100644 --- a/rust/lance/src/dataset/tests/dataset_index.rs +++ b/rust/lance/src/dataset/tests/dataset_index.rs @@ -13,6 +13,7 @@ use crate::{Dataset, Error, Result}; use lance_arrow::FixedSizeListArrayExt; use crate::dataset::write::{WriteMode, WriteParams}; +use crate::index::DatasetIndexExt; use arrow::array::{AsArray, GenericListBuilder, GenericStringBuilder}; use arrow::datatypes::UInt64Type; use arrow_array::RecordBatch; @@ -31,7 +32,6 @@ use lance_core::utils::tempfile::TempStrDir; use lance_datagen::{BatchCount, Dimension, RowCount, array, gen_batch}; use lance_file::reader::{FileReader, FileReaderOptions}; use lance_file::version::LanceFileVersion; -use lance_index::DatasetIndexExt; use lance_index::scalar::FullTextSearchQuery; use lance_index::scalar::inverted::{ query::{BooleanQuery, MatchQuery, Occur, Operator, PhraseQuery}, diff --git a/rust/lance/src/dataset/tests/dataset_io.rs b/rust/lance/src/dataset/tests/dataset_io.rs index e438e0801ea..86ae416b1be 100644 --- a/rust/lance/src/dataset/tests/dataset_io.rs +++ b/rust/lance/src/dataset/tests/dataset_io.rs @@ -36,9 +36,10 @@ use lance_file::version::LanceFileVersion; use lance_io::assert_io_eq; use lance_table::feature_flags; +use crate::index::DatasetIndexExt; use futures::TryStreamExt; +use lance_index::IndexType; use lance_index::scalar::ScalarIndexParams; -use lance_index::{DatasetIndexExt, IndexType}; use lance_io::object_store::{ObjectStore, ObjectStoreParams}; use lance_io::utils::tracking_store::IOTracker; use lance_table::io::manifest::read_manifest; diff --git a/rust/lance/src/dataset/tests/dataset_merge_update.rs b/rust/lance/src/dataset/tests/dataset_merge_update.rs index 5990318ef5c..b11f0c1474f 100644 --- a/rust/lance/src/dataset/tests/dataset_merge_update.rs +++ b/rust/lance/src/dataset/tests/dataset_merge_update.rs @@ -9,11 +9,12 @@ use crate::dataset::WriteDestination; use crate::dataset::optimize::{CompactionOptions, compact_files}; use crate::dataset::transaction::{DataReplacementGroup, Operation}; use crate::dataset::{AutoCleanupParams, MergeInsertBuilder, ProjectionRequest}; +use crate::index::DatasetIndexExt; use crate::{Dataset, Error}; use lance_core::ROW_ADDR; +use lance_index::IndexType; use lance_index::optimize::OptimizeOptions; use lance_index::scalar::ScalarIndexParams; -use lance_index::{DatasetIndexExt, IndexType}; use mock_instant::thread_local::MockClock; use crate::dataset::write::{InsertBuilder, WriteMode, WriteParams}; diff --git a/rust/lance/src/dataset/tests/dataset_migrations.rs b/rust/lance/src/dataset/tests/dataset_migrations.rs index ecbffa717ba..d71a65bfa69 100644 --- a/rust/lance/src/dataset/tests/dataset_migrations.rs +++ b/rust/lance/src/dataset/tests/dataset_migrations.rs @@ -11,12 +11,12 @@ use crate::{Dataset, Result}; use lance_table::format::IndexMetadata; use crate::dataset::write::{WriteMode, WriteParams}; +use crate::index::DatasetIndexExt; use arrow::compute::concat_batches; use arrow_array::RecordBatch; use arrow_array::{Float32Array, Int64Array, RecordBatchIterator}; use arrow_schema::Schema as ArrowSchema; use lance_file::version::LanceFileVersion; -use lance_index::DatasetIndexExt; use futures::{StreamExt, TryStreamExt}; use rstest::rstest; diff --git a/rust/lance/src/dataset/tests/dataset_scanner.rs b/rust/lance/src/dataset/tests/dataset_scanner.rs index e5e72b48f84..d5dac4c8562 100644 --- a/rust/lance/src/dataset/tests/dataset_scanner.rs +++ b/rust/lance/src/dataset/tests/dataset_scanner.rs @@ -9,6 +9,7 @@ use crate::index::vector::VectorIndexParams; use lance_arrow::FixedSizeListArrayExt; use lance_arrow::json::{JsonArray, is_arrow_json_field, json_field}; +use crate::index::DatasetIndexExt; use arrow::compute::concat_batches; use arrow_array::UInt64Array; use arrow_array::{Array, FixedSizeListArray}; @@ -24,7 +25,7 @@ use lance_index::scalar::FullTextSearchQuery; use lance_index::scalar::inverted::{ SCORE_FIELD, query::PhraseQuery, tokenizer::InvertedIndexParams, }; -use lance_index::{DatasetIndexExt, IndexType, vector::DIST_COL}; +use lance_index::{IndexType, vector::DIST_COL}; use lance_io::scheduler::{ScanScheduler, SchedulerConfig}; use lance_io::utils::CachedFileSize; use lance_linalg::distance::MetricType; diff --git a/rust/lance/src/dataset/tests/dataset_transactions.rs b/rust/lance/src/dataset/tests/dataset_transactions.rs index 1f522800a44..2b49d0963e1 100644 --- a/rust/lance/src/dataset/tests/dataset_transactions.rs +++ b/rust/lance/src/dataset/tests/dataset_transactions.rs @@ -14,13 +14,13 @@ use crate::{Dataset, Result}; use lance_table::io::commit::ManifestNamingScheme; use crate::dataset::write::{CommitBuilder, InsertBuilder, WriteMode, WriteParams}; +use crate::index::DatasetIndexExt; use arrow_array::Array; use arrow_array::RecordBatch; use arrow_array::{Int32Array, RecordBatchIterator, StringArray, types::Int32Type}; use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; use lance_core::utils::tempfile::{TempDir, TempStrDir}; use lance_datagen::{BatchCount, RowCount, array}; -use lance_index::DatasetIndexExt; use crate::datafusion::LanceTableProvider; use datafusion::prelude::SessionContext; diff --git a/rust/lance/src/dataset/udtf.rs b/rust/lance/src/dataset/udtf.rs index d25aec45009..b39586c2777 100644 --- a/rust/lance/src/dataset/udtf.rs +++ b/rust/lance/src/dataset/udtf.rs @@ -259,13 +259,14 @@ impl FtsQueryUDTFBuilder { pub mod tests { use crate::Dataset; use crate::dataset::udtf::FtsQueryUDTFBuilder; + use crate::index::DatasetIndexExt; use arrow_array::{ Array, Int32Array, RecordBatch, RecordBatchIterator, StringArray, UInt64Array, }; use arrow_schema::{DataType, Field}; use datafusion::prelude::SessionContext; + use lance_index::IndexType; use lance_index::scalar::InvertedIndexParams; - use lance_index::{DatasetIndexExt, IndexType}; use std::sync::Arc; #[tokio::test] diff --git a/rust/lance/src/dataset/write/delete.rs b/rust/lance/src/dataset/write/delete.rs index 18916b087f5..2f7314925e6 100644 --- a/rust/lance/src/dataset/write/delete.rs +++ b/rust/lance/src/dataset/write/delete.rs @@ -288,6 +288,7 @@ mod tests { use super::*; use crate::dataset::{InsertBuilder, UpdateBuilder}; use crate::dataset::{WriteMode, WriteParams}; + use crate::index::DatasetIndexExt; use crate::utils::test::TestDatasetGenerator; use arrow::array::AsArray; use arrow::datatypes::UInt32Type; @@ -296,7 +297,7 @@ mod tests { use futures::TryStreamExt; use lance_core::utils::tempfile::TempStrDir; use lance_file::version::LanceFileVersion; - use lance_index::{DatasetIndexExt, IndexType, scalar::ScalarIndexParams}; + use lance_index::{IndexType, scalar::ScalarIndexParams}; use rstest::rstest; use std::collections::HashSet; use std::ops::Range; diff --git a/rust/lance/src/dataset/write/merge_insert.rs b/rust/lance/src/dataset/write/merge_insert.rs index cfc1e8f0dca..2c56d8349fc 100644 --- a/rust/lance/src/dataset/write/merge_insert.rs +++ b/rust/lance/src/dataset/write/merge_insert.rs @@ -29,6 +29,7 @@ use super::{CommitBuilder, WriteParams, write_fragments_internal}; use crate::dataset::rowids::get_row_id_index; use crate::dataset::transaction::UpdateMode::{RewriteColumns, RewriteRows}; use crate::dataset::utils::CapturedRowIds; +use crate::index::DatasetIndexExt; use crate::{ Dataset, datafusion::dataframe::SessionContextExt, @@ -94,8 +95,8 @@ use lance_datafusion::{ utils::StreamingWriteSource, }; use lance_file::version::LanceFileVersion; +use lance_index::IndexCriteria; use lance_index::mem_wal::MergedGeneration; -use lance_index::{DatasetIndexExt, IndexCriteria}; use lance_table::format::{Fragment, IndexMetadata, RowIdMeta}; use log::info; use roaring::RoaringTreemap; diff --git a/rust/lance/src/dataset/write/update.rs b/rust/lance/src/dataset/write/update.rs index 855d83018f4..ec34000642d 100644 --- a/rust/lance/src/dataset/write/update.rs +++ b/rust/lance/src/dataset/write/update.rs @@ -483,6 +483,7 @@ mod tests { use super::*; use crate::dataset::{WriteDestination, WriteMode}; + use crate::index::DatasetIndexExt; use crate::index::vector::VectorIndexParams; use crate::utils::test::{DatagenExt, FragmentCount, FragmentRowCount}; use arrow::{array::AsArray, datatypes::UInt32Type}; @@ -495,7 +496,6 @@ mod tests { use lance_core::utils::tempfile::TempStrDir; use lance_datagen::{Dimension, RowCount}; use lance_file::version::LanceFileVersion; - use lance_index::DatasetIndexExt; use lance_index::IndexType; use lance_index::scalar::ScalarIndexParams; use lance_io::object_store::ObjectStoreParams; diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index 28da8f61e1b..843fc8c7740 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -24,6 +24,7 @@ use lance_core::utils::tracing::{ }; use lance_file::previous::reader::FileReader as PreviousFileReader; use lance_file::reader::FileReaderOptions; +use lance_index::INDEX_METADATA_SCHEMA_KEY; pub use lance_index::IndexParams; use lance_index::frag_reuse::{FRAG_REUSE_INDEX_NAME, FragReuseIndex}; use lance_index::mem_wal::{MEM_WAL_INDEX_NAME, MemWalIndex}; @@ -42,7 +43,6 @@ use lance_index::vector::flat::index::{FlatBinQuantizer, FlatIndex, FlatQuantize use lance_index::vector::hnsw::HNSW; use lance_index::vector::pq::ProductQuantizer; use lance_index::vector::sq::ScalarQuantizer; -use lance_index::{DatasetIndexExt, INDEX_METADATA_SCHEMA_KEY, IndexDescription, IndexSegment}; use lance_index::{INDEX_FILE_NAME, Index, IndexType, pb, vector::VectorIndex}; use lance_index::{ IndexCriteria, is_system_index, @@ -65,6 +65,7 @@ use uuid::Uuid; use vector::ivf::v2::IVFIndex; use vector::utils::get_vector_type; +mod api; pub(crate) mod append; mod create; pub mod frag_reuse; @@ -79,6 +80,7 @@ use crate::dataset::index::LanceIndexStoreExt; use crate::dataset::optimize::RemappedIndex; use crate::dataset::optimize::remapping::RemapResult; use crate::dataset::transaction::{Operation, Transaction, TransactionBuilder}; +pub use crate::index::api::{DatasetIndexExt, IndexSegment, IndexSegmentPlan}; use crate::index::frag_reuse::{load_frag_reuse_index_details, open_frag_reuse_index}; use crate::index::mem_wal::open_mem_wal_index; pub use crate::index::prefilter::{FilterLoader, PreFilter}; @@ -86,6 +88,7 @@ use crate::index::scalar::{IndexDetails, fetch_index_details, load_training_data use crate::session::index_caches::{FragReuseIndexKey, IndexMetadataKey}; use crate::{Error, Result, dataset::Dataset}; pub use create::CreateIndexBuilder; +pub use lance_index::IndexDescription; fn validate_index_segments(index_name: &str, segments: &[IndexSegment]) -> Result<()> { if segments.is_empty() { @@ -645,7 +648,8 @@ impl DatasetIndexExt for Dataset { /// Create a scalar BTREE index: /// ``` /// # use lance::{Dataset, Result}; - /// # use lance_index::{DatasetIndexExt, IndexType, scalar::ScalarIndexParams}; + /// # use lance::index::DatasetIndexExt; + /// # use lance_index::{IndexType, scalar::ScalarIndexParams}; /// # async fn example(dataset: &mut Dataset) -> Result<()> { /// let params = ScalarIndexParams::default(); /// dataset @@ -659,7 +663,8 @@ impl DatasetIndexExt for Dataset { /// Create an empty index that will be populated later: /// ``` /// # use lance::{Dataset, Result}; - /// # use lance_index::{DatasetIndexExt, IndexType, scalar::ScalarIndexParams}; + /// # use lance::index::DatasetIndexExt; + /// # use lance_index::{IndexType, scalar::ScalarIndexParams}; /// # async fn example(dataset: &mut Dataset) -> Result<()> { /// let params = ScalarIndexParams::default(); /// dataset diff --git a/rust/lance/src/index/api.rs b/rust/lance/src/index/api.rs new file mode 100644 index 00000000000..f8e7ee7d012 --- /dev/null +++ b/rust/lance/src/index/api.rs @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +use async_trait::async_trait; +use datafusion::execution::SendableRecordBatchStream; +use lance_index::{IndexParams, IndexType, optimize::OptimizeOptions}; +use lance_table::format::IndexMetadata; +use roaring::RoaringBitmap; +use uuid::Uuid; + +use crate::{Error, Result}; + +/// A single physical segment of a logical index. +/// +/// Each segment is stored independently and will become one manifest entry when committed. +/// The logical index identity (name / target column / dataset version) is provided separately +/// by the commit API. +#[derive(Debug, Clone, PartialEq)] +pub struct IndexSegment { + /// Unique ID of the physical segment. + uuid: Uuid, + /// The fragments covered by this segment. + fragment_bitmap: RoaringBitmap, + /// Metadata specific to the index type. + index_details: Arc, + /// The on-disk index version for this segment. + index_version: i32, +} + +impl IndexSegment { + /// Create a fully described segment with the given UUID, fragment coverage, and index + /// metadata. + pub fn new( + uuid: Uuid, + fragment_bitmap: I, + index_details: Arc, + index_version: i32, + ) -> Self + where + I: IntoIterator, + { + Self { + uuid, + fragment_bitmap: fragment_bitmap.into_iter().collect(), + index_details, + index_version, + } + } + + /// Return the UUID of this segment. + pub fn uuid(&self) -> Uuid { + self.uuid + } + + /// Return the fragment coverage of this segment. + pub fn fragment_bitmap(&self) -> &RoaringBitmap { + &self.fragment_bitmap + } + + /// Return the serialized index details for this segment. + pub fn index_details(&self) -> &Arc { + &self.index_details + } + + /// Return the on-disk index version for this segment. + pub fn index_version(&self) -> i32 { + self.index_version + } + + /// Consume the segment and return its component parts. + pub fn into_parts(self) -> (Uuid, RoaringBitmap, Arc, i32) { + ( + self.uuid, + self.fragment_bitmap, + self.index_details, + self.index_version, + ) + } +} + +/// A plan for building one physical segment from one or more existing +/// vector index segments. +#[derive(Debug, Clone, PartialEq)] +pub struct IndexSegmentPlan { + segment: IndexSegment, + segments: Vec, + estimated_bytes: u64, + requested_index_type: Option, +} + +impl IndexSegmentPlan { + /// Create a plan for one built segment. + pub fn new( + segment: IndexSegment, + segments: Vec, + estimated_bytes: u64, + requested_index_type: Option, + ) -> Self { + Self { + segment, + segments, + estimated_bytes, + requested_index_type, + } + } + + /// Return the segment metadata that should be committed after this plan is built. + pub fn segment(&self) -> &IndexSegment { + &self.segment + } + + /// Return the input segment metadata that should be combined into the segment. + pub fn segments(&self) -> &[IndexMetadata] { + &self.segments + } + + /// Return the estimated number of bytes covered by this plan. + pub fn estimated_bytes(&self) -> u64 { + self.estimated_bytes + } + + /// Return the requested logical index type, if one was supplied to the planner. + pub fn requested_index_type(&self) -> Option { + self.requested_index_type + } +} + +/// Extends [`crate::Dataset`] with secondary index APIs. +#[async_trait] +pub trait DatasetIndexExt { + type IndexBuilder<'a> + where + Self: 'a; + type IndexSegmentBuilder<'a> + where + Self: 'a; + + /// Create a builder for creating an index on columns. + /// + /// This returns a builder that can be configured with additional options + /// like `name()`, `replace()`, and `train()` before awaiting to execute. + fn create_index_builder<'a>( + &'a mut self, + columns: &'a [&'a str], + index_type: IndexType, + params: &'a dyn IndexParams, + ) -> Self::IndexBuilder<'a>; + + /// Create a builder for building physical index segments from uncommitted + /// vector index outputs. + /// + /// The caller supplies the uncommitted index metadata returned by + /// `execute_uncommitted()` so the builder can plan segment grouping without + /// rediscovering fragment coverage. + /// + /// This is the canonical entry point for distributed vector segment build. + /// After building the physical segments, publish them as a + /// logical index with [`Self::commit_existing_index_segments`]. + fn create_index_segment_builder<'a>(&'a self) -> Self::IndexSegmentBuilder<'a>; + + /// Create indices on columns. + /// + /// Upon finish, a new dataset version is generated. + async fn create_index( + &mut self, + columns: &[&str], + index_type: IndexType, + name: Option, + params: &dyn IndexParams, + replace: bool, + ) -> Result; + + /// Drop indices by name. + /// + /// Upon finish, a new dataset version is generated. + async fn drop_index(&mut self, name: &str) -> Result<()>; + + /// Prewarm an index by name. + /// + /// This will load the index into memory and cache it. + async fn prewarm_index(&self, name: &str) -> Result<()>; + + /// Read all indices of this Dataset version. + /// + /// The indices are lazy loaded and cached in memory within the `Dataset` instance. + /// The cache is invalidated when the dataset version (Manifest) is changed. + async fn load_indices(&self) -> Result>>; + + /// Loads all the indices of a given UUID. + /// + /// Note that it is possible to have multiple indices with the same UUID, + /// as they are the deltas of the same index. + async fn load_index(&self, uuid: &str) -> Result> { + self.load_indices().await.map(|indices| { + indices + .iter() + .find(|idx| idx.uuid.to_string() == uuid) + .cloned() + }) + } + + /// Loads a specific index with the given index name. + /// + /// Returns `Ok(vec![])` if the index does not exist. + async fn load_indices_by_name(&self, name: &str) -> Result> { + self.load_indices().await.map(|indices| { + indices + .iter() + .filter(|idx| idx.name == name) + .cloned() + .collect() + }) + } + + /// Loads a specific index with the given index name. + /// This function only works for indices that are unique. + /// If there are multiple indices sharing the same name, please use [`Self::load_indices_by_name`]. + async fn load_index_by_name(&self, name: &str) -> Result> { + let indices = self.load_indices_by_name(name).await?; + if indices.is_empty() { + Ok(None) + } else if indices.len() == 1 { + Ok(Some(indices[0].clone())) + } else { + Err(Error::index(format!( + "Found multiple indices of the same name: {:?}, please use load_indices_by_name", + indices.iter().map(|idx| &idx.name).collect::>() + ))) + } + } + + /// Describes indexes in a dataset. + /// + /// This method should only access the index metadata and should not load the index into memory. + async fn describe_indices<'a, 'b>( + &'a self, + criteria: Option>, + ) -> Result>>; + + /// Loads a specific scalar index using the provided criteria. + async fn load_scalar_index<'a, 'b>( + &'a self, + criteria: lance_index::IndexCriteria<'b>, + ) -> Result>; + + /// Optimize indices. + async fn optimize_indices(&mut self, options: &OptimizeOptions) -> Result<()>; + + /// Find an index with the given name and return its serialized statistics. + async fn index_statistics(&self, index_name: &str) -> Result; + + /// Commit one or more existing physical index segments as a logical index. + async fn commit_existing_index_segments( + &mut self, + index_name: &str, + column: &str, + segments: Vec, + ) -> Result<()>; + + async fn read_index_partition( + &self, + index_name: &str, + partition_id: usize, + with_vector: bool, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::{IndexSegment, IndexSegmentPlan}; + use lance_index::IndexType; + use uuid::Uuid; + + #[test] + fn test_index_segment_plan_accessors() { + let uuid = Uuid::new_v4(); + let segment = IndexSegment::new(uuid, [1_u32, 3], Arc::new(prost_types::Any::default()), 7); + let plan = IndexSegmentPlan::new(segment.clone(), vec![], 128, Some(IndexType::BTree)); + + assert_eq!(segment.uuid(), uuid); + assert_eq!( + segment.fragment_bitmap().iter().collect::>(), + vec![1, 3] + ); + assert_eq!(segment.index_version(), 7); + assert_eq!(plan.segment().uuid(), uuid); + assert_eq!(plan.estimated_bytes(), 128); + assert_eq!(plan.requested_index_type(), Some(IndexType::BTree)); + } +} diff --git a/rust/lance/src/index/append.rs b/rust/lance/src/index/append.rs index 01cc6ae0321..a203e555c04 100644 --- a/rust/lance/src/index/append.rs +++ b/rust/lance/src/index/append.rs @@ -320,6 +320,7 @@ pub async fn merge_indices_with_unindexed_frags<'a>( mod tests { use super::*; + use crate::index::DatasetIndexExt; use arrow::datatypes::{Float32Type, UInt32Type}; use arrow_array::cast::AsArray; use arrow_array::{ @@ -334,7 +335,7 @@ mod tests { use lance_index::vector::hnsw::builder::HnswBuildParams; use lance_index::vector::sq::builder::SQBuildParams; use lance_index::{ - DatasetIndexExt, IndexType, + IndexType, scalar::ScalarIndexParams, vector::{ivf::IvfBuildParams, pq::PQBuildParams}, }; diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index a394f52258e..0bf9fdd283c 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -20,7 +20,7 @@ use crate::{ use futures::future::{BoxFuture, try_join_all}; use lance_core::datatypes::format_field_path; use lance_index::progress::{IndexBuildProgress, NoopIndexBuildProgress}; -use lance_index::{IndexParams, IndexSegment, IndexSegmentPlan, IndexType, scalar::CreatedIndex}; +use lance_index::{IndexParams, IndexType, scalar::CreatedIndex}; use lance_index::{ metrics::NoOpMetricsCollector, scalar::{LANCE_SCALAR_INDEX, ScalarIndexParams, inverted::tokenizer::InvertedIndexParams}, @@ -32,6 +32,8 @@ use uuid::Uuid; use arrow_array::RecordBatchReader; +use super::{IndexSegment, IndexSegmentPlan}; + /// Generate default index name from field path. /// /// Joins field names with `.` to create the base index name. diff --git a/rust/lance/src/index/frag_reuse.rs b/rust/lance/src/index/frag_reuse.rs index e9e66f972b6..e5f63514d86 100644 --- a/rust/lance/src/index/frag_reuse.rs +++ b/rust/lance/src/index/frag_reuse.rs @@ -3,8 +3,8 @@ use crate::Dataset; use crate::dataset::optimize::remapping::transpose_row_ids_from_digest; +use crate::index::DatasetIndexExt; use lance_core::Error; -use lance_index::DatasetIndexExt; use lance_index::frag_reuse::{ FRAG_REUSE_DETAILS_FILE_NAME, FRAG_REUSE_INDEX_NAME, FragReuseGroup, FragReuseIndex, FragReuseIndexDetails, FragReuseVersion, diff --git a/rust/lance/src/index/mem_wal.rs b/rust/lance/src/index/mem_wal.rs index c979ab42fe2..66d3a9aaca2 100644 --- a/rust/lance/src/index/mem_wal.rs +++ b/rust/lance/src/index/mem_wal.rs @@ -122,9 +122,9 @@ mod tests { use std::sync::Arc; + use crate::index::DatasetIndexExt; use arrow_array::{Int32Array, RecordBatch}; use arrow_schema::{DataType, Field, Schema}; - use lance_index::DatasetIndexExt; use crate::dataset::transaction::{Operation, Transaction}; use crate::dataset::{CommitBuilder, InsertBuilder, WriteParams}; diff --git a/rust/lance/src/index/scalar.rs b/rust/lance/src/index/scalar.rs index f2c9cfd8969..44739454bec 100644 --- a/rust/lance/src/index/scalar.rs +++ b/rust/lance/src/index/scalar.rs @@ -6,6 +6,7 @@ use std::sync::{Arc, LazyLock}; +use crate::index::DatasetIndexExt; use crate::index::DatasetIndexInternalExt; use crate::session::index_caches::ProstAny; use crate::{ @@ -39,7 +40,7 @@ use lance_index::scalar::{ ScalarIndex, ScalarIndexParams, bitmap::BITMAP_LOOKUP_NAME, inverted::INVERT_LIST_FILE, lance_format::LanceIndexStore, }; -use lance_index::{DatasetIndexExt, IndexCriteria, IndexType}; +use lance_index::{IndexCriteria, IndexType}; use lance_table::format::{Fragment, IndexMetadata}; use log::info; use tracing::instrument; @@ -819,9 +820,9 @@ mod tests { #[tokio::test] async fn test_initialize_scalar_index_btree() { use crate::dataset::Dataset; + use crate::index::DatasetIndexExt; use arrow_array::types::Float32Type; use lance_datagen::{BatchCount, RowCount, array}; - use lance_index::DatasetIndexExt; use lance_index::metrics::NoOpMetricsCollector; use lance_index::scalar::ScalarIndexParams; @@ -925,9 +926,9 @@ mod tests { #[tokio::test] async fn test_optimize_scalar_index_btree() { use crate::dataset::Dataset; + use crate::index::DatasetIndexExt; use arrow_array::types::Float32Type; use lance_datagen::{BatchCount, RowCount, array}; - use lance_index::DatasetIndexExt; use lance_index::metrics::NoOpMetricsCollector; use lance_index::scalar::ScalarIndexParams; @@ -1043,9 +1044,9 @@ mod tests { #[tokio::test] async fn test_initialize_scalar_index_bitmap() { use crate::dataset::Dataset; + use crate::index::DatasetIndexExt; use arrow_array::types::Float32Type; use lance_datagen::{BatchCount, RowCount, array}; - use lance_index::DatasetIndexExt; use lance_index::scalar::ScalarIndexParams; let test_dir = TempStrDir::default(); @@ -1123,8 +1124,8 @@ mod tests { #[tokio::test] async fn test_initialize_scalar_index_inverted() { use crate::dataset::Dataset; + use crate::index::DatasetIndexExt; use lance_datagen::{BatchCount, ByteCount, RowCount, array}; - use lance_index::DatasetIndexExt; use lance_index::metrics::NoOpMetricsCollector; use lance_index::scalar::inverted::tokenizer::InvertedIndexParams; @@ -1262,9 +1263,9 @@ mod tests { #[tokio::test] async fn test_initialize_scalar_index_zonemap() { use crate::dataset::Dataset; + use crate::index::DatasetIndexExt; use arrow_array::types::Float32Type; use lance_datagen::{BatchCount, RowCount, array}; - use lance_index::DatasetIndexExt; use lance_index::metrics::NoOpMetricsCollector; use lance_index::scalar::ScalarIndexParams; use lance_index::scalar::zonemap::ZoneMapIndexBuilderParams; diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index a19f472a9bf..4b5b113cae9 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -47,9 +47,7 @@ use lance_index::vector::{ pq::PQBuildParams, sq::{ScalarQuantizer, builder::SQBuildParams}, }; -use lance_index::{ - DatasetIndexExt, INDEX_AUXILIARY_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, IndexType, -}; +use lance_index::{INDEX_AUXILIARY_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, IndexType}; use lance_io::traits::Reader; use lance_linalg::distance::*; use lance_table::format::{IndexMetadata, list_index_files_with_sizes}; @@ -58,7 +56,7 @@ use tracing::instrument; use utils::get_vector_type; use uuid::Uuid; -use super::{DatasetIndexInternalExt, IndexParams, pb, vector_index_details}; +use super::{DatasetIndexExt, DatasetIndexInternalExt, IndexParams, pb, vector_index_details}; use crate::dataset::index::dataset_format_version; use crate::dataset::transaction::{Operation, Transaction}; use crate::{Error, Result, dataset::Dataset, index::pb::vector_index_stage::Stage}; @@ -1688,6 +1686,7 @@ fn derive_hnsw_params(source_index: &dyn VectorIndex) -> HnswBuildParams { mod tests { use super::*; use crate::dataset::Dataset; + use crate::index::DatasetIndexExt; use arrow_array::Array; use arrow_array::RecordBatch; use arrow_array::types::{Float32Type, Int32Type}; @@ -1695,7 +1694,6 @@ mod tests { use lance_core::utils::tempfile::TempStrDir; use lance_datagen::{BatchCount, RowCount, array}; use lance_file::writer::FileWriterOptions; - use lance_index::DatasetIndexExt; use lance_index::metrics::NoOpMetricsCollector; use lance_linalg::distance::MetricType; diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 8e47b77cdfd..90864154094 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -64,8 +64,7 @@ use lance_index::vector::quantizer::QuantizationType; use lance_index::vector::v3::shuffler::create_ivf_shuffler; use lance_index::vector::v3::subindex::{IvfSubIndex, SubIndexType}; use lance_index::{ - INDEX_AUXILIARY_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, Index, IndexMetadata, IndexSegment, - IndexSegmentPlan, IndexType, + INDEX_AUXILIARY_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, Index, IndexMetadata, IndexType, optimize::OptimizeOptions, vector::{ Query, VectorIndex, @@ -103,6 +102,8 @@ use tokio::sync::mpsc; use tracing::instrument; use uuid::Uuid; +use crate::index::{IndexSegment, IndexSegmentPlan}; + pub mod builder; pub mod io; pub mod v2; diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 92ddea55cbc..8d3b50d58c6 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -626,7 +626,9 @@ mod tests { use lance_index::vector::storage::VectorStore; use crate::dataset::{InsertBuilder, UpdateBuilder, WriteMode, WriteParams}; + use crate::index::DatasetIndexExt; use crate::index::DatasetIndexInternalExt; + use crate::index::IndexSegment; use crate::index::vector::ivf::v2::IvfPq; use crate::index::vector::ivf::{build_segment, plan_segments}; use crate::utils::test::copy_test_data_to_tmp; @@ -644,6 +646,7 @@ mod tests { use lance_encoding::decoder::DecoderPlugins; use lance_file::reader::{FileReader, FileReaderOptions}; use lance_file::writer::FileWriter; + use lance_index::IndexType; use lance_index::vector::DIST_COL; use lance_index::vector::ivf::IvfBuildParams; use lance_index::vector::kmeans::{KMeansParams, train_kmeans}; @@ -653,7 +656,6 @@ mod tests { use lance_index::vector::{ pq::storage::ProductQuantizationMetadata, storage::STORAGE_METADATA_KEY, }; - use lance_index::{DatasetIndexExt, IndexSegment, IndexType}; use lance_index::{INDEX_AUXILIARY_FILE_NAME, metrics::NoOpMetricsCollector}; use lance_index::{optimize::OptimizeOptions, scalar::IndexReader}; use lance_index::{scalar::IndexWriter, vector::hnsw::builder::HnswBuildParams}; diff --git a/rust/lance/src/io/commit.rs b/rust/lance/src/io/commit.rs index 7a7b9a5a77a..2e61054d28f 100644 --- a/rust/lance/src/io/commit.rs +++ b/rust/lance/src/io/commit.rs @@ -48,6 +48,7 @@ use crate::dataset::{ ManifestWriteConfig, NewTransactionResult, TRANSACTIONS_DIR, load_new_transactions, write_manifest_file, }; +use crate::index::DatasetIndexExt; use crate::index::DatasetIndexInternalExt; use crate::io::deletion::read_dataset_deletion_file; use crate::session::Session; @@ -56,7 +57,7 @@ use crate::session::index_caches::IndexMetadataKey; use futures::future::Either; use futures::{StreamExt, TryFutureExt, TryStreamExt}; use lance_core::{Error, Result}; -use lance_index::{DatasetIndexExt, is_system_index}; +use lance_index::is_system_index; use lance_io::object_store::ObjectStoreRegistry; use log; use object_store::path::Path; diff --git a/rust/lance/src/io/commit/conflict_resolver.rs b/rust/lance/src/io/commit/conflict_resolver.rs index 3765b87eec7..8949cb383cc 100644 --- a/rust/lance/src/io/commit/conflict_resolver.rs +++ b/rust/lance/src/io/commit/conflict_resolver.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use crate::index::DatasetIndexExt; use crate::index::frag_reuse::{build_frag_reuse_index_metadata, load_frag_reuse_index_details}; use crate::index::mem_wal::{load_mem_wal_index_details, new_mem_wal_index_meta}; use crate::io::deletion::read_dataset_deletion_file; @@ -14,7 +15,6 @@ use lance_core::{ Error, Result, utils::{deletion::DeletionVector, mask::RowAddrTreeMap}, }; -use lance_index::DatasetIndexExt; use lance_index::frag_reuse::FRAG_REUSE_INDEX_NAME; use lance_index::mem_wal::{MEM_WAL_INDEX_NAME, MergedGeneration}; use lance_table::format::IndexMetadata; diff --git a/rust/lance/src/io/exec/filtered_read.rs b/rust/lance/src/io/exec/filtered_read.rs index 35fd486a283..e3837f9ce4f 100644 --- a/rust/lance/src/io/exec/filtered_read.rs +++ b/rust/lance/src/io/exec/filtered_read.rs @@ -2052,6 +2052,7 @@ impl ExecutionPlan for FilteredReadExec { mod tests { use std::collections::HashSet; + use crate::index::DatasetIndexExt; use arrow::{ compute::concat_batches, datatypes::{Float32Type, UInt32Type, UInt64Type}, @@ -2064,7 +2065,7 @@ mod tests { use lance_core::utils::tempfile::TempStrDir; use lance_datagen::{BatchCount, Dimension, RowCount, array, gen_batch}; use lance_index::{ - DatasetIndexExt, IndexType, + IndexType, optimize::OptimizeOptions, scalar::{ScalarIndexParams, expression::PlannerIndexExt}, }; diff --git a/rust/lance/src/io/exec/fts.rs b/rust/lance/src/io/exec/fts.rs index 6b2293d3b13..b40ad145802 100644 --- a/rust/lance/src/io/exec/fts.rs +++ b/rust/lance/src/io/exec/fts.rs @@ -25,7 +25,9 @@ use lance_datafusion::utils::{ExecutionPlanMetricsSetExt, MetricsExt, PARTITIONS use super::PreFilterSource; use super::utils::{IndexMetrics, InstrumentedRecordBatchStreamAdapter, build_prefilter}; +use crate::index::DatasetIndexExt; use crate::{Dataset, index::DatasetIndexInternalExt}; +use lance_index::IndexCriteria; use lance_index::metrics::MetricsCollector; use lance_index::scalar::inverted::builder::document_input; use lance_index::scalar::inverted::lance_tokenizer::{DocType, JsonTokenizer, LanceTokenizer}; @@ -37,7 +39,6 @@ use lance_index::scalar::inverted::tokenizer::lance_tokenizer::TextTokenizer; use lance_index::scalar::inverted::{ FTS_SCHEMA, InvertedIndex, SCORE_COL, flat_bm25_search_stream, }; -use lance_index::{DatasetIndexExt, IndexCriteria}; use lance_index::{prefilter::PreFilter, scalar::inverted::query::BooleanQuery}; use tracing::instrument; @@ -1397,6 +1398,7 @@ impl ExecutionPlan for BooleanQueryExec { pub mod tests { use std::sync::{Arc, Mutex}; + use crate::index::DatasetIndexExt; use datafusion::{execution::TaskContext, physical_plan::ExecutionPlan}; use lance_datafusion::datagen::DatafusionDatagenExt; use lance_datafusion::exec::{ExecutionStatsCallback, ExecutionSummaryCounts}; @@ -1409,7 +1411,7 @@ pub mod tests { PhraseQuery, }; use lance_index::scalar::{FullTextSearchQuery, InvertedIndexParams}; - use lance_index::{DatasetIndexExt, IndexCriteria, IndexType}; + use lance_index::{IndexCriteria, IndexType}; use crate::{ index::DatasetIndexInternalExt, diff --git a/rust/lance/src/io/exec/knn.rs b/rust/lance/src/io/exec/knn.rs index ac512aeba67..1e527a0c4f6 100644 --- a/rust/lance/src/io/exec/knn.rs +++ b/rust/lance/src/io/exec/knn.rs @@ -1349,6 +1349,7 @@ impl ExecutionPlan for MultivectorScoringExec { mod tests { use super::*; + use crate::index::DatasetIndexExt; use arrow::compute::{concat_batches, sort_to_indices, take_record_batch}; use arrow::datatypes::Float32Type; use arrow_array::{ @@ -1359,10 +1360,10 @@ mod tests { use lance_datafusion::exec::{ExecutionStatsCallback, ExecutionSummaryCounts}; use lance_datafusion::utils::FIND_PARTITIONS_ELAPSED_METRIC; use lance_datagen::{BatchCount, RowCount, array}; + use lance_index::IndexType; use lance_index::optimize::OptimizeOptions; use lance_index::vector::ivf::IvfBuildParams; use lance_index::vector::pq::PQBuildParams; - use lance_index::{DatasetIndexExt, IndexType}; use lance_linalg::distance::MetricType; use lance_testing::datagen::generate_random_array; use rstest::rstest; diff --git a/rust/lance/src/io/exec/scalar_index.rs b/rust/lance/src/io/exec/scalar_index.rs index aafd3c8d2eb..f587ec22a91 100644 --- a/rust/lance/src/io/exec/scalar_index.rs +++ b/rust/lance/src/io/exec/scalar_index.rs @@ -7,7 +7,7 @@ use super::utils::{IndexMetrics, InstrumentedRecordBatchStreamAdapter}; use crate::{ Dataset, dataset::rowids::load_row_id_sequences, - index::{DatasetIndexInternalExt, prefilter::DatasetPreFilter}, + index::{DatasetIndexExt, DatasetIndexInternalExt, prefilter::DatasetPreFilter}, }; use arrow_array::{Array, RecordBatch, UInt64Array}; use arrow_schema::{Schema, SchemaRef}; @@ -40,7 +40,7 @@ use lance_datafusion::{ }, }; use lance_index::{ - DatasetIndexExt, IndexCriteria, + IndexCriteria, metrics::MetricsCollector, scalar::{ SargableQuery, ScalarIndex, @@ -733,6 +733,7 @@ impl ExecutionPlan for MaterializeIndexExec { mod tests { use std::{ops::Bound, sync::Arc}; + use crate::index::DatasetIndexExt; use arrow::datatypes::UInt64Type; use datafusion::{ execution::TaskContext, physical_plan::ExecutionPlan, prelude::SessionConfig, @@ -742,7 +743,7 @@ mod tests { use lance_core::utils::tempfile::TempStrDir; use lance_datagen::gen_batch; use lance_index::{ - DatasetIndexExt, IndexType, + IndexType, scalar::{ SargableQuery, ScalarIndexParams, expression::{ScalarIndexExpr, ScalarIndexSearch}, diff --git a/rust/lance/src/session/index_extension.rs b/rust/lance/src/session/index_extension.rs index b500d52086c..2055f64e340 100644 --- a/rust/lance/src/session/index_extension.rs +++ b/rust/lance/src/session/index_extension.rs @@ -65,6 +65,7 @@ mod test { sync::{Arc, atomic::AtomicBool}, }; + use crate::index::DatasetIndexExt; use arrow_array::{Float32Array, RecordBatch, UInt32Array}; use arrow_schema::Schema; use datafusion::execution::SendableRecordBatchStream; @@ -75,8 +76,7 @@ mod test { use lance_file::version::LanceFileVersion; use lance_index::vector::v3::subindex::SubIndexType; use lance_index::{ - DatasetIndexExt, INDEX_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, Index, IndexMetadata, - IndexType, + INDEX_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, Index, IndexMetadata, IndexType, vector::{Query, hnsw::VECTOR_ID_FIELD}, }; use lance_index::{ diff --git a/rust/lance/tests/query/inverted.rs b/rust/lance/tests/query/inverted.rs index 63b36b6a823..c9ce1231d92 100644 --- a/rust/lance/tests/query/inverted.rs +++ b/rust/lance/tests/query/inverted.rs @@ -7,9 +7,10 @@ use arrow_array::{ArrayRef, Int32Array, RecordBatch, StringArray, UInt32Array}; use lance::Dataset; use lance::dataset::scanner::ColumnOrdering; use lance::dataset::{InsertBuilder, WriteParams}; +use lance::index::DatasetIndexExt; +use lance_index::IndexType; use lance_index::scalar::inverted::query::{FtsQuery, PhraseQuery}; use lance_index::scalar::{FullTextSearchQuery, InvertedIndexParams}; -use lance_index::{DatasetIndexExt, IndexType}; use tantivy::tokenizer::Language; use super::{strip_score_column, test_fts, test_scan, test_take}; diff --git a/rust/lance/tests/query/primitives.rs b/rust/lance/tests/query/primitives.rs index 6c72f66bd23..65fa6f4e4d3 100644 --- a/rust/lance/tests/query/primitives.rs +++ b/rust/lance/tests/query/primitives.rs @@ -13,8 +13,9 @@ use lance::Dataset; use lance::dataset::WriteParams; use lance::dataset::optimize::{CompactionOptions, compact_files}; +use lance::index::DatasetIndexExt; use lance_datagen::{ArrayGeneratorExt, RowCount, array, gen_batch}; -use lance_index::{DatasetIndexExt, IndexType}; +use lance_index::IndexType; use super::{test_filter, test_scan, test_take}; use crate::utils::DatasetTestCases; diff --git a/rust/lance/tests/utils/mod.rs b/rust/lance/tests/utils/mod.rs index c407c62c03b..b8a034a50b0 100644 --- a/rust/lance/tests/utils/mod.rs +++ b/rust/lance/tests/utils/mod.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use arrow_array::{ArrayRef, Int32Array, RecordBatch}; use futures::FutureExt; +use lance::index::DatasetIndexExt; use lance::index::vector::VectorIndexParams; use lance::{ Dataset, @@ -17,7 +18,7 @@ use lance_index::vector::hnsw::builder::HnswBuildParams; use lance_index::vector::ivf::IvfBuildParams; use lance_index::vector::pq::PQBuildParams; use lance_index::vector::sq::builder::SQBuildParams; -use lance_index::{DatasetIndexExt, IndexParams, IndexType}; +use lance_index::{IndexParams, IndexType}; use lance_linalg::distance::{DistanceType, MetricType}; #[derive(Clone, Copy, Debug)] From c54f529b901cf12e42f8cf2a1ff990aa1d9edeb7 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 26 Mar 2026 19:28:55 +0800 Subject: [PATCH 081/206] fix: restore namespace build after DatasetIndexExt move (#6302) This fixes the main-branch CI failure introduced after #6280 moved `DatasetIndexExt` into `lance`. `rust/lance-namespace-impls/src/dir.rs` still imported and referenced the old `lance_index::DatasetIndexExt` path, which broke the Java JNI workflow when it compiled the namespace implementation. --- rust/lance-namespace-impls/src/dir.rs | 27 +++++++++---------- .../lance/benches/distributed_vector_build.rs | 4 +-- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index 7718fc95a01..ba01c88eab2 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -16,14 +16,14 @@ use futures::TryStreamExt; use lance::dataset::builder::DatasetBuilder; use lance::dataset::transaction::{Operation, Transaction}; use lance::dataset::{Dataset, WriteMode, WriteParams}; -use lance::index::{IndexParams, vector::VectorIndexParams}; +use lance::index::{DatasetIndexExt, IndexParams, vector::VectorIndexParams}; use lance::session::Session; use lance_index::scalar::{BuiltinIndexType, InvertedIndexParams, ScalarIndexParams}; use lance_index::vector::{ bq::RQBuildParams, hnsw::builder::HnswBuildParams, ivf::IvfBuildParams, pq::PQBuildParams, sq::builder::SQBuildParams, }; -use lance_index::{DatasetIndexExt, IndexType, is_system_index}; +use lance_index::{IndexType, is_system_index}; use lance_io::object_store::{ObjectStore, ObjectStoreParams, ObjectStoreRegistry}; use lance_linalg::distance::MetricType; use lance_table::io::commit::ManifestNamingScheme; @@ -2554,18 +2554,17 @@ impl LanceNamespace for DirectoryNamespace { )); } - let stats = - ::index_statistics(&dataset, index_name) - .await - .map_err(|e| { - Error::namespace_source( - format!( - "Failed to describe index statistics for '{}' on table '{}': {}", - index_name, table_uri, e - ) - .into(), + let stats = ::index_statistics(&dataset, index_name) + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to describe index statistics for '{}' on table '{}': {}", + index_name, table_uri, e ) - })?; + .into(), + ) + })?; let stats: serde_json::Value = serde_json::from_str(&stats).map_err(|e| { Error::namespace_source( format!( @@ -2700,8 +2699,8 @@ mod tests { use super::*; use arrow_ipc::reader::StreamReader; use lance::dataset::Dataset; + use lance::index::DatasetIndexExt; use lance_core::utils::tempfile::{TempStdDir, TempStrDir}; - use lance_index::DatasetIndexExt; use lance_namespace::models::{ CreateTableRequest, JsonArrowDataType, JsonArrowField, JsonArrowSchema, ListTablesRequest, }; diff --git a/rust/lance/benches/distributed_vector_build.rs b/rust/lance/benches/distributed_vector_build.rs index 831ee8c8299..5a9b2d70602 100644 --- a/rust/lance/benches/distributed_vector_build.rs +++ b/rust/lance/benches/distributed_vector_build.rs @@ -15,11 +15,11 @@ use serde::Serialize; use uuid::Uuid; use lance::dataset::{Dataset, WriteMode, WriteParams}; -use lance::index::vector::VectorIndexParams; +use lance::index::{DatasetIndexExt, vector::VectorIndexParams}; use lance_arrow::FixedSizeListArrayExt; use lance_index::vector::kmeans::{KMeansParams, train_kmeans}; use lance_index::{ - DatasetIndexExt, IndexType, + IndexType, vector::{ivf::IvfBuildParams, pq::PQBuildParams}, }; use lance_linalg::distance::DistanceType; From 3a826489945f763b7af6ca18cb84bdb16984311c Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Thu, 26 Mar 2026 11:46:40 -0500 Subject: [PATCH 082/206] feat: actually using namespace error variants (#6275) In the rust SDK we have a `NamespaceError` that contains a message and an error code. Additionally, in both the Java and python SDKs we have logic to parse this type (and error codes) to produce native Java exceptions. The problem, is that in the `namespace-impls` we are never using them. Therefore the rust `NamespaceError` type is never used and the Java / python SDK conversion code the parse and throw native errors is essentially dead code. In this PR, we update the `namespace-impls` to actually throw the correct error types. Closes https://github.com/lance-format/lance/issues/6240 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- java/lance-jni/src/error.rs | 64 +- java/lance-jni/src/namespace.rs | 26 +- .../org/lance/NamespaceIntegrationTest.java | 3 +- .../namespace/DirectoryNamespaceTest.java | 12 +- python/python/tests/test_namespace_dir.py | 45 +- rust/lance-namespace-impls/src/connect.rs | 21 +- rust/lance-namespace-impls/src/credentials.rs | 20 +- .../src/credentials/aws.rs | 62 +- .../src/credentials/azure.rs | 202 ++--- .../src/credentials/gcp.rs | 137 ++-- rust/lance-namespace-impls/src/dir.rs | 590 +++++++------- .../lance-namespace-impls/src/dir/manifest.rs | 727 +++++++++--------- rust/lance-namespace-impls/src/rest.rs | 254 ++++-- .../lance-namespace-impls/src/rest_adapter.rs | 73 +- rust/lance-namespace/src/error.rs | 32 + 15 files changed, 1269 insertions(+), 999 deletions(-) diff --git a/java/lance-jni/src/error.rs b/java/lance-jni/src/error.rs index 8518dcf55be..e02203a9567 100644 --- a/java/lance-jni/src/error.rs +++ b/java/lance-jni/src/error.rs @@ -120,60 +120,29 @@ impl Error { env: &mut JNIEnv, code: u32, ) -> std::result::Result<(), ()> { - // Try to find and call the LanceNamespaceException constructor - // that takes ErrorCode and message - let class_name = "org/lance/namespace/errors/LanceNamespaceException"; - let error_code_class = "org/lance/namespace/errors/ErrorCode"; + // Use ErrorFactory.fromErrorCode(code, message) to get the specific exception subclass + // (e.g., TableNotFoundException, NamespaceNotFoundException, etc.) + let factory_class = "org/lance/namespace/errors/ErrorFactory"; - // Find the ErrorCode.fromCode method - let error_code_cls = env.find_class(error_code_class).map_err(|_| ())?; - let from_code_method = env + let factory_cls = env.find_class(factory_class).map_err(|_| ())?; + let from_error_code_method = env .get_static_method_id( - &error_code_cls, - "fromCode", - "(I)Lorg/lance/namespace/errors/ErrorCode;", + &factory_cls, + "fromErrorCode", + "(ILjava/lang/String;)Lorg/lance/namespace/errors/LanceNamespaceException;", ) .map_err(|_| ())?; - let error_code_obj = unsafe { - env.call_static_method_unchecked( - &error_code_cls, - from_code_method, - jni::signature::ReturnType::Object, - &[jni::sys::jvalue { - i: code as jni::sys::jint, - }], - ) - } - .map_err(|_| ())?; - - let error_code = match error_code_obj { - jni::objects::JValueGen::Object(obj) => obj, - _ => return Err(()), - }; - - // Find the LanceNamespaceException class - let exception_cls = env.find_class(class_name).map_err(|_| ())?; - // Create message JString let message_str = env.new_string(&self.message).map_err(|_| ())?; - // Find constructor (ErrorCode, String) - let constructor = env - .get_method_id( - &exception_cls, - "", - "(Lorg/lance/namespace/errors/ErrorCode;Ljava/lang/String;)V", - ) - .map_err(|_| ())?; - - // Create the exception object let exception_obj = unsafe { - env.new_object_unchecked( - &exception_cls, - constructor, + env.call_static_method_unchecked( + &factory_cls, + from_error_code_method, + jni::signature::ReturnType::Object, &[ jni::sys::jvalue { - l: error_code.as_raw(), + i: code as jni::sys::jint, }, jni::sys::jvalue { l: message_str.as_raw(), @@ -183,8 +152,13 @@ impl Error { } .map_err(|_| ())?; + let exception = match exception_obj { + jni::objects::JValueGen::Object(obj) => obj, + _ => return Err(()), + }; + // Throw the exception - env.throw(jni::objects::JThrowable::from(exception_obj)) + env.throw(jni::objects::JThrowable::from(exception)) .map_err(|_| ())?; Ok(()) diff --git a/java/lance-jni/src/namespace.rs b/java/lance-jni/src/namespace.rs index 856904f343a..fa5a67437c7 100644 --- a/java/lance-jni/src/namespace.rs +++ b/java/lance-jni/src/namespace.rs @@ -2694,8 +2694,7 @@ where let request: Req = serde_json::from_str(&request_str) .map_err(|e| Error::input_error(format!("Failed to parse request JSON: {}", e)))?; - let response = f(namespace, request) - .map_err(|e| Error::runtime_error(format!("Namespace operation failed: {}", e)))?; + let response = f(namespace, request).map_err(Error::from)?; let response_json = serde_json::to_string(&response) .map_err(|e| Error::runtime_error(format!("Failed to serialize response: {}", e)))?; @@ -2719,8 +2718,7 @@ where let request: Req = serde_json::from_str(&request_str) .map_err(|e| Error::input_error(format!("Failed to parse request JSON: {}", e)))?; - f(namespace, request) - .map_err(|e| Error::runtime_error(format!("Namespace operation failed: {}", e)))?; + f(namespace, request).map_err(Error::from)?; Ok(()) } @@ -2738,7 +2736,7 @@ fn call_namespace_count_method( let count = RT .block_on(namespace.inner.count_table_rows(request)) - .map_err(|e| Error::runtime_error(format!("Count table rows failed: {}", e)))?; + .map_err(Error::from)?; Ok(count) } @@ -2764,8 +2762,7 @@ where let data_vec = env.convert_byte_array(request_data)?; let data = bytes::Bytes::from(data_vec); - let response = f(namespace, request, data) - .map_err(|e| Error::runtime_error(format!("Namespace operation failed: {}", e)))?; + let response = f(namespace, request, data).map_err(Error::from)?; let response_json = serde_json::to_string(&response) .map_err(|e| Error::runtime_error(format!("Failed to serialize response: {}", e)))?; @@ -2786,7 +2783,7 @@ fn call_namespace_query_method<'local>( let result_bytes = RT .block_on(namespace.inner.query_table(request)) - .map_err(|e| Error::runtime_error(format!("Query table failed: {}", e)))?; + .map_err(Error::from)?; let byte_array = env.byte_array_from_slice(&result_bytes)?; Ok(byte_array) @@ -2809,8 +2806,7 @@ where let request: Req = serde_json::from_str(&request_str) .map_err(|e| Error::input_error(format!("Failed to parse request JSON: {}", e)))?; - let response = f(namespace, request) - .map_err(|e| Error::runtime_error(format!("Namespace operation failed: {}", e)))?; + let response = f(namespace, request).map_err(Error::from)?; let response_json = serde_json::to_string(&response) .map_err(|e| Error::runtime_error(format!("Failed to serialize response: {}", e)))?; @@ -2834,8 +2830,7 @@ where let request: Req = serde_json::from_str(&request_str) .map_err(|e| Error::input_error(format!("Failed to parse request JSON: {}", e)))?; - f(namespace, request) - .map_err(|e| Error::runtime_error(format!("Namespace operation failed: {}", e)))?; + f(namespace, request).map_err(Error::from)?; Ok(()) } @@ -2853,7 +2848,7 @@ fn call_rest_namespace_count_method( let count = RT .block_on(namespace.inner.count_table_rows(request)) - .map_err(|e| Error::runtime_error(format!("Count table rows failed: {}", e)))?; + .map_err(Error::from)?; Ok(count) } @@ -2879,8 +2874,7 @@ where let data_vec = env.convert_byte_array(request_data)?; let data = bytes::Bytes::from(data_vec); - let response = f(namespace, request, data) - .map_err(|e| Error::runtime_error(format!("Namespace operation failed: {}", e)))?; + let response = f(namespace, request, data).map_err(Error::from)?; let response_json = serde_json::to_string(&response) .map_err(|e| Error::runtime_error(format!("Failed to serialize response: {}", e)))?; @@ -2901,7 +2895,7 @@ fn call_rest_namespace_query_method<'local>( let result_bytes = RT .block_on(namespace.inner.query_table(request)) - .map_err(|e| Error::runtime_error(format!("Query table failed: {}", e)))?; + .map_err(Error::from)?; let byte_array = env.byte_array_from_slice(&result_bytes)?; Ok(byte_array) diff --git a/java/src/test/java/org/lance/NamespaceIntegrationTest.java b/java/src/test/java/org/lance/NamespaceIntegrationTest.java index af24279229c..dd2ad7539f3 100644 --- a/java/src/test/java/org/lance/NamespaceIntegrationTest.java +++ b/java/src/test/java/org/lance/NamespaceIntegrationTest.java @@ -16,6 +16,7 @@ import org.lance.namespace.DirectoryNamespace; import org.lance.namespace.LanceNamespace; import org.lance.namespace.LanceNamespaceStorageOptionsProvider; +import org.lance.namespace.errors.LanceNamespaceException; import org.lance.namespace.model.CreateNamespaceRequest; import org.lance.namespace.model.CreateTableRequest; import org.lance.namespace.model.CreateTableResponse; @@ -1548,7 +1549,7 @@ void testBasicCreateAndDropOnS3() throws Exception { assertNotNull(dropResp); TableExistsRequest existsReq = new TableExistsRequest().id(tableId); - assertThrows(RuntimeException.class, () -> namespace.tableExists(existsReq)); + assertThrows(LanceNamespaceException.class, () -> namespace.tableExists(existsReq)); } finally { namespace.close(); } diff --git a/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java b/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java index fa1fa8200d6..edaaa9c28bc 100644 --- a/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java +++ b/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java @@ -20,6 +20,8 @@ import org.lance.ReadOptions; import org.lance.Transaction; import org.lance.WriteParams; +import org.lance.namespace.errors.ErrorCode; +import org.lance.namespace.errors.LanceNamespaceException; import org.lance.namespace.model.*; import org.lance.namespace.model.DescribeTableVersionRequest; import org.lance.namespace.model.DescribeTableVersionResponse; @@ -176,7 +178,9 @@ void testNamespaceExists() { // Check non-existent namespace NamespaceExistsRequest notExistsReq = new NamespaceExistsRequest().id(Arrays.asList("nonexistent")); - assertThrows(RuntimeException.class, () -> namespace.namespaceExists(notExistsReq)); + LanceNamespaceException ex = + assertThrows(LanceNamespaceException.class, () -> namespace.namespaceExists(notExistsReq)); + assertEquals(ErrorCode.NAMESPACE_NOT_FOUND, ex.getErrorCode()); } @Test @@ -192,7 +196,7 @@ void testDropNamespace() { // Verify it's gone NamespaceExistsRequest existsReq = new NamespaceExistsRequest().id(Arrays.asList("workspace")); - assertThrows(RuntimeException.class, () -> namespace.namespaceExists(existsReq)); + assertThrows(LanceNamespaceException.class, () -> namespace.namespaceExists(existsReq)); } @Test @@ -280,7 +284,7 @@ void testTableExists() throws Exception { // Check non-existent table TableExistsRequest notExistsReq = new TableExistsRequest().id(Arrays.asList("workspace", "nonexistent")); - assertThrows(RuntimeException.class, () -> namespace.tableExists(notExistsReq)); + assertThrows(LanceNamespaceException.class, () -> namespace.tableExists(notExistsReq)); } @Test @@ -304,7 +308,7 @@ void testDropTable() throws Exception { // Verify it's gone TableExistsRequest existsReq = new TableExistsRequest().id(Arrays.asList("workspace", "test_table")); - assertThrows(RuntimeException.class, () -> namespace.tableExists(existsReq)); + assertThrows(LanceNamespaceException.class, () -> namespace.tableExists(existsReq)); } @Test diff --git a/python/python/tests/test_namespace_dir.py b/python/python/tests/test_namespace_dir.py index d1200d4a6c8..49523a58e81 100644 --- a/python/python/tests/test_namespace_dir.py +++ b/python/python/tests/test_namespace_dir.py @@ -41,6 +41,12 @@ TableExistsRequest, connect, ) +from lance_namespace.errors import ( + InvalidInputError, + NamespaceNotEmptyError, + NamespaceNotFoundError, + TableNotFoundError, +) def create_test_data(): @@ -116,7 +122,7 @@ def test_create_table_without_data(self, memory_namespace): create_req = CreateTableRequest(id=["workspace", "test_table"]) - with pytest.raises(Exception) as exc_info: + with pytest.raises(InvalidInputError) as exc_info: memory_namespace.create_table(create_req, b"") assert "Arrow IPC" in str(exc_info.value) or "required" in str(exc_info.value) @@ -128,7 +134,7 @@ def test_create_table_with_invalid_id(self, memory_namespace): # Test with empty ID create_req = CreateTableRequest(id=[]) - with pytest.raises(Exception): + with pytest.raises(InvalidInputError): memory_namespace.create_table(create_req, ipc_data) def test_create_table_in_child_namespace(self, memory_namespace): @@ -233,13 +239,9 @@ def test_describe_nonexistent_table(self, memory_namespace): describe_req = DescribeTableRequest(id=["workspace", "nonexistent"]) - with pytest.raises(Exception) as exc_info: + with pytest.raises(TableNotFoundError): memory_namespace.describe_table(describe_req) - # Check for error message indicating table doesn't exist - error_msg = str(exc_info.value).lower() - assert "not found" in error_msg or "does not exist" in error_msg - class TestTableOperations: """Tests for various table operations.""" @@ -261,14 +263,14 @@ def test_table_exists(self, memory_namespace): memory_namespace.table_exists(exists_req) def test_table_not_exists(self, memory_namespace): - """Test checking if a non-existent table exists.""" + """Test checking if a non-existent table exists raises TableNotFoundError.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) memory_namespace.create_namespace(create_ns_req) exists_req = TableExistsRequest(id=["workspace", "nonexistent"]) - with pytest.raises(Exception): + with pytest.raises(TableNotFoundError): memory_namespace.table_exists(exists_req) def test_drop_table(self, memory_namespace): @@ -290,7 +292,7 @@ def test_drop_table(self, memory_namespace): # Verify table no longer exists exists_req = TableExistsRequest(id=["workspace", "test_table"]) - with pytest.raises(Exception): + with pytest.raises(TableNotFoundError): memory_namespace.table_exists(exists_req) def test_deregister_table(self, temp_namespace): @@ -384,7 +386,7 @@ def test_register_table_rejects_absolute_uri(self, temp_namespace): register_req = RegisterTableRequest( id=["workspace", "test_table"], location="s3://bucket/table.lance" ) - with pytest.raises(Exception) as exc_info: + with pytest.raises(InvalidInputError) as exc_info: temp_namespace.register_table(register_req) assert "Absolute URIs are not allowed" in str(exc_info.value) @@ -398,7 +400,7 @@ def test_register_table_rejects_absolute_path(self, temp_namespace): register_req = RegisterTableRequest( id=["workspace", "test_table"], location="/tmp/table.lance" ) - with pytest.raises(Exception) as exc_info: + with pytest.raises(InvalidInputError) as exc_info: temp_namespace.register_table(register_req) assert "Absolute paths are not allowed" in str(exc_info.value) @@ -412,7 +414,7 @@ def test_register_table_rejects_path_traversal(self, temp_namespace): register_req = RegisterTableRequest( id=["workspace", "test_table"], location="../outside/table.lance" ) - with pytest.raises(Exception) as exc_info: + with pytest.raises(InvalidInputError) as exc_info: temp_namespace.register_table(register_req) assert "Path traversal is not allowed" in str(exc_info.value) @@ -462,7 +464,7 @@ def test_drop_table_in_child_namespace(self, memory_namespace): # Verify table no longer exists exists_req = TableExistsRequest(id=["test_ns", "table1"]) - with pytest.raises(Exception): + with pytest.raises(TableNotFoundError): memory_namespace.table_exists(exists_req) def test_declared_table_in_child_namespace(self, memory_namespace): @@ -548,15 +550,9 @@ def test_cannot_drop_namespace_with_tables(self, memory_namespace): # Try to drop namespace - should fail drop_req = DropNamespaceRequest(id=["test_ns"]) - with pytest.raises(Exception) as exc_info: + with pytest.raises(NamespaceNotEmptyError): memory_namespace.drop_namespace(drop_req) - # Should contain an error message about non-empty namespace - assert ( - "not empty" in str(exc_info.value).lower() - or "contains" in str(exc_info.value).lower() - ) - def test_isolation_between_namespaces(self, memory_namespace): """Test that namespaces are isolated from each other.""" # Create two namespaces @@ -623,6 +619,13 @@ def test_namespace_exists(self, memory_namespace): exists_req = NamespaceExistsRequest(id=["workspace"]) memory_namespace.namespace_exists(exists_req) + def test_namespace_not_exists(self, memory_namespace): + """Test that a non-existent namespace raises NamespaceNotFoundError.""" + exists_req = NamespaceExistsRequest(id=["nonexistent"]) + + with pytest.raises(NamespaceNotFoundError): + memory_namespace.namespace_exists(exists_req) + def test_drop_empty_namespace(self, memory_namespace): """Test dropping an empty namespace.""" # Create namespace diff --git a/rust/lance-namespace-impls/src/connect.rs b/rust/lance-namespace-impls/src/connect.rs index a9bb6dff8e7..c44eb2de219 100644 --- a/rust/lance-namespace-impls/src/connect.rs +++ b/rust/lance-namespace-impls/src/connect.rs @@ -7,8 +7,9 @@ use std::collections::HashMap; use std::sync::Arc; use lance::session::Session; -use lance_core::{Error, Result}; +use lance_core::Result; use lance_namespace::LanceNamespace; +use lance_namespace::error::NamespaceError; use crate::context::DynamicContextProvider; @@ -186,9 +187,11 @@ impl ConnectBuilder { Ok(Arc::new(builder.build()) as Arc) } #[cfg(not(feature = "rest"))] - "rest" => Err(Error::namespace_source( - "REST namespace implementation requires 'rest' feature to be enabled".into(), - )), + "rest" => Err(NamespaceError::Unsupported { + message: "REST namespace implementation requires 'rest' feature to be enabled" + .to_string(), + } + .into()), "dir" => { // Create directory implementation (always available) let mut builder = crate::dir::DirectoryNamespaceBuilder::from_properties( @@ -203,14 +206,14 @@ impl ConnectBuilder { .await .map(|ns| Arc::new(ns) as Arc) } - _ => Err(Error::namespace_source( - format!( + _ => Err(NamespaceError::Unsupported { + message: format!( "Implementation '{}' is not available. Supported: dir{}", self.impl_name, if cfg!(feature = "rest") { ", rest" } else { "" } - ) - .into(), - )), + ), + } + .into()), } } } diff --git a/rust/lance-namespace-impls/src/credentials.rs b/rust/lance-namespace-impls/src/credentials.rs index e25fd92581e..15881bfa620 100644 --- a/rust/lance-namespace-impls/src/credentials.rs +++ b/rust/lance-namespace-impls/src/credentials.rs @@ -462,13 +462,14 @@ async fn create_aws_vendor( properties: &HashMap, ) -> Result>> { use aws::{AwsCredentialVendor, AwsCredentialVendorConfig}; - use lance_core::Error; + use lance_namespace::error::NamespaceError; // AWS requires role_arn to be configured let role_arn = properties.get(aws_props::ROLE_ARN).ok_or_else(|| { - Error::invalid_input_source( - "AWS credential vending requires 'credential_vendor.aws_role_arn' to be set".into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "AWS credential vending requires 'credential_vendor.aws_role_arn' to be set" + .to_string(), + }) })?; let duration_millis = parse_duration_millis(properties, aws_props::DURATION_MILLIS); @@ -516,14 +517,15 @@ fn create_azure_vendor( properties: &HashMap, ) -> Result>> { use azure::{AzureCredentialVendor, AzureCredentialVendorConfig}; - use lance_core::Error; + use lance_namespace::error::NamespaceError; // Azure requires account_name to be configured let account_name = properties.get(azure_props::ACCOUNT_NAME).ok_or_else(|| { - Error::invalid_input_source( - "Azure credential vending requires 'credential_vendor.azure_account_name' to be set" - .into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: + "Azure credential vending requires 'credential_vendor.azure_account_name' to be set" + .to_string(), + }) })?; let duration_millis = parse_duration_millis(properties, azure_props::DURATION_MILLIS); diff --git a/rust/lance-namespace-impls/src/credentials/aws.rs b/rust/lance-namespace-impls/src/credentials/aws.rs index dda738d505b..10035306969 100644 --- a/rust/lance-namespace-impls/src/credentials/aws.rs +++ b/rust/lance-namespace-impls/src/credentials/aws.rs @@ -12,8 +12,9 @@ use async_trait::async_trait; use aws_config::BehaviorVersion; use aws_sdk_sts::Client as StsClient; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use lance_core::{Error, Result}; +use lance_core::Result; use lance_io::object_store::uri_to_url; +use lance_namespace::error::NamespaceError; use lance_namespace::models::Identity; use log::{debug, info, warn}; use sha2::{Digest, Sha256}; @@ -167,7 +168,9 @@ impl AwsCredentialVendor { let bucket = url .host_str() .ok_or_else(|| { - Error::invalid_input_source(format!("S3 URI '{}' missing bucket", uri).into()) + lance_core::Error::from(NamespaceError::InvalidInput { + message: format!("S3 URI '{}' missing bucket", uri), + }) })? .to_string(); @@ -325,9 +328,9 @@ impl AwsCredentialVendor { permission: VendedPermission, ) -> Result { let credentials = credentials.ok_or_else(|| { - Error::io_source(Box::new(std::io::Error::other( - "STS response missing credentials", - ))) + lance_core::Error::from(NamespaceError::Internal { + message: "STS response missing credentials".to_string(), + }) })?; let access_key_id = credentials.access_key_id().to_string(); @@ -391,10 +394,12 @@ impl AwsCredentialVendor { .send() .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "AssumeRoleWithWebIdentity failed for role '{}': {}", - self.config.role_arn, e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "AssumeRoleWithWebIdentity failed for role '{}': {}", + self.config.role_arn, e + ), + }) })?; self.extract_credentials( @@ -413,9 +418,10 @@ impl AwsCredentialVendor { api_key: &str, ) -> Result { let salt = self.config.api_key_salt.as_ref().ok_or_else(|| { - Error::invalid_input_source( - "api_key_salt must be configured to use API key authentication".into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "api_key_salt must be configured to use API key authentication" + .to_string(), + }) })?; let key_hash = Self::hash_api_key(api_key, salt); @@ -431,7 +437,9 @@ impl AwsCredentialVendor { "Invalid API key: hash {} not found in permissions map", &key_hash[..8] ); - Error::invalid_input_source("Invalid API key".into()) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Invalid API key".to_string(), + }) })?; let policy = Self::build_policy(bucket, prefix, permission); @@ -453,10 +461,12 @@ impl AwsCredentialVendor { .external_id(&key_hash); // Use hash as external_id let response = request.send().await.map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "AssumeRole with API key failed for role '{}': {}", - self.config.role_arn, e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "AssumeRole with API key failed for role '{}': {}", + self.config.role_arn, e + ), + }) })?; self.extract_credentials(response.credentials(), bucket, prefix, permission) @@ -496,10 +506,12 @@ impl AwsCredentialVendor { } let response = request.send().await.map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "AssumeRole failed for role '{}': {}", - self.config.role_arn, e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "AssumeRole failed for role '{}': {}", + self.config.role_arn, e + ), + }) })?; self.extract_credentials( @@ -546,9 +558,11 @@ impl CredentialVendor for AwsCredentialVendor { } Some(_) => { // Identity provided but neither api_key nor auth_token set - Err(Error::invalid_input_source( - "Identity provided but neither api_key nor auth_token is set".into(), - )) + Err(NamespaceError::InvalidInput { + message: "Identity provided but neither api_key nor auth_token is set" + .to_string(), + } + .into()) } None => { // Use AssumeRole with static configuration diff --git a/rust/lance-namespace-impls/src/credentials/azure.rs b/rust/lance-namespace-impls/src/credentials/azure.rs index 2d1b1b5eab8..22620ec98b0 100644 --- a/rust/lance-namespace-impls/src/credentials/azure.rs +++ b/rust/lance-namespace-impls/src/credentials/azure.rs @@ -16,8 +16,9 @@ use azure_storage::prelude::*; use azure_storage::shared_access_signature::service_sas::{BlobSharedAccessSignature, SasKey}; use azure_storage_blobs::prelude::*; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use lance_core::{Error, Result}; +use lance_core::Result; use lance_io::object_store::uri_to_url; +use lance_namespace::error::NamespaceError; use lance_namespace::models::Identity; use log::{debug, info, warn}; use sha2::{Digest, Sha256}; @@ -237,10 +238,9 @@ impl AzureCredentialVendor { let credential = DefaultAzureCredential::create(azure_identity::TokenCredentialOptions::default()) .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to create Azure credentials: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to create Azure credentials: {}", e), + }) })?; let credential: Arc = Arc::new(credential); @@ -265,10 +265,12 @@ impl AzureCredentialVendor { .get_user_deligation_key(now, key_end_time) .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to get user delegation key for account '{}': {}", - account, e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to get user delegation key for account '{}': {}", + account, e + ), + }) })?; let permissions = Self::build_sas_permissions(self.config.permission); @@ -283,20 +285,21 @@ impl AzureCredentialVendor { ) .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to generate SAS token for container '{}': {}", - container, e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to generate SAS token for container '{}': {}", + container, e + ), + }) })?; let expires_at_millis = (end_time.unix_timestamp() * 1000 + end_time.millisecond() as i64) as u64; let token = sas_token.token().map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to get SAS token: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to get SAS token: {}", e), + }) })?; Ok((token, expires_at_millis)) @@ -312,10 +315,9 @@ impl AzureCredentialVendor { let credential = DefaultAzureCredential::create(azure_identity::TokenCredentialOptions::default()) .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to create Azure credentials: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to create Azure credentials: {}", e), + }) })?; let credential: Arc = Arc::new(credential); @@ -336,10 +338,12 @@ impl AzureCredentialVendor { .get_user_deligation_key(now, key_end_time) .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to get user delegation key for account '{}': {}", - account, e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to get user delegation key for account '{}': {}", + account, e + ), + }) })?; let permissions = Self::build_sas_permissions(permission); @@ -352,20 +356,21 @@ impl AzureCredentialVendor { ) .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to generate SAS token for container '{}': {}", - container, e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to generate SAS token for container '{}': {}", + container, e + ), + }) })?; let expires_at_millis = (end_time.unix_timestamp() * 1000 + end_time.millisecond() as i64) as u64; let token = sas_token.token().map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to get SAS token: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to get SAS token: {}", e), + }) })?; Ok((token, expires_at_millis)) @@ -391,10 +396,9 @@ impl AzureCredentialVendor { let credential = DefaultAzureCredential::create(azure_identity::TokenCredentialOptions::default()) .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to create Azure credentials: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to create Azure credentials: {}", e), + }) })?; let credential: Arc = Arc::new(credential); @@ -415,10 +419,12 @@ impl AzureCredentialVendor { .get_user_deligation_key(now, key_end_time) .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to get user delegation key for account '{}': {}", - account, e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to get user delegation key for account '{}': {}", + account, e + ), + }) })?; // Normalize path: remove leading/trailing slashes @@ -448,10 +454,9 @@ impl AzureCredentialVendor { .signed_directory_depth(depth as u8); let token = sas.token().map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to generate directory SAS token: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to generate directory SAS token: {}", e), + }) })?; let expires_at_millis = @@ -472,15 +477,16 @@ impl AzureCredentialVendor { /// 2. The OIDC token's issuer and subject to match the Federated Credential configuration async fn exchange_oidc_for_azure_token(&self, oidc_token: &str) -> Result { let tenant_id = self.config.tenant_id.as_ref().ok_or_else(|| { - Error::invalid_input_source( - "azure_tenant_id must be configured for OIDC token exchange".into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "azure_tenant_id must be configured for OIDC token exchange".to_string(), + }) })?; let client_id = self.config.federated_client_id.as_ref().ok_or_else(|| { - Error::invalid_input_source( - "azure_federated_client_id must be configured for OIDC token exchange".into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "azure_federated_client_id must be configured for OIDC token exchange" + .to_string(), + }) })?; let token_url = format!( @@ -506,26 +512,27 @@ impl AzureCredentialVendor { .send() .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to exchange OIDC token for Azure AD token: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to exchange OIDC token for Azure AD token: {}", e), + }) })?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(Error::io_source(Box::new(std::io::Error::other(format!( - "Azure AD token exchange failed with status {}: {}", - status, body - ))))); + return Err(NamespaceError::Internal { + message: format!( + "Azure AD token exchange failed with status {}: {}", + status, body + ), + } + .into()); } let token_response: serde_json::Value = response.json().await.map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to parse Azure AD token response: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to parse Azure AD token response: {}", e), + }) })?; token_response @@ -533,9 +540,9 @@ impl AzureCredentialVendor { .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| { - Error::io_source(Box::new(std::io::Error::other( - "Azure AD token response missing access_token", - ))) + lance_core::Error::from(NamespaceError::Internal { + message: "Azure AD token response missing access_token".to_string(), + }) }) } @@ -571,10 +578,12 @@ impl AzureCredentialVendor { .get_user_deligation_key(now, key_end_time) .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to get user delegation key with federated token: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to get user delegation key with federated token: {}", + e + ), + }) })?; let permissions = Self::build_sas_permissions(permission); @@ -594,17 +603,18 @@ impl AzureCredentialVendor { ) .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to generate SAS token with federated token: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to generate SAS token with federated token: {}", + e + ), + }) })?; sas_token.token().map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to get SAS token: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to get SAS token: {}", e), + }) })? } else { // Directory-scoped SAS @@ -622,10 +632,12 @@ impl AzureCredentialVendor { .signed_directory_depth(depth as u8); sas.token().map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to generate directory SAS token with federated token: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to generate directory SAS token with federated token: {}", + e + ), + }) })? }; @@ -694,9 +706,10 @@ impl AzureCredentialVendor { api_key: &str, ) -> Result { let salt = self.config.api_key_salt.as_ref().ok_or_else(|| { - Error::invalid_input_source( - "api_key_salt must be configured to use API key authentication".into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "api_key_salt must be configured to use API key authentication" + .to_string(), + }) })?; let key_hash = Self::hash_api_key(api_key, salt); @@ -712,7 +725,9 @@ impl AzureCredentialVendor { "Invalid API key: hash {} not found in permissions map", &key_hash[..8] ); - Error::invalid_input_source("Invalid API key".into()) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Invalid API key".to_string(), + }) })?; debug!( @@ -806,9 +821,9 @@ impl CredentialVendor for AzureCredentialVendor { let url = uri_to_url(table_location)?; let container = url.host_str().ok_or_else(|| { - Error::invalid_input_source( - format!("Azure URI '{}' missing container", table_location).into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: format!("Azure URI '{}' missing container", table_location), + }) })?; // Extract path for directory-scoped SAS @@ -818,7 +833,7 @@ impl CredentialVendor for AzureCredentialVendor { self.config .account_name .as_ref() - .ok_or_else(|| Error::invalid_input_source("Azure credential vending requires 'credential_vendor.azure_account_name' to be set in configuration".into()))?; + .ok_or_else(|| lance_core::Error::from(NamespaceError::InvalidInput { message: "Azure credential vending requires 'credential_vendor.azure_account_name' to be set in configuration".to_string() }))?; // Dispatch based on identity match identity { @@ -832,9 +847,10 @@ impl CredentialVendor for AzureCredentialVendor { self.vend_with_api_key(account, container, path, api_key) .await } - Some(_) => Err(Error::invalid_input_source( - "Identity provided but neither auth_token nor api_key is set".into(), - )), + Some(_) => Err(NamespaceError::InvalidInput { + message: "Identity provided but neither auth_token nor api_key is set".to_string(), + } + .into()), None => { // Static credential vending using DefaultAzureCredential // Use directory-scoped SAS when path is provided, container-level otherwise diff --git a/rust/lance-namespace-impls/src/credentials/gcp.rs b/rust/lance-namespace-impls/src/credentials/gcp.rs index 7f33e4b663d..d4fe3d9a9d8 100644 --- a/rust/lance-namespace-impls/src/credentials/gcp.rs +++ b/rust/lance-namespace-impls/src/credentials/gcp.rs @@ -46,8 +46,9 @@ use std::collections::HashMap; use async_trait::async_trait; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use google_cloud_auth::credentials; -use lance_core::{Error, Result}; +use lance_core::Result; use lance_io::object_store::uri_to_url; +use lance_namespace::error::NamespaceError; use lance_namespace::models::Identity; use log::{debug, info, warn}; use reqwest::Client; @@ -249,10 +250,9 @@ impl GcpCredentialVendor { let credential = credentials::create_access_token_credential() .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to create GCP credentials: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to create GCP credentials: {}", e), + }) })?; Ok(Self { @@ -267,19 +267,21 @@ impl GcpCredentialVendor { let url = uri_to_url(uri)?; if url.scheme() != "gs" { - return Err(Error::invalid_input_source( - format!( + return Err(NamespaceError::InvalidInput { + message: format!( "Unsupported GCS URI scheme '{}', expected 'gs'", url.scheme() - ) - .into(), - )); + ), + } + .into()); } let bucket = url .host_str() .ok_or_else(|| { - Error::invalid_input_source(format!("GCS URI '{}' missing bucket", uri).into()) + lance_core::Error::from(NamespaceError::InvalidInput { + message: format!("GCS URI '{}' missing bucket", uri), + }) })? .to_string(); @@ -295,10 +297,9 @@ impl GcpCredentialVendor { /// directly. async fn get_source_token(&self) -> Result { let base_token = self.credential.get_token().await.map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to get GCP token: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to get GCP token: {}", e), + }) })?; // If service account impersonation is configured, use generateAccessToken API @@ -337,10 +338,9 @@ impl GcpCredentialVendor { .send() .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to call IAM generateAccessToken: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to call IAM generateAccessToken: {}", e), + }) })?; if !response.status().is_success() { @@ -349,17 +349,19 @@ impl GcpCredentialVendor { .text() .await .unwrap_or_else(|_| "unknown error".to_string()); - return Err(Error::io_source(Box::new(std::io::Error::other(format!( - "IAM generateAccessToken failed for '{}' with status {}: {}", - service_account, status, body - ))))); + return Err(NamespaceError::Internal { + message: format!( + "IAM generateAccessToken failed for '{}' with status {}: {}", + service_account, status, body + ), + } + .into()); } let token_response: GenerateAccessTokenResponse = response.json().await.map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to parse generateAccessToken response: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to parse generateAccessToken response: {}", e), + }) })?; Ok(token_response.access_token) @@ -445,10 +447,9 @@ impl GcpCredentialVendor { access_boundary: &CredentialAccessBoundary, ) -> Result<(String, u64)> { let options_json = serde_json::to_string(access_boundary).map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to serialize access boundary: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to serialize access boundary: {}", e), + }) })?; let params = [ @@ -475,10 +476,9 @@ impl GcpCredentialVendor { .send() .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to call STS token exchange: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to call STS token exchange: {}", e), + }) })?; if !response.status().is_success() { @@ -487,17 +487,16 @@ impl GcpCredentialVendor { .text() .await .unwrap_or_else(|_| "unknown error".to_string()); - return Err(Error::io_source(Box::new(std::io::Error::other(format!( - "STS token exchange failed with status {}: {}", - status, body - ))))); + return Err(NamespaceError::Internal { + message: format!("STS token exchange failed with status {}: {}", status, body), + } + .into()); } let token_response: TokenExchangeResponse = response.json().await.map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to parse STS response: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to parse STS response: {}", e), + }) })?; // Calculate expiration time @@ -592,10 +591,11 @@ impl GcpCredentialVendor { .workload_identity_provider .as_ref() .ok_or_else(|| { - Error::invalid_input_source( - "gcp_workload_identity_provider must be configured for OIDC token exchange" - .into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: + "gcp_workload_identity_provider must be configured for OIDC token exchange" + .to_string(), + }) })?; // Normalize audience to full format expected by GCP STS @@ -624,26 +624,27 @@ impl GcpCredentialVendor { .send() .await .map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to exchange OIDC token for GCP token: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to exchange OIDC token for GCP token: {}", e), + }) })?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(Error::io_source(Box::new(std::io::Error::other(format!( - "GCP STS token exchange failed with status {}: {}", - status, body - ))))); + return Err(NamespaceError::Internal { + message: format!( + "GCP STS token exchange failed with status {}: {}", + status, body + ), + } + .into()); } let token_response: TokenExchangeResponse = response.json().await.map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to parse GCP STS token response: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to parse GCP STS token response: {}", e), + }) })?; let federated_token = token_response.access_token; @@ -706,9 +707,10 @@ impl GcpCredentialVendor { api_key: &str, ) -> Result { let salt = self.config.api_key_salt.as_ref().ok_or_else(|| { - Error::invalid_input_source( - "api_key_salt must be configured to use API key authentication".into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "api_key_salt must be configured to use API key authentication" + .to_string(), + }) })?; let key_hash = Self::hash_api_key(api_key, salt); @@ -724,7 +726,9 @@ impl GcpCredentialVendor { "Invalid API key: hash {} not found in permissions map", &key_hash[..8] ); - Error::invalid_input_source("Invalid API key".into()) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Invalid API key".to_string(), + }) })?; debug!( @@ -790,9 +794,10 @@ impl CredentialVendor for GcpCredentialVendor { let api_key = id.api_key.as_ref().unwrap(); self.vend_with_api_key(&bucket, &prefix, api_key).await } - Some(_) => Err(Error::invalid_input_source( - "Identity provided but neither auth_token nor api_key is set".into(), - )), + Some(_) => Err(NamespaceError::InvalidInput { + message: "Identity provided but neither auth_token nor api_key is set".to_string(), + } + .into()), None => { // Static credential vending using ADC let source_token = self.get_source_token().await?; diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index ba01c88eab2..da07c12dfbd 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -50,8 +50,9 @@ use lance_namespace::models::{ TableVersion, }; -use lance_core::{Error, Result, box_error}; +use lance_core::Result; use lance_namespace::LanceNamespace; +use lance_namespace::error::NamespaceError; use lance_namespace::schema::arrow_schema_to_json; use crate::credentials::{ @@ -323,9 +324,9 @@ impl DirectoryNamespaceBuilder { ) -> Result { // Extract root from properties (required) let root = properties.get("root").cloned().ok_or_else(|| { - Error::namespace_source( - "Missing required property 'root' for directory namespace".into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Missing required property 'root' for directory namespace".to_string(), + }) })?; // Extract storage options (properties prefixed with "storage.") @@ -527,9 +528,10 @@ impl DirectoryNamespaceBuilder { pub async fn build(self) -> Result { // Validate: table_version_storage_enabled requires manifest_enabled if self.table_version_storage_enabled && !self.manifest_enabled { - return Err(Error::invalid_input( - "table_version_storage_enabled requires manifest_enabled=true", - )); + return Err(NamespaceError::InvalidInput { + message: "table_version_storage_enabled requires manifest_enabled=true".to_string(), + } + .into()); } let (object_store, base_path) = @@ -614,7 +616,9 @@ impl DirectoryNamespaceBuilder { let (object_store, base_path) = ObjectStore::from_uri_and_params(registry, root, ¶ms) .await .map_err(|e| { - Error::namespace_source(format!("Failed to create object store: {}", e).into()) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to create object store: {}", e), + }) })?; Ok((object_store, base_path)) @@ -727,10 +731,9 @@ impl DirectoryNamespace { .read_dir(self.base_path.clone()) .await .map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to list directory: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to list directory: {}", e), + }) })?; for entry in entries { @@ -758,10 +761,13 @@ impl DirectoryNamespace { if let Some(id) = id && !id.is_empty() { - return Err(Error::namespace_source(format!( - "Directory namespace only supports root namespace operations, but got namespace ID: {:?}. Expected empty ID.", - id - ).into())); + return Err(NamespaceError::Unsupported { + message: format!( + "Directory namespace only supports root namespace operations, but got namespace ID: {:?}. Expected empty ID.", + id + ), + } + .into()); } Ok(()) } @@ -769,15 +775,19 @@ impl DirectoryNamespace { /// Extract table name from table ID fn table_name_from_id(id: &Option>) -> Result { let id = id.as_ref().ok_or_else(|| { - Error::namespace_source("Directory namespace table ID cannot be empty".into()) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Directory namespace table ID cannot be empty".to_string(), + }) })?; if id.len() != 1 { - return Err(Error::namespace_source(format!( - "Multi-level table IDs are only supported when manifest mode is enabled, but got: {:?}", - id - ) - .into())); + return Err(NamespaceError::Unsupported { + message: format!( + "Multi-level table IDs are only supported when manifest mode is enabled, but got: {:?}", + id + ), + } + .into()); } Ok(id[0].clone()) @@ -791,7 +801,9 @@ impl DirectoryNamespace { let describe_resp = self.describe_table(describe_req).await?; describe_resp.location.ok_or_else(|| { - Error::namespace_source(format!("Table location not found for: {:?}", id).into()) + lance_core::Error::from(NamespaceError::TableNotFound { + message: format!("Table location not found for: {:?}", id), + }) }) } @@ -804,13 +816,13 @@ impl DirectoryNamespace { if let Some(version) = version && version < 0 { - return Err(Error::invalid_input_source( - format!( + return Err(NamespaceError::InvalidInput { + message: format!( "Table version for {} must be non-negative, got {}", operation, version - ) - .into(), - )); + ), + } + .into()); } let mut builder = DatasetBuilder::from_uri(table_uri); @@ -822,24 +834,22 @@ impl DirectoryNamespace { } let dataset = builder.load().await.map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::TableNotFound { + message: format!( "Failed to open table at '{}' for {}: {}", table_uri, operation, e - ) - .into(), - ) + ), + }) })?; if let Some(version) = version { return dataset.checkout_version(version as u64).await.map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::TableVersionNotFound { + message: format!( "Failed to checkout version {} for table at '{}' during {}: {}", version, table_uri, operation, e - ) - .into(), - ) + ), + }) }); } @@ -863,22 +873,22 @@ impl DirectoryNamespace { "IVF_HNSW_FLAT" => Ok(IndexType::IvfHnswFlat), "IVF_HNSW_SQ" => Ok(IndexType::IvfHnswSq), "IVF_HNSW_PQ" => Ok(IndexType::IvfHnswPq), - other => Err(Error::invalid_input_source( - format!("Unsupported index_type '{}'", other).into(), - )), + other => Err(NamespaceError::InvalidInput { + message: format!("Unsupported index_type '{}'", other), + } + .into()), } } fn parse_metric_type(distance_type: Option<&str>) -> Result { let distance_type = distance_type.unwrap_or("l2"); MetricType::try_from(distance_type).map_err(|e| { - Error::invalid_input_source( - format!( + lance_core::Error::from(NamespaceError::InvalidInput { + message: format!( "Unsupported distance_type '{}' for vector index: {}", distance_type, e - ) - .into(), - ) + ), + }) }) } @@ -926,13 +936,13 @@ impl DirectoryNamespace { } if let Some(max_token_length) = request.max_token_length { if max_token_length < 0 { - return Err(Error::invalid_input_source( - format!( + return Err(NamespaceError::InvalidInput { + message: format!( "FTS max_token_length must be non-negative, got {}", max_token_length - ) - .into(), - )); + ), + } + .into()); } params = params.max_token_length(Some(max_token_length as usize)); } @@ -1008,9 +1018,10 @@ impl DirectoryNamespace { ), }, other => { - return Err(Error::invalid_input_source( - format!("Unsupported index type for namespace API: {}", other).into(), - )); + return Err(NamespaceError::InvalidInput { + message: format!("Unsupported index type for namespace API: {}", other), + } + .into()); } }) } @@ -1125,26 +1136,28 @@ impl DirectoryNamespace { .read_transaction_by_version(version) .await .map_err(|e| { - Error::namespace_source( - format!("Failed to read transaction for version {}: {}", version, e).into(), - ) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to read transaction for version {}: {}", + version, e + ), + }) })? .ok_or_else(|| { - Error::namespace_source( - format!("Transaction not found for version {}", version).into(), - ) + lance_core::Error::from(NamespaceError::TransactionNotFound { + message: format!("version {}", version), + }) })?; return Ok((version, transaction)); } let versions = dataset.versions().await.map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to list table versions while resolving transaction '{}': {}", id, e - ) - .into(), - ) + ), + }) })?; for version in versions.into_iter().rev() { @@ -1152,13 +1165,12 @@ impl DirectoryNamespace { .read_transaction_by_version(version.version) .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to read transaction for version {} while resolving '{}': {}", version.version, id, e - ) - .into(), - ) + ), + }) })? && transaction.uuid == id { @@ -1166,9 +1178,10 @@ impl DirectoryNamespace { } } - Err(Error::namespace_source( - format!("Transaction not found: {}", id).into(), - )) + Err(NamespaceError::TransactionNotFound { + message: id.to_string(), + } + .into()) } fn table_full_uri(&self, table_name: &str) -> String { @@ -1414,13 +1427,13 @@ impl DirectoryNamespace { e ); } else { - return Err(Error::namespace_source( - format!( + return Err(NamespaceError::Internal { + message: format!( "Failed to delete version {} for table at '{}': {}", version, table_uri, e - ) - .into(), - )); + ), + } + .into()); } } } @@ -1470,14 +1483,17 @@ impl LanceNamespace for DirectoryNamespace { } if request.id.is_none() || request.id.as_ref().unwrap().is_empty() { - return Err(Error::namespace_source( - "Root namespace already exists and cannot be created".into(), - )); + return Err(NamespaceError::NamespaceAlreadyExists { + message: "root namespace".to_string(), + } + .into()); } - Err(Error::not_supported_source( - "Child namespaces are only supported when manifest mode is enabled".into(), - )) + Err(NamespaceError::Unsupported { + message: "Child namespaces are only supported when manifest mode is enabled" + .to_string(), + } + .into()) } async fn drop_namespace(&self, request: DropNamespaceRequest) -> Result { @@ -1486,14 +1502,17 @@ impl LanceNamespace for DirectoryNamespace { } if request.id.is_none() || request.id.as_ref().unwrap().is_empty() { - return Err(Error::namespace_source( - "Root namespace cannot be dropped".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Root namespace cannot be dropped".to_string(), + } + .into()); } - Err(Error::not_supported_source( - "Child namespaces are only supported when manifest mode is enabled".into(), - )) + Err(NamespaceError::Unsupported { + message: "Child namespaces are only supported when manifest mode is enabled" + .to_string(), + } + .into()) } async fn namespace_exists(&self, request: NamespaceExistsRequest) -> Result<()> { @@ -1505,26 +1524,31 @@ impl LanceNamespace for DirectoryNamespace { return Ok(()); } - Err(Error::namespace_source( - "Child namespaces are only supported when manifest mode is enabled".into(), - )) + Err(NamespaceError::NamespaceNotFound { + message: "Child namespaces are only supported when manifest mode is enabled" + .to_string(), + } + .into()) } async fn list_tables(&self, request: ListTablesRequest) -> Result { // Validate that namespace ID is provided - let namespace_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Namespace ID is required".into()))?; + let namespace_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Namespace ID is required".to_string(), + }) + })?; // For child namespaces, always delegate to manifest (if enabled) if !namespace_id.is_empty() { if let Some(ref manifest_ns) = self.manifest_ns { return manifest_ns.list_tables(request).await; } - return Err(Error::not_supported_source( - "Child namespaces are only supported when manifest mode is enabled".into(), - )); + return Err(NamespaceError::Unsupported { + message: "Child namespaces are only supported when manifest mode is enabled" + .to_string(), + } + .into()); } // When only manifest is enabled (no directory listing), delegate directly to manifest @@ -1616,15 +1640,17 @@ impl LanceNamespace for DirectoryNamespace { let status = self.check_table_status(&table_name).await; if !status.exists { - return Err(Error::namespace_source( - format!("Table does not exist: {}", table_name).into(), - )); + return Err(NamespaceError::TableNotFound { + message: table_name.to_string(), + } + .into()); } if status.is_deregistered { - return Err(Error::namespace_source( - format!("Table is deregistered: {}", table_name).into(), - )); + return Err(NamespaceError::InvalidTableState { + message: format!("Table is deregistered: {}", table_name), + } + .into()); } let load_detailed_metadata = request.load_detailed_metadata.unwrap_or(false); @@ -1735,13 +1761,13 @@ impl LanceNamespace for DirectoryNamespace { ..Default::default() }) } else { - Err(Error::namespace_source( - format!( + Err(NamespaceError::Internal { + message: format!( "Table directory exists but cannot load dataset {}: {:?}", table_name, err - ) - .into(), - )) + ), + } + .into()) } } } @@ -1751,8 +1777,11 @@ impl LanceNamespace for DirectoryNamespace { if let Some(ref manifest_ns) = self.manifest_ns { match manifest_ns.table_exists(request.clone()).await { Ok(()) => return Ok(()), - Err(_) if self.dir_listing_enabled => { - // Fall through to directory check + Err(_) + if self.dir_listing_enabled + && request.id.as_ref().is_some_and(|id| id.len() == 1) => + { + // Fall through to directory check only for single-level IDs } Err(e) => return Err(e), } @@ -1764,15 +1793,17 @@ impl LanceNamespace for DirectoryNamespace { let status = self.check_table_status(&table_name).await; if !status.exists { - return Err(Error::namespace_source( - format!("Table does not exist: {}", table_name).into(), - )); + return Err(NamespaceError::TableNotFound { + message: table_name.to_string(), + } + .into()); } if status.is_deregistered { - return Err(Error::namespace_source( - format!("Table is deregistered: {}", table_name).into(), - )); + return Err(NamespaceError::InvalidTableState { + message: format!("Table is deregistered: {}", table_name), + } + .into()); } Ok(()) @@ -1791,9 +1822,9 @@ impl LanceNamespace for DirectoryNamespace { .remove_dir_all(table_path) .await .map_err(|e| { - Error::namespace_source( - format!("Failed to drop table {}: {}", table_name, e).into(), - ) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to drop table {}: {}", table_name, e), + }) })?; Ok(DropTableResponse { @@ -1815,15 +1846,18 @@ impl LanceNamespace for DirectoryNamespace { let table_name = Self::table_name_from_id(&request.id)?; let table_uri = self.table_full_uri(&table_name); if request_data.is_empty() { - return Err(Error::namespace_source( - "Request data (Arrow IPC stream) is required for create_table".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Request data (Arrow IPC stream) is required for create_table".to_string(), + } + .into()); } // Parse the Arrow IPC stream from request_data let cursor = Cursor::new(request_data.to_vec()); let stream_reader = StreamReader::try_new(cursor, None).map_err(|e| { - Error::namespace_source(format!("Invalid Arrow IPC stream: {}", e).into()) + lance_core::Error::from(NamespaceError::InvalidInput { + message: format!("Invalid Arrow IPC stream: {}", e), + }) })?; let arrow_schema = stream_reader.schema(); @@ -1831,9 +1865,9 @@ impl LanceNamespace for DirectoryNamespace { let mut batches = Vec::new(); for batch_result in stream_reader { batches.push(batch_result.map_err(|e| { - Error::namespace_source( - format!("Failed to read batch from IPC stream: {}", e).into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: format!("Failed to read batch from IPC stream: {}", e), + }) })?); } @@ -1864,7 +1898,9 @@ impl LanceNamespace for DirectoryNamespace { Dataset::write(reader, &table_uri, Some(write_params)) .await .map_err(|e| { - Error::namespace_source(format!("Failed to create Lance dataset: {}", e).into()) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to create Lance dataset: {}", e), + }) })?; Ok(CreateTableResponse { @@ -1900,13 +1936,13 @@ impl LanceNamespace for DirectoryNamespace { if let Some(location) = &request.location { let location = location.trim_end_matches('/'); if location != table_uri { - return Err(Error::namespace_source( - format!( + return Err(NamespaceError::InvalidInput { + message: format!( "Cannot declare table {} at location {}, must be at location {}", table_name, location, table_uri - ) - .into(), - )); + ), + } + .into()); } } @@ -1916,9 +1952,10 @@ impl LanceNamespace for DirectoryNamespace { let status = self.check_table_status(&table_name).await; if status.exists && !status.has_reserved_file { // Table has data but no reserved file - it was created with data - return Err(Error::namespace_source( - format!("Table already exists: {}", table_name).into(), - )); + return Err(NamespaceError::TableAlreadyExists { + message: table_name.to_string(), + } + .into()); } // Atomically create the .lance-reserved file to mark the table as declared. @@ -1928,7 +1965,15 @@ impl LanceNamespace for DirectoryNamespace { self.put_marker_file_atomic(&reserved_file_path, &format!("table {}", table_name)) .await - .map_err(|e| Error::namespace_source(e.into()))?; + .map_err(|e| { + if e.contains("already exists") { + lance_core::Error::from(NamespaceError::TableAlreadyExists { + message: table_name.to_string(), + }) + } else { + lance_core::Error::from(NamespaceError::Internal { message: e }) + } + })?; // For backwards compatibility, only skip vending credentials when explicitly set to false let vend_credentials = request.vend_credentials.unwrap_or(true); @@ -1959,9 +2004,10 @@ impl LanceNamespace for DirectoryNamespace { } // Without manifest, register_table is not supported - Err(Error::not_supported_source( - "register_table is only supported when manifest mode is enabled".into(), - )) + Err(NamespaceError::Unsupported { + message: "register_table is only supported when manifest mode is enabled".to_string(), + } + .into()) } async fn deregister_table( @@ -1982,15 +2028,17 @@ impl LanceNamespace for DirectoryNamespace { let status = self.check_table_status(&table_name).await; if !status.exists { - return Err(Error::namespace_source( - format!("Table does not exist: {}", table_name).into(), - )); + return Err(NamespaceError::TableNotFound { + message: table_name.to_string(), + } + .into()); } if status.is_deregistered { - return Err(Error::namespace_source( - format!("Table is already deregistered: {}", table_name).into(), - )); + return Err(NamespaceError::InvalidTableState { + message: format!("Table is already deregistered: {}", table_name), + } + .into()); } // Atomically create the .lance-deregistered marker file. @@ -2005,13 +2053,13 @@ impl LanceNamespace for DirectoryNamespace { ) .await .map_err(|e| { - // Convert "already exists" to "already deregistered" for better UX - let message = if e.contains("already exists") { - format!("Table is already deregistered: {}", table_name) + if e.contains("already exists") { + lance_core::Error::from(NamespaceError::InvalidTableState { + message: format!("Table is already deregistered: {}", table_name), + }) } else { - e - }; - Error::namespace_source(message.into()) + lance_core::Error::from(NamespaceError::Internal { message: e }) + } })?; Ok(lance_namespace::models::DeregisterTableResponse { @@ -2047,13 +2095,12 @@ impl LanceNamespace for DirectoryNamespace { .try_collect() .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to list manifest files for table at '{}': {}", table_uri, e - ) - .into(), - ) + ), + }) })?; let is_v2_naming = manifest_metas @@ -2146,24 +2193,22 @@ impl LanceNamespace for DirectoryNamespace { .get(&staging_path) .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to read staging manifest at '{}': {}", staging_manifest_path, e - ) - .into(), - ) + ), + }) })? .bytes() .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to read staging manifest bytes at '{}': {}", staging_manifest_path, e - ) - .into(), - ) + ), + }) })?; let manifest_size = manifest_data.len() as i64; @@ -2182,20 +2227,20 @@ impl LanceNamespace for DirectoryNamespace { .await .map_err(|e| match e { object_store::Error::AlreadyExists { .. } - | object_store::Error::Precondition { .. } => Error::namespace_source( - format!( - "Version {} already exists for table at '{}'", - version, table_uri - ) - .into(), - ), - _ => Error::namespace_source( - format!( + | object_store::Error::Precondition { .. } => { + lance_core::Error::from(NamespaceError::ConcurrentModification { + message: format!( + "Version {} already exists for table at '{}'", + version, table_uri + ), + }) + } + _ => lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to create version {} for table at '{}': {}", version, table_uri, e - ) - .into(), - ), + ), + }), })?; // Delete the staging manifest after successful copy @@ -2280,9 +2325,9 @@ impl LanceNamespace for DirectoryNamespace { builder = builder.with_session(sess.clone()); } let mut dataset = builder.load().await.map_err(|e| { - Error::namespace_source( - format!("Failed to open table at '{}': {}", table_uri, e).into(), - ) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to open table at '{}': {}", table_uri, e), + }) })?; if let Some(version) = request.version { @@ -2290,13 +2335,12 @@ impl LanceNamespace for DirectoryNamespace { .checkout_version(version as u64) .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::TableVersionNotFound { + message: format!( "Failed to checkout version {} for table at '{}': {}", version, table_uri, e - ) - .into(), - ) + ), + }) })?; } @@ -2425,30 +2469,28 @@ impl LanceNamespace for DirectoryNamespace { ) .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to create {} index '{}' on column '{}' for table '{}': {}", request.index_type, request.name.as_deref().unwrap_or(""), request.column, table_uri, e - ) - .into(), - ) + ), + }) })?; let transaction_id = dataset .read_transaction() .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to read committed transaction after creating index on '{}': {}", table_uri, e - ) - .into(), - ) + ), + }) })? .map(|transaction| transaction.uuid); @@ -2467,9 +2509,9 @@ impl LanceNamespace for DirectoryNamespace { .describe_indices(None) .await .map_err(|e| { - Error::namespace_source( - format!("Failed to describe table indices for '{}': {}", table_uri, e).into(), - ) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to describe table indices for '{}': {}", table_uri, e), + }) })? .into_iter() .filter(|description| { @@ -2487,22 +2529,20 @@ impl LanceNamespace for DirectoryNamespace { dataset .schema() .field_path(i32::try_from(*field_id).map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Field id {} does not fit in i32 for table '{}': {}", field_id, table_uri, e - ) - .into(), - ) + ), + }) })?) .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to resolve field path for field_id {} in table '{}': {}", field_id, table_uri, e - ) - .into(), - ) + ), + }) }) }) .collect::>>()?; @@ -2532,47 +2572,45 @@ impl LanceNamespace for DirectoryNamespace { .load_dataset(&table_uri, request.version, "describe_table_index_stats") .await?; let index_name = request.index_name.as_deref().ok_or_else(|| { - Error::invalid_input_source( - "Index name is required for describe_table_index_stats".into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Index name is required for describe_table_index_stats".to_string(), + }) })?; let metadatas = dataset .load_indices_by_name(index_name) .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to load index '{}' metadata for table '{}': {}", index_name, table_uri, e - ) - .into(), - ) + ), + }) })?; if metadatas.first().is_some_and(is_system_index) { - return Err(Error::not_supported_source( - format!("System index '{}' is not exposed by this API", index_name).into(), - )); + return Err(NamespaceError::Unsupported { + message: format!("System index '{}' is not exposed by this API", index_name), + } + .into()); } let stats = ::index_statistics(&dataset, index_name) .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to describe index statistics for '{}' on table '{}': {}", index_name, table_uri, e - ) - .into(), - ) + ), + }) })?; let stats: serde_json::Value = serde_json::from_str(&stats).map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to parse index statistics for '{}' on table '{}': {}", index_name, table_uri, e - ) - .into(), - ) + ), + }) })?; Ok(Self::describe_table_index_stats_response(&stats)) @@ -2583,18 +2621,19 @@ impl LanceNamespace for DirectoryNamespace { request: DescribeTransactionRequest, ) -> Result { let mut request_id = request.id.ok_or_else(|| { - Error::invalid_input_source( - "Transaction id must include table id and transaction identifier".into(), - ) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Transaction id must include table id and transaction identifier" + .to_string(), + }) })?; if request_id.len() < 2 { - return Err(Error::invalid_input_source( - format!( + return Err(NamespaceError::InvalidInput { + message: format!( "Transaction request id must include table id and transaction identifier, got {:?}", request_id - ) - .into(), - )); + ), + } + .into()); } let id = request_id.pop().expect("request_id len checked above"); @@ -2614,13 +2653,13 @@ impl LanceNamespace for DirectoryNamespace { ) -> Result { let index_type = Self::parse_index_type(&request.index_type)?; if !index_type.is_scalar() { - return Err(Error::invalid_input_source( - format!( + return Err(NamespaceError::InvalidInput { + message: format!( "create_table_scalar_index only supports scalar index types, got {}", request.index_type - ) - .into(), - )); + ), + } + .into()); } let response = self.create_table_index(request).await?; @@ -2635,7 +2674,9 @@ impl LanceNamespace for DirectoryNamespace { ) -> Result { let table_uri = self.resolve_table_location(&request.id).await?; let index_name = request.index_name.as_deref().ok_or_else(|| { - Error::invalid_input_source("Index name is required for drop_table_index".into()) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Index name is required for drop_table_index".to_string(), + }) })?; let mut dataset = self .load_dataset(&table_uri, None, "drop_table_index") @@ -2644,45 +2685,42 @@ impl LanceNamespace for DirectoryNamespace { .load_indices_by_name(index_name) .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to load index '{}' before dropping it from table '{}': {}", index_name, table_uri, e - ) - .into(), - ) + ), + }) })?; if metadatas.first().is_some_and(is_system_index) { - return Err(Error::not_supported_source( - format!( + return Err(NamespaceError::Unsupported { + message: format!( "System index '{}' cannot be dropped via this API", index_name - ) - .into(), - )); + ), + } + .into()); } dataset.drop_index(index_name).await.map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to drop index '{}' from table '{}': {}", index_name, table_uri, e - ) - .into(), - ) + ), + }) })?; let transaction_id = dataset .read_transaction() .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to read committed transaction after dropping index '{}' from '{}': {}", index_name, table_uri, e - ) - .into(), - ) + ), + }) })? .map(|transaction| transaction.uuid); @@ -3303,12 +3341,7 @@ mod tests { let result = namespace.describe_table(request).await; assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Table does not exist") - ); + assert!(result.unwrap_err().to_string().contains("Table not found")); } #[tokio::test] @@ -3337,12 +3370,7 @@ mod tests { request.id = Some(vec!["nonexistent".to_string()]); let result = namespace.table_exists(request).await; assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Table does not exist") - ); + assert!(result.unwrap_err().to_string().contains("Table not found")); } #[tokio::test] @@ -5705,8 +5733,8 @@ mod tests { ); let err_msg = result.unwrap_err().to_string(); assert!( - err_msg.contains("does not exist"), - "Error should mention table does not exist, got: {}", + err_msg.contains("Table not found"), + "Error should mention table not found, got: {}", err_msg ); } diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index 9c8bf337712..2a9dced2e1c 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -23,7 +23,7 @@ use lance::session::Session; use lance::{Dataset, dataset::scanner::Scanner}; use lance_core::Error as LanceError; use lance_core::datatypes::LANCE_UNENFORCED_PRIMARY_KEY_POSITION; -use lance_core::{Error, Result, box_error}; +use lance_core::{Error, Result}; use lance_index::IndexType; use lance_index::optimize::OptimizeOptions; use lance_index::scalar::{BuiltinIndexType, ScalarIndexParams}; @@ -84,7 +84,10 @@ impl ObjectType { "namespace" => Ok(Self::Namespace), "table" => Ok(Self::Table), "table_version" => Ok(Self::TableVersion), - _ => Err(Error::io(format!("Invalid object type: {}", s))), + _ => Err(NamespaceError::Internal { + message: format!("Invalid object type: {}", s), + } + .into()), } } } @@ -175,10 +178,9 @@ impl DatasetConsistencyWrapper { current_version ); let latest_version = read_guard.latest_version_id().await.map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to get latest version: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to get latest version: {}", e), + }) })?; log::debug!( "Reload got latest_version={} for uri={}, current_version={}", @@ -199,18 +201,16 @@ impl DatasetConsistencyWrapper { // Double-check after acquiring write lock (someone else might have reloaded) let latest_version = write_guard.latest_version_id().await.map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to get latest version: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to get latest version: {}", e), + }) })?; if latest_version != write_guard.version().version { write_guard.checkout_latest().await.map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to checkout latest: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to checkout latest: {}", e), + }) })?; } @@ -338,10 +338,9 @@ fn convert_lance_commit_error(e: &LanceError, operation: &str, object_id: Option }; return NamespaceError::ConcurrentModification { message }.into(); } - Error::io_source(box_error(std::io::Error::other(format!( - "{}: {:?}", - operation, e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("{}: {:?}", operation, e), + }) } } } @@ -486,13 +485,12 @@ impl ManifestNamespace { } let full_url = base_url.join(relative_location).map_err(|e| { - Error::invalid_input_source( - format!( + lance_core::Error::from(NamespaceError::InvalidInput { + message: format!( "Failed to join URI '{}' with '{}': {:?}", root, relative_location, e - ) - .into(), - ) + ), + }) })?; Ok(full_url.to_string()) @@ -684,19 +682,17 @@ impl ManifestNamespace { /// Helper to execute a scanner and collect results into a Vec async fn execute_scanner(scanner: Scanner) -> Result> { let mut stream = scanner.try_into_stream().await.map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to create stream: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to create stream: {}", e), + }) })?; let mut batches = Vec::new(); while let Some(batch) = stream.next().await { batches.push(batch.map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to read batch: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to read batch: {}", e), + }) })?); } @@ -705,13 +701,19 @@ impl ManifestNamespace { /// Helper to get a string column from a record batch fn get_string_column<'a>(batch: &'a RecordBatch, column_name: &str) -> Result<&'a StringArray> { - let column = batch - .column_by_name(column_name) - .ok_or_else(|| Error::io(format!("Column '{}' not found", column_name)))?; + let column = batch.column_by_name(column_name).ok_or_else(|| { + lance_core::Error::from(NamespaceError::Internal { + message: format!("Column '{}' not found", column_name), + }) + })?; column .as_any() .downcast_ref::() - .ok_or_else(|| Error::io(format!("Column '{}' is not a string array", column_name))) + .ok_or_else(|| { + lance_core::Error::from(NamespaceError::Internal { + message: format!("Column '{}' is not a string array", column_name), + }) + }) } /// Check if the manifest contains an object with the given ID @@ -723,27 +725,24 @@ impl ManifestNamespace { let mut scanner = dataset_guard.scan(); scanner.filter(&filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; // Project no columns and enable row IDs for count_rows to work scanner.project::<&str>(&[]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; scanner.with_row_id(); let count = scanner.count_rows().await.map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to count rows: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to count rows: {}", e), + }) })?; Ok(count > 0) @@ -755,16 +754,14 @@ impl ManifestNamespace { let filter = format!("object_id = '{}' AND object_type = 'table'", escaped_id); let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; scanner.project(&["object_id", "location"]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -778,10 +775,13 @@ impl ManifestNamespace { total_rows += batch.num_rows(); if total_rows > 1 { - return Err(Error::io(format!( - "Expected exactly 1 table with id '{}', found {}", - object_id, total_rows - ))); + return Err(NamespaceError::Internal { + message: format!( + "Expected exactly 1 table with id '{}', found {}", + object_id, total_rows + ), + } + .into()); } let object_id_array = Self::get_string_column(&batch, "object_id")?; @@ -804,16 +804,14 @@ impl ManifestNamespace { let filter = "object_type = 'table' AND NOT contains(object_id, '$')"; let mut scanner = self.manifest_scanner().await?; scanner.filter(filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; scanner.project(&["location"]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -924,7 +922,11 @@ impl ManifestNamespace { Arc::new(base_objects_array), ], ) - .map_err(|e| Error::io(format!("Failed to create manifest entries: {}", e)))?; + .map_err(|e| { + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to create manifest entries: {}", e), + }) + })?; let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); @@ -936,10 +938,9 @@ impl ManifestNamespace { let mut merge_builder = MergeInsertBuilder::try_new(dataset_arc, vec!["object_id".to_string()]).map_err( |e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to create merge builder: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to create merge builder: {}", e), + }) }, )?; merge_builder.when_matched(WhenMatched::Fail); @@ -962,10 +963,9 @@ impl ManifestNamespace { let (new_dataset_arc, _merge_stats) = merge_builder .try_build() .map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to build merge: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to build merge: {}", e), + }) })? .execute_reader(Box::new(reader)) .await @@ -1042,16 +1042,14 @@ impl ManifestNamespace { ); let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; scanner.project(&["object_id", "metadata"]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -1112,16 +1110,14 @@ impl ManifestNamespace { ); let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; scanner.project(&["metadata"]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -1176,16 +1172,14 @@ impl ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; scanner.project(&["object_id"]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; let batches = Self::execute_scanner(scanner).await?; let deleted_count: i64 = batches.iter().map(|b| b.num_rows() as i64).sum(); @@ -1254,16 +1248,14 @@ impl ManifestNamespace { // Count how many entries exist so we can report the deleted count let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; scanner.project(&["object_id"]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; let batches = Self::execute_scanner(scanner).await?; let deleted_count: i64 = batches.iter().map(|b| b.num_rows() as i64).sum(); @@ -1321,10 +1313,12 @@ impl ManifestNamespace { .update_metadata([(name, value)]) .await .map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to set property '{}' in __manifest metadata: {}", - name, e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to set property '{}' in __manifest metadata: {}", + name, e + ), + }) })?; Ok(()) } @@ -1419,20 +1413,20 @@ impl ManifestNamespace { version: Box::new(tv), }); } - Err(Error::namespace_source( - format!( - "Version {} not found in manifest for table {:?}", - version, table_id - ) - .into(), - )) + Err(NamespaceError::TableVersionNotFound { + message: format!("version {} for table {:?}", version, table_id), + } + .into()) } /// Register a table in the manifest without creating the physical table (internal helper for migration) pub async fn register_table(&self, name: &str, location: String) -> Result<()> { let object_id = Self::build_object_id(&[], name); if self.manifest_contains_object(&object_id).await? { - return Err(Error::io(format!("Table '{}' already exists", name))); + return Err(NamespaceError::Internal { + message: format!("Table '{}' already exists", name), + } + .into()); } self.insert_into_manifest(object_id, ObjectType::Table, Some(location)) @@ -1445,9 +1439,10 @@ impl ManifestNamespace { let partial_path = &namespace_path[..i]; let object_id = partial_path.join(DELIMITER); if !self.manifest_contains_object(&object_id).await? { - return Err(Error::namespace_source( - format!("Parent namespace '{}' does not exist", object_id).into(), - )); + return Err(NamespaceError::NamespaceNotFound { + message: format!("parent namespace '{}'", object_id), + } + .into()); } } Ok(()) @@ -1459,16 +1454,14 @@ impl ManifestNamespace { let filter = format!("object_id = '{}' AND object_type = 'namespace'", escaped_id); let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; scanner.project(&["object_id", "metadata"]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -1482,10 +1475,13 @@ impl ManifestNamespace { total_rows += batch.num_rows(); if total_rows > 1 { - return Err(Error::io(format!( - "Expected exactly 1 namespace with id '{}', found {}", - object_id, total_rows - ))); + return Err(NamespaceError::Internal { + message: format!( + "Expected exactly 1 namespace with id '{}', found {}", + object_id, total_rows + ), + } + .into()); } let object_id_array = Self::get_string_column(&batch, "object_id")?; @@ -1497,10 +1493,13 @@ impl ManifestNamespace { match serde_json::from_str::>(metadata_str) { Ok(map) => Some(map), Err(e) => { - return Err(Error::io(format!( - "Failed to deserialize metadata for namespace '{}': {}", - object_id, e - ))); + return Err(NamespaceError::Internal { + message: format!( + "Failed to deserialize metadata for namespace '{}': {}", + object_id, e + ), + } + .into()); } } } else { @@ -1569,17 +1568,15 @@ impl ManifestNamespace { .update_field_metadata() .update("object_id", [(LANCE_UNENFORCED_PRIMARY_KEY_POSITION, "0")]) .map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to find object_id field for migration: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to find object_id field for migration: {}", e), + }) })? .await .map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to migrate primary key metadata: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to migrate primary key metadata: {}", e), + }) })?; } @@ -1675,17 +1672,18 @@ impl ManifestNamespace { .load() .await .map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to load manifest dataset after creation conflict: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!( + "Failed to load manifest dataset after creation conflict: {}", + e + ), + }) })?; Ok(DatasetConsistencyWrapper::new(dataset)) } - Err(e) => Err(Error::io_source(box_error(std::io::Error::other(format!( - "Failed to create manifest dataset: {}", - e - ))))), + Err(e) => Err(lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to create manifest dataset: {}", e), + })), } } } @@ -1698,10 +1696,11 @@ impl LanceNamespace for ManifestNamespace { } async fn list_tables(&self, request: ListTablesRequest) -> Result { - let namespace_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Namespace ID is required".into()))?; + let namespace_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Namespace ID is required".to_string(), + }) + })?; // Build filter to find tables in this namespace let filter = if namespace_id.is_empty() { @@ -1720,16 +1719,14 @@ impl LanceNamespace for ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; scanner.project(&["object_id"]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -1752,15 +1749,17 @@ impl LanceNamespace for ManifestNamespace { } async fn describe_table(&self, request: DescribeTableRequest) -> Result { - let table_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Table ID is required".into()))?; + let table_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Table ID is required".to_string(), + }) + })?; if table_id.is_empty() { - return Err(Error::invalid_input_source( - "Table ID cannot be empty".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Table ID cannot be empty".to_string(), + } + .into()); } let object_id = Self::str_object_id(table_id); @@ -1838,22 +1837,25 @@ impl LanceNamespace for ManifestNamespace { } } } - None => Err(Error::namespace_source( - format!("Table '{}' not found", object_id).into(), - )), + None => Err(NamespaceError::TableNotFound { + message: object_id.to_string(), + } + .into()), } } async fn table_exists(&self, request: TableExistsRequest) -> Result<()> { - let table_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Table ID is required".into()))?; + let table_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Table ID is required".to_string(), + }) + })?; if table_id.is_empty() { - return Err(Error::invalid_input_source( - "Table ID cannot be empty".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Table ID cannot be empty".to_string(), + } + .into()); } let (namespace, table_name) = Self::split_object_id(table_id); @@ -1862,9 +1864,10 @@ impl LanceNamespace for ManifestNamespace { if exists { Ok(()) } else { - Err(Error::namespace_source( - format!("Table '{}' not found", table_name).into(), - )) + Err(NamespaceError::TableNotFound { + message: table_name.to_string(), + } + .into()) } } @@ -1873,15 +1876,17 @@ impl LanceNamespace for ManifestNamespace { request: CreateTableRequest, data: Bytes, ) -> Result { - let table_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Table ID is required".into()))?; + let table_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Table ID is required".to_string(), + }) + })?; if table_id.is_empty() { - return Err(Error::invalid_input_source( - "Table ID cannot be empty".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Table ID cannot be empty".to_string(), + } + .into()); } let (namespace, table_name) = Self::split_object_id(table_id); @@ -1889,7 +1894,10 @@ impl LanceNamespace for ManifestNamespace { // Check if table already exists in manifest if self.manifest_contains_object(&object_id).await? { - return Err(Error::io(format!("Table '{}' already exists", table_name))); + return Err(NamespaceError::Internal { + message: format!("Table '{}' already exists", table_name), + } + .into()); } // Create the physical table location with hash-based naming @@ -1906,23 +1914,33 @@ impl LanceNamespace for ManifestNamespace { // Validate that request_data is provided if data.is_empty() { - return Err(Error::namespace_source( - "Request data (Arrow IPC stream) is required for create_table".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Request data (Arrow IPC stream) is required for create_table".to_string(), + } + .into()); } // Write the data using Lance Dataset let cursor = Cursor::new(data.to_vec()); - let stream_reader = StreamReader::try_new(cursor, None) - .map_err(|e| Error::io(format!("Failed to read IPC stream: {}", e)))?; + let stream_reader = StreamReader::try_new(cursor, None).map_err(|e| { + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to read IPC stream: {}", e), + }) + })?; - let batches: Vec = - stream_reader - .collect::, _>>() - .map_err(|e| Error::io(format!("Failed to collect batches: {}", e)))?; + let batches: Vec = stream_reader + .collect::, _>>() + .map_err(|e| { + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to collect batches: {}", e), + }) + })?; if batches.is_empty() { - return Err(Error::io("No data provided for table creation")); + return Err(NamespaceError::Internal { + message: "No data provided for table creation".to_string(), + } + .into()); } let schema = batches[0].schema(); @@ -1948,10 +1966,9 @@ impl LanceNamespace for ManifestNamespace { let _dataset = Dataset::write(Box::new(reader), &table_uri, Some(write_params)) .await .map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to write dataset: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to write dataset: {}", e), + }) })?; // Register in manifest (store dir_name, not full URI) @@ -1967,15 +1984,17 @@ impl LanceNamespace for ManifestNamespace { } async fn drop_table(&self, request: DropTableRequest) -> Result { - let table_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Table ID is required".into()))?; + let table_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Table ID is required".to_string(), + }) + })?; if table_id.is_empty() { - return Err(Error::invalid_input_source( - "Table ID cannot be empty".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Table ID cannot be empty".to_string(), + } + .into()); } let (namespace, table_name) = Self::split_object_id(table_id); @@ -1999,9 +2018,9 @@ impl LanceNamespace for ManifestNamespace { .boxed() .await .map_err(|e| { - Error::namespace_source( - format!("Failed to delete table directory: {}", e).into(), - ) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to delete table directory: {}", e), + }) })?; Ok(DropTableResponse { @@ -2010,9 +2029,10 @@ impl LanceNamespace for ManifestNamespace { ..Default::default() }) } - None => Err(Error::namespace_source( - format!("Table '{}' not found", table_name).into(), - )), + None => Err(NamespaceError::TableNotFound { + message: table_name.to_string(), + } + .into()), } } @@ -2020,10 +2040,11 @@ impl LanceNamespace for ManifestNamespace { &self, request: ListNamespacesRequest, ) -> Result { - let parent_namespace = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Namespace ID is required".into()))?; + let parent_namespace = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Namespace ID is required".to_string(), + }) + })?; // Build filter to find direct child namespaces let filter = if parent_namespace.is_empty() { @@ -2042,16 +2063,14 @@ impl LanceNamespace for ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; scanner.project(&["object_id"]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -2077,10 +2096,11 @@ impl LanceNamespace for ManifestNamespace { &self, request: DescribeNamespaceRequest, ) -> Result { - let namespace_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Namespace ID is required".into()))?; + let namespace_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Namespace ID is required".to_string(), + }) + })?; // Root namespace always exists if namespace_id.is_empty() { @@ -2101,9 +2121,10 @@ impl LanceNamespace for ManifestNamespace { properties: info.metadata, ..Default::default() }), - None => Err(Error::namespace_source( - format!("Namespace '{}' not found", object_id).into(), - )), + None => Err(NamespaceError::NamespaceNotFound { + message: object_id.to_string(), + } + .into()), } } @@ -2111,16 +2132,18 @@ impl LanceNamespace for ManifestNamespace { &self, request: CreateNamespaceRequest, ) -> Result { - let namespace_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Namespace ID is required".into()))?; + let namespace_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Namespace ID is required".to_string(), + }) + })?; // Root namespace always exists and cannot be created if namespace_id.is_empty() { - return Err(Error::namespace_source( - "Root namespace already exists and cannot be created".into(), - )); + return Err(NamespaceError::NamespaceAlreadyExists { + message: "root namespace".to_string(), + } + .into()); } // Validate parent namespaces exist (but not the namespace being created) @@ -2131,9 +2154,10 @@ impl LanceNamespace for ManifestNamespace { let object_id = namespace_id.join(DELIMITER); if self.manifest_contains_object(&object_id).await? { - return Err(Error::namespace_source( - format!("Namespace '{}' already exists", object_id).into(), - )); + return Err(NamespaceError::NamespaceAlreadyExists { + message: object_id.to_string(), + } + .into()); } // Serialize properties if provided @@ -2163,25 +2187,28 @@ impl LanceNamespace for ManifestNamespace { } async fn drop_namespace(&self, request: DropNamespaceRequest) -> Result { - let namespace_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Namespace ID is required".into()))?; + let namespace_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Namespace ID is required".to_string(), + }) + })?; // Root namespace always exists and cannot be dropped if namespace_id.is_empty() { - return Err(Error::namespace_source( - "Root namespace cannot be dropped".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Root namespace cannot be dropped".to_string(), + } + .into()); } let object_id = namespace_id.join(DELIMITER); // Check if namespace exists if !self.manifest_contains_object(&object_id).boxed().await? { - return Err(Error::namespace_source( - format!("Namespace '{}' not found", object_id).into(), - )); + return Err(NamespaceError::NamespaceNotFound { + message: object_id.to_string(), + } + .into()); } // Check for child namespaces @@ -2190,33 +2217,27 @@ impl LanceNamespace for ManifestNamespace { let filter = format!("starts_with(object_id, '{}')", prefix); let mut scanner = self.manifest_scanner().boxed().await?; scanner.filter(&filter).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to filter: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to filter: {}", e), + }) })?; scanner.project::<&str>(&[]).map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to project: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to project: {}", e), + }) })?; scanner.with_row_id(); let count = scanner.count_rows().boxed().await.map_err(|e| { - Error::io_source(box_error(std::io::Error::other(format!( - "Failed to count rows: {}", - e - )))) + lance_core::Error::from(NamespaceError::Internal { + message: format!("Failed to count rows: {}", e), + }) })?; if count > 0 { - return Err(Error::namespace_source( - format!( - "Namespace '{}' is not empty (contains {} child objects)", - object_id, count - ) - .into(), - )); + return Err(NamespaceError::NamespaceNotEmpty { + message: format!("'{}' (contains {} child objects)", object_id, count), + } + .into()); } self.delete_from_manifest(&object_id).boxed().await?; @@ -2225,10 +2246,11 @@ impl LanceNamespace for ManifestNamespace { } async fn namespace_exists(&self, request: NamespaceExistsRequest) -> Result<()> { - let namespace_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Namespace ID is required".into()))?; + let namespace_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Namespace ID is required".to_string(), + }) + })?; // Root namespace always exists if namespace_id.is_empty() { @@ -2239,22 +2261,25 @@ impl LanceNamespace for ManifestNamespace { if self.manifest_contains_object(&object_id).await? { Ok(()) } else { - Err(Error::namespace_source( - format!("Namespace '{}' not found", object_id).into(), - )) + Err(NamespaceError::NamespaceNotFound { + message: object_id.to_string(), + } + .into()) } } async fn declare_table(&self, request: DeclareTableRequest) -> Result { - let table_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Table ID is required".into()))?; + let table_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Table ID is required".to_string(), + }) + })?; if table_id.is_empty() { - return Err(Error::invalid_input_source( - "Table ID cannot be empty".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Table ID cannot be empty".to_string(), + } + .into()); } let (namespace, table_name) = Self::split_object_id(table_id); @@ -2263,9 +2288,10 @@ impl LanceNamespace for ManifestNamespace { // Check if table already exists in manifest let existing = self.query_manifest_for_table(&object_id).await?; if existing.is_some() { - return Err(Error::namespace_source( - format!("Table '{}' already exists", table_name).into(), - )); + return Err(NamespaceError::TableAlreadyExists { + message: table_name.to_string(), + } + .into()); } // Create table location path with hash-based naming @@ -2285,13 +2311,13 @@ impl LanceNamespace for ManifestNamespace { if let Some(req_location) = &request.location { let req_location = req_location.trim_end_matches('/'); if req_location != table_uri { - return Err(Error::namespace_source( - format!( + return Err(NamespaceError::InvalidInput { + message: format!( "Cannot declare table {} at location {}, must be at location {}", table_name, req_location, table_uri - ) - .into(), - )); + ), + } + .into()); } } @@ -2302,24 +2328,22 @@ impl LanceNamespace for ManifestNamespace { .create(&reserved_file_path) .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to create .lance-reserved file for table {}: {}", table_name, e - ) - .into(), - ) + ), + }) })? .shutdown() .await .map_err(|e| { - Error::namespace_source( - format!( + lance_core::Error::from(NamespaceError::Internal { + message: format!( "Failed to finalize .lance-reserved file for table {}: {}", table_name, e - ) - .into(), - ) + ), + }) })?; // Add entry to manifest marking this as a declared table (store dir_name, not full path) @@ -2348,15 +2372,17 @@ impl LanceNamespace for ManifestNamespace { } async fn register_table(&self, request: RegisterTableRequest) -> Result { - let table_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Table ID is required".into()))?; + let table_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Table ID is required".to_string(), + }) + })?; if table_id.is_empty() { - return Err(Error::invalid_input_source( - "Table ID cannot be empty".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Table ID cannot be empty".to_string(), + } + .into()); } let location = request.location.clone(); @@ -2364,25 +2390,34 @@ impl LanceNamespace for ManifestNamespace { // Validate that location is a relative path within the root directory // We don't allow absolute URIs or paths that escape the root if location.contains("://") { - return Err(Error::invalid_input_source(format!( - "Absolute URIs are not allowed for register_table. Location must be a relative path within the root directory: {}", - location - ).into())); + return Err(NamespaceError::InvalidInput { + message: format!( + "Absolute URIs are not allowed for register_table. Location must be a relative path within the root directory: {}", + location + ), + } + .into()); } if location.starts_with('/') { - return Err(Error::invalid_input_source(format!( - "Absolute paths are not allowed for register_table. Location must be a relative path within the root directory: {}", - location - ).into())); + return Err(NamespaceError::InvalidInput { + message: format!( + "Absolute paths are not allowed for register_table. Location must be a relative path within the root directory: {}", + location + ), + } + .into()); } // Check for path traversal attempts if location.contains("..") { - return Err(Error::invalid_input_source(format!( - "Path traversal is not allowed. Location must be a relative path within the root directory: {}", - location - ).into())); + return Err(NamespaceError::InvalidInput { + message: format!( + "Path traversal is not allowed. Location must be a relative path within the root directory: {}", + location + ), + } + .into()); } let (namespace, table_name) = Self::split_object_id(table_id); @@ -2395,9 +2430,10 @@ impl LanceNamespace for ManifestNamespace { // Check if table already exists if self.manifest_contains_object(&object_id).await? { - return Err(Error::namespace_source( - format!("Table '{}' already exists", object_id).into(), - )); + return Err(NamespaceError::TableAlreadyExists { + message: object_id.to_string(), + } + .into()); } // Register the table with its location in the manifest @@ -2414,15 +2450,17 @@ impl LanceNamespace for ManifestNamespace { &self, request: DeregisterTableRequest, ) -> Result { - let table_id = request - .id - .as_ref() - .ok_or_else(|| Error::invalid_input_source("Table ID is required".into()))?; + let table_id = request.id.as_ref().ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Table ID is required".to_string(), + }) + })?; if table_id.is_empty() { - return Err(Error::invalid_input_source( - "Table ID cannot be empty".into(), - )); + return Err(NamespaceError::InvalidInput { + message: "Table ID cannot be empty".to_string(), + } + .into()); } let (namespace, table_name) = Self::split_object_id(table_id); @@ -2438,9 +2476,10 @@ impl LanceNamespace for ManifestNamespace { Self::construct_full_uri(&self.root, &info.location)? } None => { - return Err(Error::namespace_source( - format!("Table '{}' not found", object_id).into(), - )); + return Err(NamespaceError::TableNotFound { + message: object_id.to_string(), + } + .into()); } }; diff --git a/rust/lance-namespace-impls/src/rest.rs b/rust/lance-namespace-impls/src/rest.rs index 42fcbe540c4..be850fd885d 100644 --- a/rust/lance-namespace-impls/src/rest.rs +++ b/rust/lance-namespace-impls/src/rest.rs @@ -43,9 +43,10 @@ use lance_namespace::models::{ }; use serde::{Serialize, de::DeserializeOwned}; -use lance_core::{Error, Result, box_error}; +use lance_core::{Error, Result}; use lance_namespace::LanceNamespace; +use lance_namespace::error::NamespaceError; /// HTTP client wrapper that supports per-request header injection. /// @@ -250,7 +251,9 @@ impl RestNamespaceBuilder { pub fn from_properties(properties: HashMap) -> Result { // Extract URI (required) let uri = properties.get("uri").cloned().ok_or_else(|| { - Error::namespace_source("Missing required property 'uri' for REST namespace".into()) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "Missing required property 'uri' for REST namespace".to_string(), + }) })?; // Extract delimiter (optional) @@ -415,7 +418,10 @@ fn object_id_str(id: &Option>, delimiter: &str) -> Result { match id { Some(id_parts) if !id_parts.is_empty() => Ok(id_parts.join(delimiter)), Some(_) => Ok(delimiter.to_string()), - None => Err(Error::namespace_source("Object ID is required".into())), + None => Err(NamespaceError::InvalidInput { + message: "Object ID is required".to_string(), + } + .into()), } } @@ -494,6 +500,96 @@ impl RestNamespace { } } + /// Parse an error response body and return the appropriate NamespaceError. + /// + /// Attempts to parse a JSON body with `{"error": {"code": N, "message": "..."}}`. + /// Falls back to mapping the HTTP status code (using the operation to disambiguate) + /// if the JSON body doesn't contain an error code. + fn parse_error_response( + status: reqwest::StatusCode, + content: &str, + operation: &str, + ) -> lance_core::Error { + if let Ok(json) = serde_json::from_str::(content) + && let Some(error_obj) = json.get("error") + { + let code = error_obj + .get("code") + .and_then(|c| c.as_u64()) + .map(|c| c as u32); + let message = error_obj + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or(content); + + if let Some(code) = code { + return NamespaceError::from_code(code, message).into(); + } + } + + let message = format!("Response error: status={}, content={}", status, content); + Self::error_from_status(status, operation, message).into() + } + + /// Map an HTTP status code to a NamespaceError variant. + /// + /// For unambiguous status codes (401, 403, 429, 501, 503) the mapping is direct. + /// For 404 and 409 the `operation` string is used to select the appropriate + /// "not found" or "already exists" variant. + fn error_from_status( + status: reqwest::StatusCode, + operation: &str, + message: String, + ) -> NamespaceError { + match status.as_u16() { + 400 => NamespaceError::InvalidInput { message }, + 401 => NamespaceError::Unauthenticated { message }, + 403 => NamespaceError::PermissionDenied { message }, + 404 => Self::not_found_for_operation(operation, message), + 409 => Self::already_exists_for_operation(operation, message), + 429 => NamespaceError::Throttled { message }, + 501 => NamespaceError::Unsupported { message }, + 503 => NamespaceError::ServiceUnavailable { message }, + _ => NamespaceError::Internal { message }, + } + } + + /// Pick the appropriate "not found" variant based on the operation. + fn not_found_for_operation(operation: &str, message: String) -> NamespaceError { + if operation.contains("namespace") { + NamespaceError::NamespaceNotFound { message } + } else if operation.contains("index") { + NamespaceError::TableIndexNotFound { message } + } else if operation.contains("tag") { + NamespaceError::TableTagNotFound { message } + } else if operation.contains("transaction") { + NamespaceError::TransactionNotFound { message } + } else if operation.contains("version") { + NamespaceError::TableVersionNotFound { message } + } else if operation.contains("column") { + NamespaceError::TableColumnNotFound { message } + } else if operation.contains("table") { + NamespaceError::TableNotFound { message } + } else { + NamespaceError::Internal { message } + } + } + + /// Pick the appropriate "already exists" variant based on the operation. + fn already_exists_for_operation(operation: &str, message: String) -> NamespaceError { + if operation.contains("namespace") { + NamespaceError::NamespaceAlreadyExists { message } + } else if operation.contains("index") { + NamespaceError::TableIndexAlreadyExists { message } + } else if operation.contains("tag") { + NamespaceError::TableTagAlreadyExists { message } + } else if operation.contains("table") { + NamespaceError::TableAlreadyExists { message } + } else { + NamespaceError::Internal { message } + } + } + /// Execute a GET request and parse JSON response. async fn get_json( &self, @@ -509,22 +605,28 @@ impl RestNamespace { .rest_client .execute(req_builder, operation, object_id) .await - .map_err(|e| Error::io_source(box_error(e)))?; + .map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to execute request: {}", e), + }) + })?; let status = resp.status(); - let content = resp - .text() - .await - .map_err(|e| Error::io_source(box_error(e)))?; + let content = resp.text().await.map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to read response body: {}", e), + }) + })?; if status.is_success() { serde_json::from_str(&content).map_err(|e| { - Error::namespace_source(format!("Failed to parse response: {}", e).into()) + NamespaceError::Internal { + message: format!("Failed to parse response: {}", e), + } + .into() }) } else { - Err(Error::namespace_source( - format!("Response error: status={}, content={}", status, content).into(), - )) + Err(Self::parse_error_response(status, &content, operation)) } } @@ -544,22 +646,28 @@ impl RestNamespace { .rest_client .execute(req_builder, operation, object_id) .await - .map_err(|e| Error::io_source(box_error(e)))?; + .map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to execute request: {}", e), + }) + })?; let status = resp.status(); - let content = resp - .text() - .await - .map_err(|e| Error::io_source(box_error(e)))?; + let content = resp.text().await.map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to read response body: {}", e), + }) + })?; if status.is_success() { serde_json::from_str(&content).map_err(|e| { - Error::namespace_source(format!("Failed to parse response: {}", e).into()) + NamespaceError::Internal { + message: format!("Failed to parse response: {}", e), + } + .into() }) } else { - Err(Error::namespace_source( - format!("Response error: status={}, content={}", status, content).into(), - )) + Err(Self::parse_error_response(status, &content, operation)) } } @@ -579,19 +687,22 @@ impl RestNamespace { .rest_client .execute(req_builder, operation, object_id) .await - .map_err(|e| Error::io_source(box_error(e)))?; + .map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to execute request: {}", e), + }) + })?; let status = resp.status(); if status.is_success() { Ok(()) } else { - let content = resp - .text() - .await - .map_err(|e| Error::io_source(box_error(e)))?; - Err(Error::namespace_source( - format!("Response error: status={}, content={}", status, content).into(), - )) + let content = resp.text().await.map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to read response body: {}", e), + }) + })?; + Err(Self::parse_error_response(status, &content, operation)) } } @@ -611,22 +722,28 @@ impl RestNamespace { .rest_client .execute(req_builder, operation, object_id) .await - .map_err(|e| Error::io_source(box_error(e)))?; + .map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to execute request: {}", e), + }) + })?; let status = resp.status(); - let content = resp - .text() - .await - .map_err(|e| Error::io_source(box_error(e)))?; + let content = resp.text().await.map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to read response body: {}", e), + }) + })?; if status.is_success() { serde_json::from_str(&content).map_err(|e| { - Error::namespace_source(format!("Failed to parse response: {}", e).into()) + NamespaceError::Internal { + message: format!("Failed to parse response: {}", e), + } + .into() }) } else { - Err(Error::namespace_source( - format!("Response error: status={}, content={}", status, content).into(), - )) + Err(Self::parse_error_response(status, &content, operation)) } } @@ -647,21 +764,26 @@ impl RestNamespace { .rest_client .execute(req_builder, operation, object_id) .await - .map_err(|e| Error::io_source(box_error(e)))?; + .map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to execute request: {}", e), + }) + })?; let status = resp.status(); if status.is_success() { - resp.bytes() - .await - .map_err(|e| Error::io_source(box_error(e))) + resp.bytes().await.map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to read response bytes: {}", e), + }) + }) } else { - let content = resp - .text() - .await - .map_err(|e| Error::io_source(box_error(e)))?; - Err(Error::namespace_source( - format!("Response error: status={}, content={}", status, content).into(), - )) + let content = resp.text().await.map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to read response body: {}", e), + }) + })?; + Err(Self::parse_error_response(status, &content, operation)) } } @@ -880,7 +1002,9 @@ impl LanceNamespace for RestNamespace { let encoded_id = urlencode(&id); let on = request.on.as_deref().ok_or_else(|| { - Error::namespace_source("'on' field is required for merge insert".into()) + lance_core::Error::from(NamespaceError::InvalidInput { + message: "'on' field is required for merge insert".to_string(), + }) })?; let path = format!("/v1/table/{}/merge_insert", encoded_id); @@ -961,6 +1085,7 @@ impl LanceNamespace for RestNamespace { let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/query", encoded_id); let query = [("delimiter", self.delimiter.as_str())]; + let operation = "query_table"; let url = format!("{}{}", self.rest_client.base_path(), path); let req_builder = self @@ -972,23 +1097,28 @@ impl LanceNamespace for RestNamespace { let resp = self .rest_client - .execute(req_builder, "query_table", &id) + .execute(req_builder, operation, &id) .await - .map_err(|e| Error::io_source(box_error(e)))?; + .map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to execute request: {}", e), + }) + })?; let status = resp.status(); if status.is_success() { - resp.bytes() - .await - .map_err(|e| Error::io_source(box_error(e))) + resp.bytes().await.map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to read response bytes: {}", e), + }) + }) } else { - let content = resp - .text() - .await - .map_err(|e| Error::io_source(box_error(e)))?; - Err(Error::namespace_source( - format!("Response error: status={}, content={}", status, content).into(), - )) + let content = resp.text().await.map_err(|e| { + Error::from(NamespaceError::Internal { + message: format!("Failed to read response body: {}", e), + }) + })?; + Err(Self::parse_error_response(status, &content, operation)) } } diff --git a/rust/lance-namespace-impls/src/rest_adapter.rs b/rust/lance-namespace-impls/src/rest_adapter.rs index ee696ee1a32..c126b219f54 100644 --- a/rust/lance-namespace-impls/src/rest_adapter.rs +++ b/rust/lance-namespace-impls/src/rest_adapter.rs @@ -25,6 +25,7 @@ use tower_http::trace::TraceLayer; use lance_core::{Error, Result}; use lance_namespace::LanceNamespace; +use lance_namespace::error::NamespaceError; use lance_namespace::models::*; /// Configuration for the REST server @@ -153,7 +154,9 @@ impl RestAdapter { let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| { log::error!("RestAdapter::start() failed to bind to {}: {}", addr, e); - Error::io_source(Box::new(e)) + Error::from(NamespaceError::Internal { + message: format!("Failed to bind to {}: {}", addr, e), + }) })?; // Get the actual port (important when port 0 was specified) @@ -249,40 +252,62 @@ struct PaginationQuery { // Error Conversion // ============================================================================ +/// Map a NamespaceError error code to an HTTP status code. +fn error_code_to_status(code: u32) -> StatusCode { + match lance_namespace::error::ErrorCode::from_u32(code) { + Some(lance_namespace::error::ErrorCode::NamespaceNotFound) + | Some(lance_namespace::error::ErrorCode::TableNotFound) + | Some(lance_namespace::error::ErrorCode::TableIndexNotFound) + | Some(lance_namespace::error::ErrorCode::TableTagNotFound) + | Some(lance_namespace::error::ErrorCode::TransactionNotFound) + | Some(lance_namespace::error::ErrorCode::TableVersionNotFound) + | Some(lance_namespace::error::ErrorCode::TableColumnNotFound) => StatusCode::NOT_FOUND, + Some(lance_namespace::error::ErrorCode::NamespaceAlreadyExists) + | Some(lance_namespace::error::ErrorCode::TableAlreadyExists) + | Some(lance_namespace::error::ErrorCode::TableIndexAlreadyExists) + | Some(lance_namespace::error::ErrorCode::TableTagAlreadyExists) + | Some(lance_namespace::error::ErrorCode::ConcurrentModification) => StatusCode::CONFLICT, + Some(lance_namespace::error::ErrorCode::InvalidInput) + | Some(lance_namespace::error::ErrorCode::InvalidTableState) + | Some(lance_namespace::error::ErrorCode::TableSchemaValidationError) + | Some(lance_namespace::error::ErrorCode::NamespaceNotEmpty) => StatusCode::BAD_REQUEST, + Some(lance_namespace::error::ErrorCode::Unsupported) => StatusCode::NOT_IMPLEMENTED, + Some(lance_namespace::error::ErrorCode::PermissionDenied) => StatusCode::FORBIDDEN, + Some(lance_namespace::error::ErrorCode::Unauthenticated) => StatusCode::UNAUTHORIZED, + Some(lance_namespace::error::ErrorCode::ServiceUnavailable) => { + StatusCode::SERVICE_UNAVAILABLE + } + Some(lance_namespace::error::ErrorCode::Throttled) => StatusCode::TOO_MANY_REQUESTS, + Some(lance_namespace::error::ErrorCode::Internal) | None => { + StatusCode::INTERNAL_SERVER_ERROR + } + } +} + /// Convert Lance errors to HTTP responses fn error_to_response(err: Error) -> Response { match err { Error::Namespace { source, .. } => { - let error_msg = source.to_string(); - if error_msg.contains("not found") || error_msg.contains("does not exist") { - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": { - "message": error_msg, - "type": "NamespaceNotFoundException" - } - })), - ) - .into_response() - } else if error_msg.contains("already exists") { + if let Some(ns_err) = source.downcast_ref::() { + let code = ns_err.code().as_u32(); + let status = error_code_to_status(code); ( - StatusCode::BAD_REQUEST, + status, Json(serde_json::json!({ "error": { - "message": error_msg, - "type": "TableAlreadyExistsException" + "message": ns_err.message(), + "code": code } })), ) .into_response() } else { ( - StatusCode::BAD_REQUEST, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": { - "message": error_msg, - "type": "NamespaceException" + "message": source.to_string(), + "code": 18 } })), ) @@ -2207,8 +2232,8 @@ mod tests { ); let err_msg = result.unwrap_err().to_string(); assert!( - err_msg.contains("is not empty"), - "Error should be 'is not empty', got: {}", + err_msg.contains("not empty"), + "Error should contain 'not empty', got: {}", err_msg ); } @@ -2308,8 +2333,8 @@ mod tests { assert!(result.is_err(), "Cannot create root namespace"); let err_msg = result.unwrap_err().to_string(); assert!( - err_msg.contains("Root namespace already exists and cannot be created"), - "Error should be 'Root namespace already exists and cannot be created', got: {}", + err_msg.contains("already exists") && err_msg.contains("root namespace"), + "Error should contain 'already exists' and 'root namespace', got: {}", err_msg ); diff --git a/rust/lance-namespace/src/error.rs b/rust/lance-namespace/src/error.rs index 78b850b5e61..cb367215e0a 100644 --- a/rust/lance-namespace/src/error.rs +++ b/rust/lance-namespace/src/error.rs @@ -263,6 +263,38 @@ pub enum NamespaceError { } impl NamespaceError { + /// Returns the inner message without the Display prefix. + /// + /// Useful when serializing across boundaries (e.g. REST) where + /// the receiver will reconstruct the variant from the error code + /// and re-apply its own Display formatting. + pub fn message(&self) -> &str { + match self { + Self::Unsupported { message } + | Self::NamespaceNotFound { message } + | Self::NamespaceAlreadyExists { message } + | Self::NamespaceNotEmpty { message } + | Self::TableNotFound { message } + | Self::TableAlreadyExists { message } + | Self::TableIndexNotFound { message } + | Self::TableIndexAlreadyExists { message } + | Self::TableTagNotFound { message } + | Self::TableTagAlreadyExists { message } + | Self::TransactionNotFound { message } + | Self::TableVersionNotFound { message } + | Self::TableColumnNotFound { message } + | Self::InvalidInput { message } + | Self::ConcurrentModification { message } + | Self::PermissionDenied { message } + | Self::Unauthenticated { message } + | Self::ServiceUnavailable { message } + | Self::Internal { message } + | Self::InvalidTableState { message } + | Self::TableSchemaValidationError { message } + | Self::Throttled { message } => message, + } + } + /// Returns the error code for this error. /// /// Use this for programmatic error handling across language boundaries. From 2ad415a71aac8c01b0f20f5f6e9ed81056d4378a Mon Sep 17 00:00:00 2001 From: Esteban Gutierrez Date: Thu, 26 Mar 2026 13:45:13 -0500 Subject: [PATCH 083/206] fix: multiple improvements for gh workflows (#6306) Co-authored-by: Esteban Gutierrez --- .github/workflows/claude-code-review.yml | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 3247ab3269f..00b54f0f29e 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,7 +1,7 @@ name: Claude Code Review on: - pull_request_target: + pull_request: types: [opened] # Optional: Only run on specific file changes # paths: @@ -14,14 +14,7 @@ jobs: claude-review: if: | github.event.pull_request.author_association == 'MEMBER' || - github.event.pull_request.author_association == 'COLLABORATOR' || - github.event.pull_request.author_association == 'OWNER' || - github.event.pull_request.author_association == 'CONTRIBUTOR' - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + github.event.pull_request.author_association == 'OWNER' runs-on: ubuntu-latest permissions: @@ -37,11 +30,12 @@ jobs: - name: Run Claude Code Review id: claude-review - uses: anthropics/claude-code-action@26ec041249acb0a944c0a47b6c0c13f05dbc5b44 # v1 + uses: anthropics/claude-code-action@3ac52d0da9f8ec9ca7b4dc23bb477e36ef9c77a9 # v1.0.79 + env: + CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1' with: anthropic_api_key: ${{ secrets.CLAUDE_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} - allowed_non_write_users: ${{ github.event.pull_request.user.login }} prompt: | REPO: ${{ github.repository }} PR NUMBER: ${{ github.event.pull_request.number }} @@ -68,5 +62,5 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options claude_args: | - --allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)" + --allowed-tools "Bash(gh pr comment ${{ github.event.pull_request.number }}:*),Bash(gh pr diff ${{ github.event.pull_request.number }}:*),Bash(gh pr view ${{ github.event.pull_request.number }}:*)" --model "claude-opus-4-6" From f0aa55f10cfa942285ca530eb49cccfca184f041 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 27 Mar 2026 04:33:37 +0800 Subject: [PATCH 084/206] feat!: support sampling selected fragments (#6294) --- rust/lance/benches/take.rs | 2 +- rust/lance/src/dataset.rs | 75 +++++++++++-- rust/lance/src/dataset/scanner.rs | 2 +- rust/lance/src/dataset/take.rs | 7 +- rust/lance/src/dataset/tests/dataset_io.rs | 112 +++++++++++++++++++ rust/lance/src/dataset/write/merge_insert.rs | 2 +- rust/lance/src/index/vector/utils.rs | 2 +- 7 files changed, 187 insertions(+), 15 deletions(-) diff --git a/rust/lance/benches/take.rs b/rust/lance/benches/take.rs index 68d9c963ef9..ec078d0f636 100644 --- a/rust/lance/benches/take.rs +++ b/rust/lance/benches/take.rs @@ -376,7 +376,7 @@ fn bench_sample(c: &mut Criterion) { let schema = schema.clone(); let dataset = dataset.clone(); async move { - dataset.sample(sample_size, &schema).await.unwrap(); + dataset.sample(sample_size, &schema, None).await.unwrap(); } }) }, diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 8f3deb2bef6..4d9af7bf04e 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -55,7 +55,7 @@ use roaring::RoaringBitmap; use rowids::get_row_id_index; use serde::{Deserialize, Serialize}; use std::borrow::Cow; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt::Debug; use std::ops::Range; use std::pin::Pin; @@ -1467,7 +1467,8 @@ impl Dataset { row_indices: &[u64], column: impl AsRef, ) -> Result> { - let row_addrs = row_offsets_to_row_addresses(self, row_indices).await?; + let fragments = self.get_fragments(); + let row_addrs = row_offsets_to_row_addresses(&fragments, row_indices).await?; blob::take_blobs_by_addresses(self, &row_addrs, column.as_ref()).await } @@ -1485,14 +1486,74 @@ impl Dataset { /// Randomly sample `n` rows from the dataset. /// + /// If `fragment_ids` is provided, sampling is limited to rows from those + /// fragments in the current dataset version. + /// /// The returned rows are in row-id order (not random order), which allows /// the underlying take operation to use an efficient sorted code path. - pub async fn sample(&self, n: usize, projection: &Schema) -> Result { + pub async fn sample( + &self, + n: usize, + projection: &Schema, + fragment_ids: Option<&[u32]>, + ) -> Result { use rand::seq::IteratorRandom; - let num_rows = self.count_rows(None).await?; - let mut ids = (0..num_rows as u64).choose_multiple(&mut rand::rng(), n); - ids.sort_unstable(); - self.take(&ids, projection.clone()).await + + match fragment_ids { + None => { + let num_rows = self.count_rows(None).await?; + let mut ids = (0..num_rows as u64).choose_multiple(&mut rand::rng(), n); + ids.sort_unstable(); + self.take(&ids, projection.clone()).await + } + Some(fragment_ids) => { + if fragment_ids.is_empty() { + return Err(Error::invalid_input( + "Dataset::sample does not accept an empty fragment_ids list".to_string(), + )); + } + + let selected_fragment_ids = fragment_ids.iter().copied().collect::>(); + let selected_fragments = self + .get_fragments() + .into_iter() + .filter(|fragment| selected_fragment_ids.contains(&(fragment.id() as u32))) + .collect::>(); + + if selected_fragments.len() != selected_fragment_ids.len() { + let present_fragment_ids = selected_fragments + .iter() + .map(|fragment| fragment.id() as u32) + .collect::>(); + let missing_fragment_ids = selected_fragment_ids + .into_iter() + .filter(|fragment_id| !present_fragment_ids.contains(fragment_id)) + .collect::>(); + return Err(Error::invalid_input(format!( + "Dataset::sample received fragment ids that are not part of the current dataset version: {missing_fragment_ids:?}", + ))); + } + + let num_rows = stream::iter(selected_fragments.iter().cloned()) + .map(|fragment| async move { fragment.count_rows(None).await }) + .buffer_unordered(16) + .try_fold(0_u64, |acc, rows| async move { Ok(acc + rows as u64) }) + .await?; + + let mut offsets = (0..num_rows).choose_multiple(&mut rand::rng(), n); + offsets.sort_unstable(); + + let row_addrs = row_offsets_to_row_addresses(&selected_fragments, &offsets).await?; + let dataset = Arc::new(self.clone()); + let projection = Arc::new( + ProjectionRequest::from(projection.clone()) + .into_projection_plan(dataset.clone())?, + ); + TakeBuilder::try_new_from_addresses(dataset, row_addrs, projection)? + .execute() + .await + } + } } /// Delete rows based on a predicate. diff --git a/rust/lance/src/dataset/scanner.rs b/rust/lance/src/dataset/scanner.rs index 34483bfd847..48ac7dbd8ac 100644 --- a/rust/lance/src/dataset/scanner.rs +++ b/rust/lance/src/dataset/scanner.rs @@ -2719,7 +2719,7 @@ impl Scanner { TakeOperation::RowAddrs(addrs) => self.u64s_as_take_input(addrs), TakeOperation::RowOffsets(offsets) => { let mut addrs = - row_offsets_to_row_addresses(self.dataset.as_ref(), &offsets).await?; + row_offsets_to_row_addresses(&self.dataset.get_fragments(), &offsets).await?; addrs.retain(|addr| *addr != RowAddress::TOMBSTONE_ROW); self.u64s_as_take_input(addrs) } diff --git a/rust/lance/src/dataset/take.rs b/rust/lance/src/dataset/take.rs index 73625a171e0..68121410f01 100644 --- a/rust/lance/src/dataset/take.rs +++ b/rust/lance/src/dataset/take.rs @@ -44,11 +44,9 @@ use super::{Dataset, fragment::FileFragment, scanner::DatasetRecordBatchStream}; /// /// If any offsets are beyond the end of the dataset, they will be mapped to a tombstone row address. pub(super) async fn row_offsets_to_row_addresses( - dataset: &Dataset, + fragments: &[FileFragment], row_indices: &[u64], ) -> Result> { - let fragments = dataset.get_fragments(); - let mut perm = permutation::sort(row_indices); let sorted_offsets = perm.apply_slice(row_indices); @@ -115,7 +113,8 @@ pub async fn take( } // First, convert the dataset offsets into row addresses - let addrs = row_offsets_to_row_addresses(dataset, offsets).await?; + let fragments = dataset.get_fragments(); + let addrs = row_offsets_to_row_addresses(&fragments, offsets).await?; let builder = TakeBuilder::try_new_from_addresses( Arc::new(dataset.clone()), diff --git a/rust/lance/src/dataset/tests/dataset_io.rs b/rust/lance/src/dataset/tests/dataset_io.rs index 86ae416b1be..dbe03ed5122 100644 --- a/rust/lance/src/dataset/tests/dataset_io.rs +++ b/rust/lance/src/dataset/tests/dataset_io.rs @@ -1438,6 +1438,118 @@ async fn test_fast_count_rows( ); } +#[rstest] +#[tokio::test] +async fn test_sample_with_fragment_ids( + #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)] + data_storage_version: LanceFileVersion, +) { + let test_uri = TempStrDir::default(); + let data = gen_batch() + .col("i", array::step::()) + .into_reader_rows(RowCount::from(12), BatchCount::from(1)); + let mut dataset = Dataset::write( + data, + &test_uri, + Some(WriteParams { + max_rows_per_file: 4, + max_rows_per_group: 2, + data_storage_version: Some(data_storage_version), + ..Default::default() + }), + ) + .await + .unwrap(); + + dataset.delete("i IN (1, 9)").await.unwrap(); + + let projection = dataset.schema().project(&["i"]).unwrap(); + let sampled = dataset + .sample(8, &projection, Some(&[0, 0, 2])) + .await + .unwrap(); + let sampled_values = sampled + .column_by_name("i") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap() + .values() + .to_vec(); + + assert_eq!(sampled_values, vec![0, 2, 3, 8, 10, 11]); +} + +#[rstest] +#[tokio::test] +async fn test_sample_with_empty_fragment_ids_rejected( + #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)] + data_storage_version: LanceFileVersion, +) { + let test_uri = TempStrDir::default(); + let data = gen_batch() + .col("i", array::step::()) + .into_reader_rows(RowCount::from(8), BatchCount::from(1)); + let dataset = Dataset::write( + data, + &test_uri, + Some(WriteParams { + max_rows_per_file: 4, + max_rows_per_group: 2, + data_storage_version: Some(data_storage_version), + ..Default::default() + }), + ) + .await + .unwrap(); + + let projection = dataset.schema().project(&["i"]).unwrap(); + let err = dataset.sample(1, &projection, Some(&[])).await.unwrap_err(); + + assert!(matches!(err, Error::InvalidInput { .. })); + assert!( + err.to_string() + .contains("does not accept an empty fragment_ids list") + ); +} + +#[rstest] +#[tokio::test] +async fn test_sample_with_unknown_fragment_ids_rejected( + #[values(LanceFileVersion::Legacy, LanceFileVersion::Stable)] + data_storage_version: LanceFileVersion, +) { + let test_uri = TempStrDir::default(); + let data = gen_batch() + .col("i", array::step::()) + .into_reader_rows(RowCount::from(8), BatchCount::from(1)); + let dataset = Dataset::write( + data, + &test_uri, + Some(WriteParams { + max_rows_per_file: 4, + max_rows_per_group: 2, + data_storage_version: Some(data_storage_version), + ..Default::default() + }), + ) + .await + .unwrap(); + + let projection = dataset.schema().project(&["i"]).unwrap(); + let err = dataset + .sample(1, &projection, Some(&[0, 999])) + .await + .unwrap_err(); + + assert!(matches!(err, Error::InvalidInput { .. })); + assert!( + err.to_string() + .contains("not part of the current dataset version") + ); + assert!(err.to_string().contains("999")); +} + #[rstest] #[tokio::test] async fn test_bfloat16_roundtrip( diff --git a/rust/lance/src/dataset/write/merge_insert.rs b/rust/lance/src/dataset/write/merge_insert.rs index 2c56d8349fc..290a83fb707 100644 --- a/rust/lance/src/dataset/write/merge_insert.rs +++ b/rust/lance/src/dataset/write/merge_insert.rs @@ -3318,7 +3318,7 @@ mod tests { // Sample 2048 random indices and then paste on a column of 9999999's let some_indices = ds - .sample(2048, &(&just_index_col).try_into().unwrap()) + .sample(2048, &(&just_index_col).try_into().unwrap(), None) .await .unwrap(); let some_indices = some_indices.column(0).clone(); diff --git a/rust/lance/src/index/vector/utils.rs b/rust/lance/src/index/vector/utils.rs index 83e010dc1a4..b20d659d6f3 100644 --- a/rust/lance/src/index/vector/utils.rs +++ b/rust/lance/src/index/vector/utils.rs @@ -96,7 +96,7 @@ async fn estimate_multivector_vectors_per_row( // Try a few random samples first (fast path). let sample_batch_size = std::cmp::min(64, num_rows); for _ in 0..8 { - let batch = dataset.sample(sample_batch_size, &projection).await?; + let batch = dataset.sample(sample_batch_size, &projection, None).await?; let array = get_column_from_batch(&batch, column)?; let list_array = array.as_list::(); for i in 0..list_array.len() { From 7f6fd0e466b51684264d4561ffa6925e07952d42 Mon Sep 17 00:00:00 2001 From: Beinan Date: Thu, 26 Mar 2026 14:22:08 -0700 Subject: [PATCH 085/206] feat(java): add non-blocking AsyncScanner with CompletableFuture API (#6102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements a parallel async scanner alongside the existing blocking `LanceScanner` to prevent thread starvation in Java query engines like Presto and Trino. This PR adds **AsyncScanner** - a non-blocking alternative to `LanceScanner` that uses `CompletableFuture` for true async I/O operations. ## Motivation Query engines like Presto and Trino rely on non-blocking I/O to efficiently multiplex thousands of concurrent queries on a limited thread pool. The current `LanceScanner` blocks Java threads during Rust I/O operations, causing thread starvation and poor performance in these environments. ## Key Features ✅ **Non-blocking I/O**: Spawns Tokio tasks instead of blocking Java threads ✅ **CompletableFuture API**: Native Java async patterns for seamless integration ✅ **Persistent JNI dispatcher**: Single thread attached to JVM for zero-overhead callbacks ✅ **Task-based architecture**: Uses task IDs instead of JNI refs to prevent memory leaks ✅ **Full feature parity**: Supports filters, projections, vector search, FTS, aggregates ✅ **Clean cancellation**: Proper task cleanup without resource leaks ✅ **Parallel to existing API**: No breaking changes, `LanceScanner` unchanged ## Architecture The implementation uses the **"Task ID + Dispatcher" pattern**: 1. **Java manages futures**: `ConcurrentHashMap>` maps task IDs to pending requests 2. **Rust spawns async tasks**: Returns immediately while Tokio handles I/O in background 3. **Lock-free completion channel**: Carries `(taskId, resultPointer)` from Tokio to dispatcher 4. **Persistent dispatcher thread**: Attaches to JVM once, completes Java futures via cached JNI method IDs ### Components **Rust (`java/lance-jni/src/`):** - `dispatcher.rs` - Persistent JNI thread with cached method IDs for callbacks - `task_tracker.rs` - Thread-safe task registry using `RwLock` - `async_scanner.rs` - AsyncScanner with Tokio task spawning and JNI exports - `lib.rs` - Modified to add `JNI_OnLoad` hook for dispatcher initialization **Java (`java/src/main/java/org/lance/ipc/`):** - `AsyncScanner.java` - CompletableFuture-based async API with task management **Tests (`java/src/test/java/org/lance/`):** - `AsyncScannerTest.java` - 6 comprehensive examples demonstrating usage patterns ## Usage Examples ### Basic async scan ```java ScanOptions options = new ScanOptions.Builder().batchSize(20L).build(); try (AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator)) { CompletableFuture future = scanner.scanBatchesAsync(); ArrowReader reader = future.get(10, TimeUnit.SECONDS); while (reader.loadNextBatch()) { // Process batches without blocking } } ``` ### Multiple concurrent scans (key benefit for Presto/Trino) ```java List> futures = new ArrayList<>(); for (int i = 0; i < 100; i++) { AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator); futures.add(scanner.scanBatchesAsync() .thenApply(reader -> processInBackground(reader))); } // All scans run in parallel without blocking threads! CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) .get(30, TimeUnit.SECONDS); ``` ## Testing ```bash cd java ./mvnw test -Dtest=AsyncScannerTest ./mvnw compile # Full build verification ``` All tests pass ✅ ## Compatibility - ✅ No breaking changes to existing APIs - ✅ `LanceScanner` remains unchanged - ✅ Uses same `ScanOptions` for consistency - ✅ Opt-in: users choose blocking or async based on their needs ## Performance Benefits For query engines running hundreds of concurrent queries: - **Before**: Thread pool exhaustion as threads block on I/O - **After**: Threads immediately return, I/O happens in background ## Checklist - [x] Rust code compiles (`cargo check`) - [x] Java code compiles (`./mvnw compile`) - [x] Code formatted (`./mvnw spotless:apply && cargo fmt`) - [x] Comprehensive tests added (`AsyncScannerTest.java`) - [x] Documentation in code examples - [x] No breaking changes ## Related Issues Addresses the need for non-blocking I/O in Java query engines that integrate with LanceDB. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- java/lance-jni/src/async_scanner.rs | 352 ++++++++++++++++++ java/lance-jni/src/blocking_scanner.rs | 244 +++++++----- java/lance-jni/src/dispatcher.rs | 158 ++++++++ java/lance-jni/src/lib.rs | 23 ++ java/lance-jni/src/task_tracker.rs | 110 ++++++ .../main/java/org/lance/ipc/AsyncScanner.java | 208 +++++++++++ .../test/java/org/lance/AsyncScannerTest.java | 311 ++++++++++++++++ 7 files changed, 1314 insertions(+), 92 deletions(-) create mode 100644 java/lance-jni/src/async_scanner.rs create mode 100644 java/lance-jni/src/dispatcher.rs create mode 100644 java/lance-jni/src/task_tracker.rs create mode 100644 java/src/main/java/org/lance/ipc/AsyncScanner.java create mode 100644 java/src/test/java/org/lance/AsyncScannerTest.java diff --git a/java/lance-jni/src/async_scanner.rs b/java/lance-jni/src/async_scanner.rs new file mode 100644 index 00000000000..eada9287c47 --- /dev/null +++ b/java/lance-jni/src/async_scanner.rs @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +use crate::RT; +use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET}; +use crate::blocking_scanner::{ScannerOptions, build_scanner_with_options}; +use crate::dispatcher::{DISPATCHER, DispatcherMessage}; +use crate::error::Result; +use crate::task_tracker::{TASK_TRACKER, TaskInfo}; +use arrow::ffi::FFI_ArrowSchema; +use jni::JNIEnv; +use jni::objects::JObject; +use jni::sys::{jboolean, jint, jlong}; +use lance::dataset::scanner::Scanner; +use lance_io::ffi::to_ffi_arrow_array_stream; + +pub const NATIVE_ASYNC_SCANNER: &str = "nativeAsyncScannerHandle"; + +/// Async scanner that spawns Tokio tasks for non-blocking I/O +pub struct AsyncScanner { + pub(crate) inner: Arc, +} + +/// RAII guard that ensures task cleanup even on panic or early return +/// +/// This guard prevents memory leaks in the task tracker by guaranteeing +/// that task_id is removed from the HashMap when the guard is dropped, +/// regardless of how the async task terminates (normal completion, panic, +/// or cancellation). +struct TaskCleanupGuard { + task_id: u64, +} + +impl TaskCleanupGuard { + fn new(task_id: u64) -> Self { + Self { task_id } + } +} + +impl Drop for TaskCleanupGuard { + fn drop(&mut self) { + // GUARANTEED to run when guard goes out of scope + // Works even if the task panics or returns early + // + // Note: We spawn a detached task instead of using block_on() + // because Drop may be called from within a tokio runtime context + let task_id = self.task_id; + RT.spawn(async move { + TASK_TRACKER.complete(task_id).await; + log::debug!("Task {} cleaned up via RAII guard", task_id); + }); + } +} + +impl AsyncScanner { + pub fn create(scanner: Scanner) -> Self { + Self { + inner: Arc::new(scanner), + } + } + + /// Start an async scan task (static method to avoid holding locks) + pub fn start_scan_with_scanner( + scanner: Arc, + task_id: u64, + scanner_global_ref: jni::objects::GlobalRef, + ) { + // Two-phase registration to prevent race condition: + // 1. Pre-register with placeholder handle BEFORE spawning + // 2. Spawn the actual task + // 3. Update registration with real handle + // This ensures task is registered before cleanup can run + + // Clone for the spawned task + let global_ref_for_task = scanner_global_ref.clone(); + + // Step 1: Pre-register with placeholder handle + let placeholder_handle = RT.spawn(async { + // Placeholder task that does nothing + // Will be aborted when real handle is registered + }); + + RT.block_on(async { + TASK_TRACKER + .register( + task_id, + TaskInfo { + scanner_global_ref: scanner_global_ref.clone(), + cancel_handle: placeholder_handle, + }, + ) + .await; + }); + + // Step 2: Spawn the actual task + let handle = RT.spawn(async move { + // RAII guard ensures cleanup on normal exit, panic, or cancellation + let _cleanup_guard = TaskCleanupGuard::new(task_id); + + let result = match scanner.try_into_stream().await { + Ok(stream) => { + // Convert to FFI pointer + match to_ffi_arrow_array_stream(stream, RT.handle().clone()) { + Ok(ffi_stream) => { + let ptr = Box::into_raw(Box::new(ffi_stream)) as i64; + Ok(ptr) + } + Err(e) => Err(e.to_string()), + } + } + Err(e) => Err(e.to_string()), + }; + + // Send result to dispatcher for Java completion + let dispatcher = match DISPATCHER.get() { + Some(d) => d, + None => { + log::error!( + "Dispatcher not initialized - cannot complete task {}. \ + This indicates a critical initialization failure.", + task_id + ); + // Clean up the FFI stream pointer to prevent memory leak + if let Ok(ptr) = result { + unsafe { + drop(Box::from_raw( + ptr as *mut arrow::ffi_stream::FFI_ArrowArrayStream, + )); + } + log::debug!("Cleaned up FFI stream pointer for task {}", task_id); + } + return; + } + }; + + // Save the pointer before sending so we can clean up on failure + let result_ptr = result.as_ref().ok().copied(); + + if let Err(e) = dispatcher.send(DispatcherMessage { + scanner_global_ref: global_ref_for_task, + task_id, + result, + }) { + log::error!( + "Failed to send completion message for task {}: {}", + task_id, + e + ); + // Clean up the FFI stream pointer to prevent memory leak + if let Some(ptr) = result_ptr { + unsafe { + drop(Box::from_raw( + ptr as *mut arrow::ffi_stream::FFI_ArrowArrayStream, + )); + } + log::debug!("Cleaned up FFI stream pointer for task {}", task_id); + } + } + + // _cleanup_guard.drop() called here automatically, removing task from tracker + }); + + // Step 3: Update registration with real handle + RT.block_on(async { + TASK_TRACKER.update_handle(task_id, handle).await; + }); + } +} + +// JNI Exports + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_ipc_AsyncScanner_createAsyncScanner<'local>( + mut env: JNIEnv<'local>, + _class: JObject<'local>, + jdataset: JObject<'local>, + fragment_ids_obj: JObject<'local>, + columns_obj: JObject<'local>, + substrait_filter_obj: JObject<'local>, + filter_obj: JObject<'local>, + batch_size_obj: JObject<'local>, + limit_obj: JObject<'local>, + offset_obj: JObject<'local>, + query_obj: JObject<'local>, + fts_query_obj: JObject<'local>, + prefilter: jboolean, + with_row_id: jboolean, + with_row_address: jboolean, + batch_readahead: jint, + column_orderings: JObject<'local>, + use_scalar_index: jboolean, + substrait_aggregate_obj: JObject<'local>, +) -> JObject<'local> { + crate::ok_or_throw!( + env, + inner_create_async_scanner( + &mut env, + jdataset, + fragment_ids_obj, + columns_obj, + substrait_filter_obj, + filter_obj, + batch_size_obj, + limit_obj, + offset_obj, + query_obj, + fts_query_obj, + prefilter, + with_row_id, + with_row_address, + batch_readahead, + column_orderings, + use_scalar_index, + substrait_aggregate_obj, + ) + ) +} + +#[allow(clippy::too_many_arguments)] +fn inner_create_async_scanner<'local>( + env: &mut JNIEnv<'local>, + jdataset: JObject<'local>, + fragment_ids_obj: JObject<'local>, + columns_obj: JObject<'local>, + substrait_filter_obj: JObject<'local>, + filter_obj: JObject<'local>, + batch_size_obj: JObject<'local>, + limit_obj: JObject<'local>, + offset_obj: JObject<'local>, + query_obj: JObject<'local>, + fts_query_obj: JObject<'local>, + prefilter: jboolean, + with_row_id: jboolean, + with_row_address: jboolean, + batch_readahead: jint, + column_orderings: JObject<'local>, + use_scalar_index: jboolean, + substrait_aggregate_obj: JObject<'local>, +) -> Result> { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; + let dataset = dataset_guard.inner.clone(); + drop(dataset_guard); + + let options = ScannerOptions { + fragment_ids_obj, + columns_obj, + substrait_filter_obj, + filter_obj, + batch_size_obj, + limit_obj, + offset_obj, + query_obj, + fts_query_obj, + prefilter, + with_row_id, + with_row_address, + batch_readahead, + column_orderings, + use_scalar_index, + substrait_aggregate_obj, + }; + + let scanner = build_scanner_with_options(env, &dataset, options)?; + + let async_scanner = AsyncScanner::create(scanner); + + // Create Java AsyncScanner object + let j_scanner = env.new_object("org/lance/ipc/AsyncScanner", "()V", &[])?; + + // Attach native handle + unsafe { env.set_rust_field(&j_scanner, NATIVE_ASYNC_SCANNER, async_scanner)? }; + + Ok(j_scanner) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_ipc_AsyncScanner_nativeStartScan( + mut env: JNIEnv, + j_scanner: JObject, + task_id: jlong, +) { + ok_or_throw_without_return!(env, inner_start_scan(&mut env, j_scanner, task_id as u64)); +} + +fn inner_start_scan(env: &mut JNIEnv, j_scanner: JObject, task_id: u64) -> Result<()> { + // Create global reference first, before borrowing scanner + let scanner_global_ref = env.new_global_ref(&j_scanner)?; + + // Clone the Arc and drop the MutexGuard before calling start_scan, + // which does block_on internally. Holding the guard across block_on risks deadlock. + let scanner = { + let guard = + unsafe { env.get_rust_field::<_, _, AsyncScanner>(&j_scanner, NATIVE_ASYNC_SCANNER)? }; + guard.inner.clone() + }; + + AsyncScanner::start_scan_with_scanner(scanner, task_id, scanner_global_ref); + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_ipc_AsyncScanner_nativeCancelTask( + _env: JNIEnv, + _j_scanner: JObject, + task_id: jlong, +) { + RT.block_on(async { + TASK_TRACKER.cancel(task_id as u64).await; + }); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_ipc_AsyncScanner_releaseNativeScanner( + mut env: JNIEnv, + j_scanner: JObject, +) { + ok_or_throw_without_return!(env, inner_release_async_scanner(&mut env, j_scanner)); +} + +fn inner_release_async_scanner(env: &mut JNIEnv, j_scanner: JObject) -> Result<()> { + let _: AsyncScanner = unsafe { env.take_rust_field(j_scanner, NATIVE_ASYNC_SCANNER) }?; + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_ipc_AsyncScanner_importFfiSchema( + mut env: JNIEnv, + j_scanner: JObject, + schema_addr: jlong, +) { + ok_or_throw_without_return!( + env, + inner_import_async_ffi_schema(&mut env, j_scanner, schema_addr) + ); +} + +fn inner_import_async_ffi_schema( + env: &mut JNIEnv, + j_scanner: JObject, + schema_addr: jlong, +) -> Result<()> { + let scanner_guard = + unsafe { env.get_rust_field::<_, _, AsyncScanner>(j_scanner, NATIVE_ASYNC_SCANNER)? }; + + let schema = RT.block_on(scanner_guard.inner.schema())?; + let ffi_schema = FFI_ArrowSchema::try_from(&*schema)?; + unsafe { std::ptr::write_unaligned(schema_addr as *mut FFI_ArrowSchema, ffi_schema) } + Ok(()) +} diff --git a/java/lance-jni/src/blocking_scanner.rs b/java/lance-jni/src/blocking_scanner.rs index 93a441f3902..5a369b98a73 100644 --- a/java/lance-jni/src/blocking_scanner.rs +++ b/java/lance-jni/src/blocking_scanner.rs @@ -58,7 +58,16 @@ impl BlockingScanner { } } -fn build_full_text_search_query<'a>(env: &mut JNIEnv<'a>, java_obj: JObject) -> Result { +/////////////////// +// Shared Helpers // +/////////////////// + +/// Build FTS query from Java FullTextQuery object +/// Made pub(crate) to be reused by async_scanner +pub(crate) fn build_full_text_search_query<'a>( + env: &mut JNIEnv<'a>, + java_obj: JObject, +) -> Result { let type_obj = env .call_method( &java_obj, @@ -193,88 +202,40 @@ fn build_full_text_search_query<'a>(env: &mut JNIEnv<'a>, java_obj: JObject) -> } } -/////////////////// -// Write Methods // -/////////////////// -#[unsafe(no_mangle)] -pub extern "system" fn Java_org_lance_ipc_LanceScanner_createScanner<'local>( - mut env: JNIEnv<'local>, - _reader: JObject, - jdataset: JObject, - fragment_ids_obj: JObject, // Optional> - columns_obj: JObject, // Optional> - substrait_filter_obj: JObject, // Optional - filter_obj: JObject, // Optional - batch_size_obj: JObject, // Optional - limit_obj: JObject, // Optional - offset_obj: JObject, // Optional - query_obj: JObject, // Optional - fts_query_obj: JObject, // Optional - prefilter: jboolean, // boolean - with_row_id: jboolean, // boolean - with_row_address: jboolean, // boolean - batch_readahead: jint, // int - column_orderings: JObject, // Optional> - use_scalar_index: jboolean, // boolean - substrait_aggregate_obj: JObject, // Optional -) -> JObject<'local> { - ok_or_throw!( - env, - inner_create_scanner( - &mut env, - jdataset, - fragment_ids_obj, - columns_obj, - substrait_filter_obj, - filter_obj, - batch_size_obj, - limit_obj, - offset_obj, - query_obj, - fts_query_obj, - prefilter, - with_row_id, - with_row_address, - batch_readahead, - column_orderings, - use_scalar_index, - substrait_aggregate_obj - ) - ) +/// Scanner options passed from JNI - shared between blocking and async scanners +pub(crate) struct ScannerOptions<'a> { + pub fragment_ids_obj: JObject<'a>, + pub columns_obj: JObject<'a>, + pub substrait_filter_obj: JObject<'a>, + pub filter_obj: JObject<'a>, + pub batch_size_obj: JObject<'a>, + pub limit_obj: JObject<'a>, + pub offset_obj: JObject<'a>, + pub query_obj: JObject<'a>, + pub fts_query_obj: JObject<'a>, + pub prefilter: jboolean, + pub with_row_id: jboolean, + pub with_row_address: jboolean, + pub batch_readahead: jint, + pub column_orderings: JObject<'a>, + pub use_scalar_index: jboolean, + pub substrait_aggregate_obj: JObject<'a>, } -#[allow(clippy::too_many_arguments)] -fn inner_create_scanner<'local>( - env: &mut JNIEnv<'local>, - jdataset: JObject, - fragment_ids_obj: JObject, - columns_obj: JObject, - substrait_filter_obj: JObject, - filter_obj: JObject, - batch_size_obj: JObject, - limit_obj: JObject, - offset_obj: JObject, - query_obj: JObject, - fts_query_obj: JObject, - prefilter: jboolean, - with_row_id: jboolean, - with_row_address: jboolean, - batch_readahead: jint, - column_orderings: JObject, - use_scalar_index: jboolean, - substrait_aggregate_obj: JObject, -) -> Result> { - let fragment_ids_opt = env.get_ints_opt(&fragment_ids_obj)?; - let dataset_guard = - unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; - - let mut scanner = dataset_guard.inner.scan(); +/// Build a scanner with options applied - shared by blocking and async scanners +pub(crate) fn build_scanner_with_options<'a>( + env: &mut JNIEnv<'a>, + dataset: &lance::Dataset, + options: ScannerOptions<'a>, +) -> Result { + let mut scanner = dataset.scan(); // handle fragment_ids + let fragment_ids_opt = env.get_ints_opt(&options.fragment_ids_obj)?; if let Some(fragment_ids) = fragment_ids_opt { let mut fragments = Vec::with_capacity(fragment_ids.len()); for fragment_id in fragment_ids { - let Some(fragment) = dataset_guard.inner.get_fragment(fragment_id as usize) else { + let Some(fragment) = dataset.get_fragment(fragment_id as usize) else { return Err(Error::input_error(format!( "Fragment {fragment_id} not found" ))); @@ -283,49 +244,48 @@ fn inner_create_scanner<'local>( } scanner.with_fragments(fragments); } - drop(dataset_guard); - let columns_opt = env.get_strings_opt(&columns_obj)?; + let columns_opt = env.get_strings_opt(&options.columns_obj)?; if let Some(columns) = columns_opt { scanner.project(&columns)?; }; - let substrait_opt = env.get_bytes_opt(&substrait_filter_obj)?; + let substrait_opt = env.get_bytes_opt(&options.substrait_filter_obj)?; if let Some(substrait) = substrait_opt { RT.block_on(async { scanner.filter_substrait(substrait) })?; } - let filter_opt = env.get_string_opt(&filter_obj)?; + let filter_opt = env.get_string_opt(&options.filter_obj)?; if let Some(filter) = filter_opt { scanner.filter(filter.as_str())?; } - let batch_size_opt = env.get_long_opt(&batch_size_obj)?; + let batch_size_opt = env.get_long_opt(&options.batch_size_obj)?; if let Some(batch_size) = batch_size_opt { scanner.batch_size(batch_size as usize); } - let limit_opt = env.get_long_opt(&limit_obj)?; - let offset_opt = env.get_long_opt(&offset_obj)?; + let limit_opt = env.get_long_opt(&options.limit_obj)?; + let offset_opt = env.get_long_opt(&options.offset_obj)?; scanner .limit(limit_opt, offset_opt) .map_err(|err| Error::input_error(err.to_string()))?; - if with_row_id == JNI_TRUE { + if options.with_row_id == JNI_TRUE { scanner.with_row_id(); } - if with_row_address == JNI_TRUE { + if options.with_row_address == JNI_TRUE { scanner.with_row_address(); } - if prefilter == JNI_TRUE { + if options.prefilter == JNI_TRUE { scanner.prefilter(true); } - scanner.use_scalar_index(use_scalar_index == JNI_TRUE); + scanner.use_scalar_index(options.use_scalar_index == JNI_TRUE); - env.get_optional(&query_obj, |env, java_obj| { + env.get_optional(&options.query_obj, |env, java_obj| { // Set column and key for nearest search let column = env.get_string_from_method(&java_obj, "getColumn")?; let key_array = env.get_vec_f32_from_method(&java_obj, "getKey")?; @@ -363,16 +323,16 @@ fn inner_create_scanner<'local>( Ok(()) })?; - env.get_optional(&fts_query_obj, |env, java_obj| { + env.get_optional(&options.fts_query_obj, |env, java_obj| { let fts_query = build_full_text_search_query(env, java_obj)?; let full_text_query = FullTextSearchQuery::new_query(fts_query); scanner.full_text_search(full_text_query)?; Ok(()) })?; - scanner.batch_readahead(batch_readahead as usize); + scanner.batch_readahead(options.batch_readahead as usize); - env.get_optional(&column_orderings, |env, java_obj| { + env.get_optional(&options.column_orderings, |env, java_obj| { let list = env.get_list(&java_obj)?; let mut iter = list.iter(env)?; let mut results = Vec::with_capacity(list.size(env)? as usize); @@ -391,11 +351,111 @@ fn inner_create_scanner<'local>( Ok(()) })?; - let substrait_aggregate_opt = env.get_bytes_opt(&substrait_aggregate_obj)?; + let substrait_aggregate_opt = env.get_bytes_opt(&options.substrait_aggregate_obj)?; if let Some(substrait_aggregate) = substrait_aggregate_opt { scanner.aggregate(AggregateExpr::substrait(substrait_aggregate))?; } + Ok(scanner) +} + +/////////////////// +// Write Methods // +/////////////////// +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_ipc_LanceScanner_createScanner<'local>( + mut env: JNIEnv<'local>, + _reader: JObject<'local>, + jdataset: JObject<'local>, + fragment_ids_obj: JObject<'local>, // Optional> + columns_obj: JObject<'local>, // Optional> + substrait_filter_obj: JObject<'local>, // Optional + filter_obj: JObject<'local>, // Optional + batch_size_obj: JObject<'local>, // Optional + limit_obj: JObject<'local>, // Optional + offset_obj: JObject<'local>, // Optional + query_obj: JObject<'local>, // Optional + fts_query_obj: JObject<'local>, // Optional + prefilter: jboolean, // boolean + with_row_id: jboolean, // boolean + with_row_address: jboolean, // boolean + batch_readahead: jint, // int + column_orderings: JObject<'local>, // Optional> + use_scalar_index: jboolean, // boolean + substrait_aggregate_obj: JObject<'local>, // Optional +) -> JObject<'local> { + ok_or_throw!( + env, + inner_create_scanner( + &mut env, + jdataset, + fragment_ids_obj, + columns_obj, + substrait_filter_obj, + filter_obj, + batch_size_obj, + limit_obj, + offset_obj, + query_obj, + fts_query_obj, + prefilter, + with_row_id, + with_row_address, + batch_readahead, + column_orderings, + use_scalar_index, + substrait_aggregate_obj + ) + ) +} + +#[allow(clippy::too_many_arguments)] +fn inner_create_scanner<'local>( + env: &mut JNIEnv<'local>, + jdataset: JObject<'local>, + fragment_ids_obj: JObject<'local>, + columns_obj: JObject<'local>, + substrait_filter_obj: JObject<'local>, + filter_obj: JObject<'local>, + batch_size_obj: JObject<'local>, + limit_obj: JObject<'local>, + offset_obj: JObject<'local>, + query_obj: JObject<'local>, + fts_query_obj: JObject<'local>, + prefilter: jboolean, + with_row_id: jboolean, + with_row_address: jboolean, + batch_readahead: jint, + column_orderings: JObject<'local>, + use_scalar_index: jboolean, + substrait_aggregate_obj: JObject<'local>, +) -> Result> { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; + let dataset = dataset_guard.inner.clone(); + drop(dataset_guard); + + let options = ScannerOptions { + fragment_ids_obj, + columns_obj, + substrait_filter_obj, + filter_obj, + batch_size_obj, + limit_obj, + offset_obj, + query_obj, + fts_query_obj, + prefilter, + with_row_id, + with_row_address, + batch_readahead, + column_orderings, + use_scalar_index, + substrait_aggregate_obj, + }; + + let scanner = build_scanner_with_options(env, &dataset, options)?; + let scanner = BlockingScanner::create(scanner); scanner.into_java(env) } diff --git a/java/lance-jni/src/dispatcher.rs b/java/lance-jni/src/dispatcher.rs new file mode 100644 index 00000000000..a5efadc8cea --- /dev/null +++ b/java/lance-jni/src/dispatcher.rs @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use jni::JavaVM; +use jni::objects::GlobalRef; +use std::sync::{Arc, OnceLock}; +use tokio::sync::mpsc; + +/// Message sent from Tokio tasks to the dispatcher thread +pub struct DispatcherMessage { + pub scanner_global_ref: GlobalRef, + pub task_id: u64, + pub result: Result, // Ok(stream_ptr) or Err(error_msg) +} + +/// Global dispatcher instance initialized in JNI_OnLoad +pub static DISPATCHER: OnceLock> = OnceLock::new(); + +/// Dispatcher manages a persistent JNI thread for completing Java futures +#[derive(Debug)] +pub struct Dispatcher { + tx: mpsc::UnboundedSender, +} + +impl Dispatcher { + /// Initialize the dispatcher with a persistent JNI thread + pub fn initialize(jvm: Arc) -> Arc { + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // Spawn persistent dispatcher thread + std::thread::Builder::new() + .name("lance-jni-dispatcher".to_string()) + .spawn(move || { + // Attach ONCE and never detach - this is the key optimization + let mut env = jvm + .attach_current_thread_permanently() + .expect("Failed to attach dispatcher to JVM"); + + log::info!("JNI dispatcher thread started"); + + // Cache method IDs for completeTask and failTask + let async_scanner_class = env + .find_class("org/lance/ipc/AsyncScanner") + .expect("AsyncScanner class not found"); + let complete_method = env + .get_method_id(&async_scanner_class, "completeTask", "(JJ)V") + .expect("completeTask method not found"); + let fail_method = env + .get_method_id(&async_scanner_class, "failTask", "(JLjava/lang/String;)V") + .expect("failTask method not found"); + + // Event loop: block waiting for completions + while let Some(msg) = rx.blocking_recv() { + let scanner_obj = msg.scanner_global_ref.as_obj(); + + match msg.result { + Err(error) => { + handle_error(&mut env, scanner_obj, fail_method, msg.task_id, &error) + } + Ok(result_ptr) => handle_success( + &mut env, + scanner_obj, + complete_method, + msg.task_id, + result_ptr, + ), + } + } + + log::info!("JNI dispatcher thread shutting down"); + }) + .expect("Failed to spawn dispatcher thread"); + + Arc::new(Self { tx }) + } + + /// Send a completion message to the dispatcher + pub fn send(&self, msg: DispatcherMessage) -> std::result::Result<(), String> { + self.tx + .send(msg) + .map_err(|e| format!("Failed to send message to dispatcher: {}", e)) + } +} + +/// Handle error completion by calling failTask on Java side +fn handle_error( + env: &mut jni::JNIEnv, + scanner_obj: &jni::objects::JObject, + fail_method: jni::objects::JMethodID, + task_id: u64, + error: &str, +) { + let error_jstr = match env.new_string(error) { + Ok(s) => s, + Err(e) => { + log::error!("Failed to create JString for error: {:?}", e); + let _ = env.exception_clear(); + return; + } + }; + + let result = unsafe { + env.call_method_unchecked( + scanner_obj, + fail_method, + jni::signature::ReturnType::Primitive(jni::signature::Primitive::Void), + &[ + jni::sys::jvalue { j: task_id as i64 }, + jni::sys::jvalue { + l: error_jstr.as_raw(), + }, + ], + ) + }; + + if let Err(e) = result { + log::error!("Failed to call failTask: {:?}", e); + // Clear any pending JNI exception to protect the dispatcher loop + let _ = env.exception_clear(); + } +} + +/// Handle success completion by calling completeTask on Java side +fn handle_success( + env: &mut jni::JNIEnv, + scanner_obj: &jni::objects::JObject, + complete_method: jni::objects::JMethodID, + task_id: u64, + result_ptr: i64, +) { + let result = unsafe { + env.call_method_unchecked( + scanner_obj, + complete_method, + jni::signature::ReturnType::Primitive(jni::signature::Primitive::Void), + &[ + jni::sys::jvalue { j: task_id as i64 }, + jni::sys::jvalue { j: result_ptr }, + ], + ) + }; + + if let Err(e) = result { + log::error!("Failed to call completeTask: {:?}", e); + // Clear any pending JNI exception to protect the dispatcher loop + let _ = env.exception_clear(); + // Clean up the FFI stream since Java won't receive it + unsafe { + drop(Box::from_raw( + result_ptr as *mut arrow::ffi_stream::FFI_ArrowArrayStream, + )); + } + log::debug!( + "Cleaned up FFI stream pointer for task {} after completeTask failure", + task_id + ); + } +} diff --git a/java/lance-jni/src/lib.rs b/java/lance-jni/src/lib.rs index 53ce125aca8..90be9b3ef80 100644 --- a/java/lance-jni/src/lib.rs +++ b/java/lance-jni/src/lib.rs @@ -39,10 +39,12 @@ macro_rules! ok_or_throw_with_return { }; } +mod async_scanner; mod blocking_blob; mod blocking_dataset; mod blocking_scanner; mod delta; +mod dispatcher; pub mod error; pub mod ffi; mod file_reader; @@ -56,6 +58,7 @@ mod schema; mod session; mod sql; mod storage_options; +mod task_tracker; pub mod traits; mod transaction; pub mod utils; @@ -151,3 +154,23 @@ pub extern "system" fn Java_org_lance_JniLoader_initLanceLogger() { log::set_max_level(max_level); // todo: add tracing } + +/// JNI_OnLoad - Called when the JVM loads the native library +/// Initializes the global dispatcher for async operations +#[unsafe(no_mangle)] +pub extern "system" fn JNI_OnLoad( + vm: jni::JavaVM, + _reserved: *mut std::ffi::c_void, +) -> jni::sys::jint { + let jvm_arc = Arc::new(vm); + + // Initialize global dispatcher with persistent thread + let dispatcher = dispatcher::Dispatcher::initialize(jvm_arc); + + // Set the global DISPATCHER (will panic if called more than once) + dispatcher::DISPATCHER + .set(dispatcher) + .expect("Dispatcher already initialized"); + + jni::sys::JNI_VERSION_1_8 +} diff --git a/java/lance-jni/src/task_tracker.rs b/java/lance-jni/src/task_tracker.rs new file mode 100644 index 00000000000..bc9d9b0519f --- /dev/null +++ b/java/lance-jni/src/task_tracker.rs @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use jni::objects::GlobalRef; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; +use tokio::sync::RwLock; + +pub type TaskId = u64; + +/// Information about an in-flight async task +pub struct TaskInfo { + #[allow(dead_code)] // Used for cleanup when task is cancelled + pub scanner_global_ref: GlobalRef, + pub cancel_handle: tokio::task::JoinHandle<()>, +} + +/// Thread-safe task registry for managing async scan operations +pub struct TaskTracker { + tasks: Arc>>, +} + +impl TaskTracker { + pub fn new() -> Self { + Self { + tasks: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Register a new task + pub async fn register(&self, task_id: TaskId, info: TaskInfo) { + let mut tasks = self.tasks.write().await; + tasks.insert(task_id, info); + } + + /// Update the cancel handle for a task (used in two-phase registration) + /// Returns true if task was found and updated, false if task already completed + pub async fn update_handle( + &self, + task_id: TaskId, + cancel_handle: tokio::task::JoinHandle<()>, + ) -> bool { + let mut tasks = self.tasks.write().await; + if let Some(task_info) = tasks.get_mut(&task_id) { + // Abort the old placeholder handle and replace with real handle + task_info.cancel_handle.abort(); + task_info.cancel_handle = cancel_handle; + true + } else { + // Task already completed before we could update - abort the handle + cancel_handle.abort(); + false + } + } + + /// Mark a task as complete and return its info + pub async fn complete(&self, task_id: TaskId) -> Option { + let mut tasks = self.tasks.write().await; + tasks.remove(&task_id) + } + + /// Cancel a task by ID + pub async fn cancel(&self, task_id: TaskId) { + let info = { + let mut tasks = self.tasks.write().await; + tasks.remove(&task_id) + }; + + if let Some(info) = info { + info.cancel_handle.abort(); + } + } + + // TODO: Implement timeout-based cleanup for defense-in-depth + // + // While TaskCleanupGuard (RAII pattern) ensures cleanup in normal and panic cases, + // a background cleanup task provides additional safety against edge cases: + // + // Proposed implementation: + // ``` + // pub async fn cleanup_stale_tasks(&self, max_age: Duration) { + // let mut tasks = self.tasks.write().await; + // let now = Instant::now(); + // tasks.retain(|task_id, info| { + // let is_finished = info.cancel_handle.is_finished(); + // let is_stale = info.created_at.elapsed() > max_age; + // + // if is_finished || is_stale { + // log::warn!("Cleaning up stale/finished task {}", task_id); + // false // remove from HashMap + // } else { + // true // keep in HashMap + // } + // }); + // } + // + // // In JNI_OnLoad or module initialization: + // RT.spawn(async { + // loop { + // tokio::time::sleep(Duration::from_secs(60)).await; + // TASK_TRACKER.cleanup_stale_tasks(Duration::from_secs(300)).await; + // } + // }); + // ``` + // + // This would require adding `created_at: Instant` field to TaskInfo. +} + +/// Global task tracker instance +pub static TASK_TRACKER: LazyLock = LazyLock::new(TaskTracker::new); diff --git a/java/src/main/java/org/lance/ipc/AsyncScanner.java b/java/src/main/java/org/lance/ipc/AsyncScanner.java new file mode 100644 index 00000000000..59ecdebd750 --- /dev/null +++ b/java/src/main/java/org/lance/ipc/AsyncScanner.java @@ -0,0 +1,208 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.lance.ipc; + +import org.lance.Dataset; +import org.lance.LockManager; + +import org.apache.arrow.c.ArrowArrayStream; +import org.apache.arrow.c.ArrowSchema; +import org.apache.arrow.c.Data; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.util.Preconditions; +import org.apache.arrow.vector.ipc.ArrowReader; +import org.apache.arrow.vector.types.pojo.Schema; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Async scanner that provides non-blocking scan operations via CompletableFuture. + * + *

This scanner spawns async I/O tasks in Rust and completes Java futures when data is ready, + * preventing thread starvation in Java query engines like Presto/Trino. + */ +public class AsyncScanner implements AutoCloseable { + private static final AtomicLong TASK_ID_GENERATOR = new AtomicLong(1); + private final ConcurrentHashMap> pendingTasks = + new ConcurrentHashMap<>(); + + private BufferAllocator allocator; + private final LockManager lockManager = new LockManager(); + private long nativeAsyncScannerHandle; + + private AsyncScanner() {} + + /** + * Create an AsyncScanner. + * + * @param dataset the dataset to scan + * @param options scan options + * @param allocator allocator + * @return an AsyncScanner + */ + public static AsyncScanner create( + Dataset dataset, ScanOptions options, BufferAllocator allocator) { + Preconditions.checkNotNull(dataset); + Preconditions.checkNotNull(options); + Preconditions.checkNotNull(allocator); + AsyncScanner scanner = + createAsyncScanner( + dataset, + options.getFragmentIds(), + options.getColumns(), + options.getSubstraitFilter(), + options.getFilter(), + options.getBatchSize(), + options.getLimit(), + options.getOffset(), + options.getNearest(), + options.getFullTextQuery(), + options.isPrefilter(), + options.isWithRowId(), + options.isWithRowAddress(), + options.getBatchReadahead(), + options.getColumnOrderings(), + options.isUseScalarIndex(), + options.getSubstraitAggregate()); + scanner.allocator = allocator; + return scanner; + } + + static native AsyncScanner createAsyncScanner( + Dataset dataset, + Optional> fragmentIds, + Optional> columns, + Optional substraitFilter, + Optional filter, + Optional batchSize, + Optional limit, + Optional offset, + Optional query, + Optional fullTextQuery, + boolean prefilter, + boolean withRowId, + boolean withRowAddress, + int batchReadahead, + Optional> columnOrderings, + boolean useScalarIndex, + Optional substraitAggregate); + + /** + * Asynchronously scan batches and return a CompletableFuture. + * + * @return a CompletableFuture that will be completed with an ArrowReader when data is ready + */ + public CompletableFuture scanBatchesAsync() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + if (nativeAsyncScannerHandle == 0) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new IllegalStateException("Scanner is closed")); + return future; + } + + long taskId = TASK_ID_GENERATOR.getAndIncrement(); + CompletableFuture streamPtrFuture = new CompletableFuture<>(); + pendingTasks.put(taskId, streamPtrFuture); + + // Start async scan in Rust + nativeStartScan(taskId); + + // Transform stream pointer to ArrowReader + return streamPtrFuture.handle( + (streamPtr, error) -> { + pendingTasks.remove(taskId); + + if (error != null) { + throw new RuntimeException("Scan failed", error); + } + + if (streamPtr < 0) { + throw new RuntimeException("Native scan error"); + } + + try { + ArrowArrayStream stream = ArrowArrayStream.wrap(streamPtr); + return Data.importArrayStream(allocator, stream); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + } + + /** Called by Rust dispatcher thread via JNI to complete a task successfully. */ + private void completeTask(long taskId, long resultPtr) { + CompletableFuture future = pendingTasks.get(taskId); + if (future != null) { + future.complete(resultPtr); + } + } + + /** Called by Rust dispatcher thread via JNI to fail a task with an error. */ + private void failTask(long taskId, String errorMessage) { + CompletableFuture future = pendingTasks.get(taskId); + if (future != null) { + future.completeExceptionally(new RuntimeException(errorMessage)); + } + } + + private native void nativeStartScan(long taskId); + + /** + * Get schema (synchronous operation). + * + * @return the schema + */ + public Schema schema() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeAsyncScannerHandle != 0, "Scanner is closed"); + try (ArrowSchema ffiSchema = ArrowSchema.allocateNew(allocator)) { + importFfiSchema(ffiSchema.memoryAddress()); + return Data.importSchema(allocator, ffiSchema, null); + } + } + } + + private native void importFfiSchema(long arrowSchemaMemoryAddress); + + /** + * Closes this scanner and releases any system resources associated with it. If the scanner is + * already closed, then invoking this method has no effect. + */ + @Override + public void close() throws Exception { + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + if (nativeAsyncScannerHandle != 0) { + // Cancel all pending tasks + for (Long taskId : pendingTasks.keySet()) { + nativeCancelTask(taskId); + } + pendingTasks.clear(); + + releaseNativeScanner(); + nativeAsyncScannerHandle = 0; + } + } + } + + private native void nativeCancelTask(long taskId); + + /** Native method to release the async scanner resources. */ + private native void releaseNativeScanner(); +} diff --git a/java/src/test/java/org/lance/AsyncScannerTest.java b/java/src/test/java/org/lance/AsyncScannerTest.java new file mode 100644 index 00000000000..98f46887b64 --- /dev/null +++ b/java/src/test/java/org/lance/AsyncScannerTest.java @@ -0,0 +1,311 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.lance; + +import org.lance.ipc.AsyncScanner; +import org.lance.ipc.ScanOptions; + +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowReader; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Example tests demonstrating AsyncScanner usage with CompletableFuture-based API. + * + *

AsyncScanner provides non-blocking scan operations that prevent thread starvation in Java + * query engines like Presto/Trino. + */ +public class AsyncScannerTest { + private static Dataset dataset; + + @BeforeAll + static void setup() {} + + @AfterAll + static void tearDown() { + if (dataset != null) { + dataset.close(); + } + } + + /** + * Example 1: Basic async scan with CompletableFuture. + * + *

This shows the simplest usage - create an async scanner and wait for results. + */ + @Test + void testBasicAsyncScan(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_basic").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + int totalRows = 40; + + try (Dataset dataset = testDataset.write(1, totalRows)) { + // Create AsyncScanner with same options as LanceScanner + ScanOptions options = new ScanOptions.Builder().batchSize(20L).build(); + + try (AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator)) { + // Start async scan - returns CompletableFuture + CompletableFuture future = scanner.scanBatchesAsync(); + + // Wait for result (blocks current thread, but doesn't block Rust I/O threads) + ArrowReader reader = future.get(10, TimeUnit.SECONDS); + assertNotNull(reader); + + // Read all batches + int rowCount = 0; + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + rowCount += root.getRowCount(); + } + + assertEquals(totalRows, rowCount, "Should read all rows"); + reader.close(); + } + } + } + } + + /** + * Example 2: Async scan with filter. + * + *

Shows how to use async scanner with SQL-like filters. + */ + @Test + void testAsyncScanWithFilter(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_filter").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + try (Dataset dataset = testDataset.write(1, 40)) { + // Scan with filter - only rows where id < 20 + ScanOptions options = new ScanOptions.Builder().filter("id < 20").build(); + + try (AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator)) { + CompletableFuture future = scanner.scanBatchesAsync(); + + ArrowReader reader = future.get(10, TimeUnit.SECONDS); + int rowCount = 0; + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + rowCount += root.getRowCount(); + } + + assertEquals(20, rowCount, "Should read only filtered rows"); + reader.close(); + } + } + } + } + + /** + * Example 3: Multiple concurrent async scans. + * + *

Shows how to run multiple scans in parallel without blocking threads. This is the key + * benefit for query engines like Presto/Trino. + */ + @Test + void testConcurrentAsyncScans(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_concurrent").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + int totalRows = 100; + + try (Dataset dataset = testDataset.write(1, totalRows)) { + // Create 5 concurrent scans with different filters + List> futures = new ArrayList<>(); + + for (int i = 0; i < 5; i++) { + final int rangeStart = i * 20; + final int rangeEnd = rangeStart + 20; + String filter = String.format("id >= %d AND id < %d", rangeStart, rangeEnd); + + ScanOptions options = new ScanOptions.Builder().filter(filter).build(); + + AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator); + + // Chain async operations: scan -> read -> count rows -> cleanup + CompletableFuture future = + scanner + .scanBatchesAsync() + .thenApply( + reader -> { + try { + int count = 0; + while (reader.loadNextBatch()) { + count += reader.getVectorSchemaRoot().getRowCount(); + } + reader.close(); + scanner.close(); + return count; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + futures.add(future); + } + + // Wait for all scans to complete + CompletableFuture allDone = + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + allDone.get(30, TimeUnit.SECONDS); + + // Verify each scan read the expected number of rows + for (CompletableFuture future : futures) { + assertEquals(20, future.get(), "Each range should have 20 rows"); + } + } + } + } + + /** + * Example 4: Async scan with error handling. + * + *

Shows how to handle errors in async operations. + */ + @Test + void testAsyncScanErrorHandling(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_error").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + try (Dataset dataset = testDataset.write(1, 40)) { + ScanOptions options = new ScanOptions.Builder().build(); + + try (AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator)) { + CompletableFuture future = + scanner + .scanBatchesAsync() + .whenComplete( + (reader, error) -> { + if (error != null) { + // Handle error + System.err.println("Scan failed: " + error.getMessage()); + } else { + // Process successful result + assertNotNull(reader); + } + }); + + ArrowReader reader = future.get(10, TimeUnit.SECONDS); + assertNotNull(reader); + reader.close(); + } + } + } + } + + /** + * Example 5: Async scan with projection (column selection). + * + *

Shows how to select specific columns for better performance. + */ + @Test + void testAsyncScanWithProjection(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_projection").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + try (Dataset dataset = testDataset.write(1, 40)) { + // Select only "id" column + ScanOptions options = new ScanOptions.Builder().columns(List.of("id")).build(); + + try (AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator)) { + CompletableFuture future = scanner.scanBatchesAsync(); + + ArrowReader reader = future.get(10, TimeUnit.SECONDS); + + // Verify schema has only one column + assertEquals(1, reader.getVectorSchemaRoot().getFieldVectors().size()); + assertEquals("id", reader.getVectorSchemaRoot().getVector(0).getName()); + + reader.close(); + } + } + } + } + + /** + * Example 6: Using thenCompose for sequential async operations. + * + *

Shows how to chain multiple async operations together. + */ + @Test + void testAsyncChaining(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_chaining").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + try (Dataset dataset = testDataset.write(1, 40)) { + ScanOptions options = new ScanOptions.Builder().build(); + + try (AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator)) { + // Chain operations: scan -> read first batch -> extract values + CompletableFuture> future = + scanner + .scanBatchesAsync() + .thenApply( + reader -> { + try { + List values = new ArrayList<>(); + if (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + IntVector idVector = (IntVector) root.getVector("id"); + for (int i = 0; i < root.getRowCount(); i++) { + values.add(idVector.get(i)); + } + } + reader.close(); + return values; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + List values = future.get(10, TimeUnit.SECONDS); + assertTrue(values.size() > 0, "Should read some values"); + } + } + } + } +} From 8c99eef9271934ca5da3c6fb8394ce43f30cffe9 Mon Sep 17 00:00:00 2001 From: Enwei Jiao Date: Fri, 27 Mar 2026 12:35:26 +0800 Subject: [PATCH 086/206] fix(namespace): support nested types in convert_json_arrow_type (#6300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `convert_json_arrow_type` only handled scalar types (int/float/utf8/binary), causing deserialization failures for any schema containing list, struct, large_binary, large_utf8, fixed_size_list, map, decimal, or date/time types. This made `arrow_type_to_json` and `convert_json_arrow_type` asymmetric: serialization worked for all types but deserialization rejected most of them with "Unsupported Arrow type". In practice this broke the DuckDB lance extension's fast schema-from-REST path — tables with list/struct columns fell back to opening the S3 dataset for every DESCRIBE, making SHOW ALL TABLES ~20x slower than necessary. Add support for: float16, large_utf8, large_binary, fixed_size_binary, decimal32/64/128/256, date32, date64, timestamp, duration, list, large_list, fixed_size_list, struct, and map. Add a roundtrip test covering all supported types. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-namespace/src/schema.rs | 302 +++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) diff --git a/rust/lance-namespace/src/schema.rs b/rust/lance-namespace/src/schema.rs index 3f44847bcd4..69aa59a51e9 100644 --- a/rust/lance-namespace/src/schema.rs +++ b/rust/lance-namespace/src/schema.rs @@ -248,9 +248,12 @@ pub fn convert_json_arrow_field(json_field: &JsonArrowField) -> Result { /// Convert JsonArrowDataType to Arrow DataType pub fn convert_json_arrow_type(json_type: &JsonArrowDataType) -> Result { + use std::sync::Arc; + let type_name = json_type.r#type.to_lowercase(); match type_name.as_str() { + // Primitive types "null" => Ok(DataType::Null), "bool" | "boolean" => Ok(DataType::Boolean), "int8" => Ok(DataType::Int8), @@ -261,10 +264,108 @@ pub fn convert_json_arrow_type(json_type: &JsonArrowDataType) -> Result Ok(DataType::UInt32), "int64" => Ok(DataType::Int64), "uint64" => Ok(DataType::UInt64), + "float16" => Ok(DataType::Float16), "float32" => Ok(DataType::Float32), "float64" => Ok(DataType::Float64), + + // Decimal types - encoding: precision * 1000 + scale + // Decoding must handle negative scale: precision = ((encoded + 128) / 1000) + "decimal32" => { + let encoded = json_type.length.unwrap_or(0); + let precision = ((encoded + 128) / 1000) as u8; + let scale = (encoded - precision as i64 * 1000) as i8; + Ok(DataType::Decimal32(precision, scale)) + } + "decimal64" => { + let encoded = json_type.length.unwrap_or(0); + let precision = ((encoded + 128) / 1000) as u8; + let scale = (encoded - precision as i64 * 1000) as i8; + Ok(DataType::Decimal64(precision, scale)) + } + "decimal128" => { + let encoded = json_type.length.unwrap_or(0); + let precision = ((encoded + 128) / 1000) as u8; + let scale = (encoded - precision as i64 * 1000) as i8; + Ok(DataType::Decimal128(precision, scale)) + } + "decimal256" => { + let encoded = json_type.length.unwrap_or(0); + let precision = ((encoded + 128) / 1000) as u8; + let scale = (encoded - precision as i64 * 1000) as i8; + Ok(DataType::Decimal256(precision, scale)) + } + + // Date/Time types + "date32" => Ok(DataType::Date32), + "date64" => Ok(DataType::Date64), + "timestamp" => Ok(DataType::Timestamp( + arrow::datatypes::TimeUnit::Microsecond, + None, + )), + "duration" => Ok(DataType::Duration(arrow::datatypes::TimeUnit::Microsecond)), + + // String and Binary types "utf8" => Ok(DataType::Utf8), + "large_utf8" => Ok(DataType::LargeUtf8), "binary" => Ok(DataType::Binary), + "large_binary" => Ok(DataType::LargeBinary), + "fixed_size_binary" => { + let size = json_type.length.unwrap_or(0) as i32; + Ok(DataType::FixedSizeBinary(size)) + } + + // Nested types + "list" => { + let inner = json_type + .fields + .as_ref() + .and_then(|f| f.first()) + .ok_or_else(|| Error::namespace("list type missing inner field"))?; + Ok(DataType::List(Arc::new(convert_json_arrow_field(inner)?))) + } + "large_list" => { + let inner = json_type + .fields + .as_ref() + .and_then(|f| f.first()) + .ok_or_else(|| Error::namespace("large_list type missing inner field"))?; + Ok(DataType::LargeList(Arc::new(convert_json_arrow_field( + inner, + )?))) + } + "fixed_size_list" => { + let inner = json_type + .fields + .as_ref() + .and_then(|f| f.first()) + .ok_or_else(|| Error::namespace("fixed_size_list type missing inner field"))?; + let size = json_type.length.unwrap_or(0) as i32; + Ok(DataType::FixedSizeList( + Arc::new(convert_json_arrow_field(inner)?), + size, + )) + } + "struct" => { + let fields = json_type + .fields + .as_ref() + .ok_or_else(|| Error::namespace("struct type missing fields"))?; + let arrow_fields: Result> = + fields.iter().map(convert_json_arrow_field).collect(); + Ok(DataType::Struct(arrow_fields?.into())) + } + "map" => { + let entries = json_type + .fields + .as_ref() + .and_then(|f| f.first()) + .ok_or_else(|| Error::namespace("map type missing entries field"))?; + Ok(DataType::Map( + Arc::new(convert_json_arrow_field(entries)?), + false, + )) + } + _ => Err(Error::namespace(format!( "Unsupported Arrow type: {}", type_name @@ -524,4 +625,205 @@ mod tests { let float16 = arrow_type_to_json(&DataType::Float16).unwrap(); assert_eq!(float16.r#type, "float16"); } + + /// Verify that convert_json_arrow_type (deserialization) is the inverse of + /// arrow_type_to_json (serialization) for all supported types. + #[test] + fn test_json_arrow_type_roundtrip() { + use arrow::datatypes::Field; + + let cases: Vec = vec![ + // Scalars + DataType::Null, + DataType::Boolean, + DataType::Int8, + DataType::UInt8, + DataType::Int16, + DataType::UInt16, + DataType::Int32, + DataType::UInt32, + DataType::Int64, + DataType::UInt64, + DataType::Float16, + DataType::Float32, + DataType::Float64, + DataType::Utf8, + DataType::LargeUtf8, + DataType::Binary, + DataType::LargeBinary, + DataType::Date32, + DataType::Date64, + DataType::FixedSizeBinary(16), + // Decimal types with positive and negative scales + DataType::Decimal32(10, -2), + DataType::Decimal32(9, 3), + DataType::Decimal64(18, -5), + DataType::Decimal64(10, 4), + DataType::Decimal128(9, -2), + DataType::Decimal128(38, 10), + DataType::Decimal256(38, 10), + DataType::Decimal256(76, -10), + // Timestamp and Duration + DataType::Timestamp(arrow::datatypes::TimeUnit::Microsecond, None), + DataType::Duration(arrow::datatypes::TimeUnit::Microsecond), + // Nested + DataType::List(Arc::new(Field::new("item", DataType::Int32, true))), + DataType::LargeList(Arc::new(Field::new("item", DataType::Utf8, true))), + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, false)), 128), + DataType::Struct( + vec![ + Field::new("a", DataType::Int64, false), + Field::new("b", DataType::Utf8, true), + ] + .into(), + ), + // Map + DataType::Map( + Arc::new(Field::new( + "entries", + DataType::Struct( + vec![ + Field::new("keys", DataType::Utf8, false), + Field::new("values", DataType::Int32, true), + ] + .into(), + ), + false, + )), + false, + ), + ]; + + for dt in &cases { + let json = arrow_type_to_json(dt) + .unwrap_or_else(|e| panic!("arrow_type_to_json failed for {:?}: {}", dt, e)); + let back = convert_json_arrow_type(&json) + .unwrap_or_else(|e| panic!("convert_json_arrow_type failed for {:?}: {}", dt, e)); + assert_eq!(&back, dt, "Roundtrip mismatch for {:?}: got {:?}", dt, back); + } + } + + #[test] + fn test_decimal_negative_scale_roundtrip() { + // Explicitly test the cases requested by reviewer + let cases = vec![ + DataType::Decimal32(10, -2), + DataType::Decimal128(9, -2), + DataType::Decimal256(38, 10), + ]; + for dt in &cases { + let json = arrow_type_to_json(dt).unwrap(); + let back = convert_json_arrow_type(&json).unwrap(); + assert_eq!(&back, dt, "Decimal roundtrip failed for {:?}", dt); + } + } + + #[test] + fn test_schema_with_metadata_roundtrip() { + let mut metadata = HashMap::new(); + metadata.insert("key1".to_string(), "value1".to_string()); + metadata.insert("key2".to_string(), "value2".to_string()); + + let arrow_schema = ArrowSchema::new_with_metadata( + vec![ + Field::new("id", DataType::Int32, false), + Field::new("name", DataType::Utf8, true), + ], + metadata.clone(), + ); + + let json_schema = arrow_schema_to_json(&arrow_schema).unwrap(); + assert_eq!(json_schema.metadata.as_ref().unwrap(), &metadata); + + let roundtrip = convert_json_arrow_schema(&json_schema).unwrap(); + assert_eq!(roundtrip.metadata(), &metadata); + } + + #[test] + fn test_dictionary_type_unwraps_to_value_type() { + let dict_type = DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8)); + let json = arrow_type_to_json(&dict_type).unwrap(); + assert_eq!(json.r#type, "utf8"); + } + + #[test] + fn test_map_keys_sorted_unsupported() { + let map_type = DataType::Map( + Arc::new(Field::new( + "entries", + DataType::Struct( + vec![ + Field::new("keys", DataType::Utf8, false), + Field::new("values", DataType::Int32, true), + ] + .into(), + ), + false, + )), + true, // keys_sorted = true + ); + let result = arrow_type_to_json(&map_type); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("keys_sorted=true")); + } + + #[test] + fn test_unsupported_types_error() { + // RunEndEncoded + let ree = DataType::RunEndEncoded( + Arc::new(Field::new("run_ends", DataType::Int32, false)), + Arc::new(Field::new("values", DataType::Utf8, true)), + ); + assert!(arrow_type_to_json(&ree).is_err()); + + // ListView + let lv = DataType::ListView(Arc::new(Field::new("item", DataType::Int32, true))); + assert!(arrow_type_to_json(&lv).is_err()); + + // LargeListView + let llv = DataType::LargeListView(Arc::new(Field::new("item", DataType::Int32, true))); + assert!(arrow_type_to_json(&llv).is_err()); + + // Utf8View / BinaryView + assert!(arrow_type_to_json(&DataType::Utf8View).is_err()); + assert!(arrow_type_to_json(&DataType::BinaryView).is_err()); + } + + #[test] + fn test_large_list_roundtrip() { + let inner_field = Field::new("item", DataType::Float64, true); + let large_list = DataType::LargeList(Arc::new(inner_field)); + + let json = arrow_type_to_json(&large_list).unwrap(); + assert_eq!(json.r#type, "large_list"); + + let back = convert_json_arrow_type(&json).unwrap(); + assert_eq!(back, large_list); + } + + #[test] + fn test_field_with_metadata_roundtrip() { + let mut field_meta = HashMap::new(); + field_meta.insert("custom_key".to_string(), "custom_val".to_string()); + + let field = Field::new("col", DataType::Int64, false).with_metadata(field_meta.clone()); + let schema = ArrowSchema::new(vec![field]); + + let json_schema = arrow_schema_to_json(&schema).unwrap(); + let roundtrip = convert_json_arrow_schema(&json_schema).unwrap(); + assert_eq!(roundtrip.field(0).metadata(), &field_meta); + } + + #[test] + fn test_nested_list_with_field_metadata() { + let mut meta = HashMap::new(); + meta.insert("encoding".to_string(), "delta".to_string()); + + let inner = Field::new("item", DataType::Int32, true).with_metadata(meta.clone()); + let list_type = DataType::List(Arc::new(inner)); + + let json = arrow_type_to_json(&list_type).unwrap(); + let fields = json.fields.as_ref().unwrap(); + assert_eq!(fields[0].metadata.as_ref().unwrap(), &meta); + } } From 08ec9952283a20c577b46428217b894a8aa2434c Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Fri, 27 Mar 2026 13:26:01 +0800 Subject: [PATCH 087/206] feat: add a fast dataset version ID API (#6303) ## Summary - add `Dataset::version_id()` in Rust to return the checked-out manifest version without building full version metadata - route Python `dataset.version` and default checkout refs through the new fast path - route Java `Dataset.version()` through a new JNI version-id accessor while keeping `getVersion()` unchanged - extend Rust, Python, and Java tests to cover current, updated, and historical version reads ## Testing - `cargo test -p lance test_version_id_fast_path` - `cargo check --manifest-path java/lance-jni/Cargo.toml` - `cargo check --manifest-path python/Cargo.toml` - Python pytest target not run in this environment (`pytest` unavailable) - Java Maven test target not run in this environment (JRE unavailable) --- java/lance-jni/src/blocking_dataset.rs | 18 +++++++ java/src/main/java/org/lance/Dataset.java | 7 ++- java/src/test/java/org/lance/DatasetTest.java | 10 ++++ python/python/tests/test_dataset.py | 5 ++ python/src/dataset.rs | 4 +- rust/lance/src/dataset.rs | 12 +++++ .../src/dataset/tests/dataset_versioning.rs | 47 +++++++++++++++++++ 7 files changed, 100 insertions(+), 3 deletions(-) diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index e705c0eda1d..cc82c8acee5 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -236,6 +236,10 @@ impl BlockingDataset { Ok(version) } + pub fn version_id(&self) -> u64 { + self.inner.version_id() + } + pub fn list_versions(&self) -> Result> { let versions = RT.block_on(self.inner.versions())?; Ok(versions) @@ -1625,6 +1629,20 @@ fn inner_get_version<'local>( version.into_java(env) } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeGetVersionId( + mut env: JNIEnv, + java_dataset: JObject, +) -> jlong { + ok_or_throw_with_return!(env, inner_get_version_id(&mut env, java_dataset), -1) as jlong +} + +fn inner_get_version_id(env: &mut JNIEnv, java_dataset: JObject) -> Result { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + Ok(dataset_guard.version_id()) +} + #[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetLatestVersionId( mut env: JNIEnv, diff --git a/java/src/main/java/org/lance/Dataset.java b/java/src/main/java/org/lance/Dataset.java index 506827be902..b0517ca1bb8 100644 --- a/java/src/main/java/org/lance/Dataset.java +++ b/java/src/main/java/org/lance/Dataset.java @@ -790,9 +790,14 @@ public String uri() { * @return the version id of the dataset */ public long version() { - return getVersion().getId(); + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return nativeGetVersionId(); + } } + private native long nativeGetVersionId(); + /** * Gets the currently checked out version of the dataset. * diff --git a/java/src/test/java/org/lance/DatasetTest.java b/java/src/test/java/org/lance/DatasetTest.java index 59e0ee80e7b..00e5d930323 100644 --- a/java/src/test/java/org/lance/DatasetTest.java +++ b/java/src/test/java/org/lance/DatasetTest.java @@ -178,6 +178,7 @@ void testDatasetVersion(@TempDir Path tempDir) { try (Dataset dataset = testDataset.createEmptyDataset()) { ZonedDateTime time1 = dataset.getVersion().getDataTime(); assertEquals(1, dataset.version()); + assertEquals(dataset.getVersion().getId(), dataset.version()); assertTrue(time1.isEqual(before) || time1.isAfter(before)); assertTrue(time1.isEqual(ZonedDateTime.now()) || time1.isBefore(ZonedDateTime.now())); assertEquals(time1.getZone(), Clock.systemUTC().getZone()); @@ -187,8 +188,10 @@ void testDatasetVersion(@TempDir Path tempDir) { try (Dataset dataset2 = testDataset.write(1, 5)) { ZonedDateTime time2 = dataset2.getVersion().getDataTime(); assertEquals(1, dataset.version()); + assertEquals(dataset.getVersion().getId(), dataset.version()); assertEquals(2, dataset.latestVersion()); assertEquals(2, dataset2.version()); + assertEquals(dataset2.getVersion().getId(), dataset2.version()); assertEquals(2, dataset2.latestVersion()); assertTrue(time2.isEqual(before) || time2.isAfter(before)); assertTrue(time2.isEqual(time1) || time2.isAfter(time1)); @@ -198,6 +201,7 @@ void testDatasetVersion(@TempDir Path tempDir) { ReadOptions options1 = new ReadOptions.Builder().setVersion(1).build(); try (Dataset datasetV1 = Dataset.open(allocator, datasetPath, options1)) { assertEquals(1, datasetV1.version()); + assertEquals(datasetV1.getVersion().getId(), datasetV1.version()); assertTrue(time1.isEqual(dataset.getVersion().getDataTime())); assertEquals(2, datasetV1.latestVersion()); } @@ -206,19 +210,23 @@ void testDatasetVersion(@TempDir Path tempDir) { try (Dataset dataset3 = testDataset.write(2, 3)) { ZonedDateTime time3 = dataset3.getVersion().getDataTime(); assertEquals(1, dataset.version()); + assertEquals(dataset.getVersion().getId(), dataset.version()); assertTrue(time1.isEqual(dataset.getVersion().getDataTime())); assertEquals(3, dataset.latestVersion()); assertEquals(2, dataset2.version()); + assertEquals(dataset2.getVersion().getId(), dataset2.version()); assertTrue(time2.isEqual(dataset2.getVersion().getDataTime())); assertEquals(3, dataset2.latestVersion()); assertTrue(time3.isEqual(before) || time3.isAfter(before)); assertEquals(3, dataset3.version()); + assertEquals(dataset3.getVersion().getId(), dataset3.version()); assertEquals(3, dataset3.latestVersion()); // Open dataset with version 2 ReadOptions options2 = new ReadOptions.Builder().setVersion(2).build(); try (Dataset datasetV2 = Dataset.open(allocator, datasetPath, options2)) { assertEquals(2, datasetV2.version()); + assertEquals(datasetV2.getVersion().getId(), datasetV2.version()); assertTrue(time2.isEqual(datasetV2.getVersion().getDataTime())); assertEquals(3, datasetV2.latestVersion()); } @@ -226,6 +234,7 @@ void testDatasetVersion(@TempDir Path tempDir) { // Open dataset with latest version (3) try (Dataset datasetLatest = Dataset.open(datasetPath, allocator)) { assertEquals(3, datasetLatest.version()); + assertEquals(datasetLatest.getVersion().getId(), datasetLatest.version()); assertTrue(time3.isEqual(datasetLatest.getVersion().getDataTime())); assertEquals(3, datasetLatest.latestVersion()); } @@ -242,6 +251,7 @@ void testDatasetVersion(@TempDir Path tempDir) { assertArrayEquals(versions.toArray(), dataset3.listVersions().toArray()); dataset.checkoutLatest(); assertEquals(3, dataset.version()); + assertEquals(dataset.getVersion().getId(), dataset.version()); assertTrue(time3.isEqual(dataset.getVersion().getDataTime())); assertEquals(3, dataset.latestVersion()); diff --git a/python/python/tests/test_dataset.py b/python/python/tests/test_dataset.py index 3df324a13f7..889c37036a0 100644 --- a/python/python/tests/test_dataset.py +++ b/python/python/tests/test_dataset.py @@ -329,6 +329,11 @@ def test_version_id(tmp_path: Path): assert updated_ds.version == 2 assert updated_ds.latest_version == 2 + historical_ds = updated_ds.checkout_version(1) + assert historical_ds.version == 1 + assert historical_ds.latest_version == 2 + assert historical_ds.checkout_version(historical_ds.latest_version).version == 2 + def test_checkout(tmp_path: Path): tab = pa.table({"a": range(3)}) diff --git a/python/src/dataset.rs b/python/src/dataset.rs index 402bc010268..35306636c93 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -1531,7 +1531,7 @@ impl Dataset { /// Fetches the currently checked out version of the dataset. fn version(&self) -> PyResult { - Ok(self.ds.version().version) + Ok(self.ds.version_id()) } fn latest_version(self_: PyRef<'_, Self>) -> PyResult { @@ -3010,7 +3010,7 @@ impl Dataset { } else { Ok(Ref::Version( self.ds.manifest.branch.clone(), - Some(self.ds.version().version), + Some(self.ds.version_id()), )) } } diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 4d9af7bf04e..817954da710 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -1799,6 +1799,18 @@ impl Dataset { self.session.clone() } + /// Get the currently checked-out version id. + /// + /// This is a cheap accessor that reads the id directly from the loaded + /// manifest without constructing the full [Version] summary. + pub fn version_id(&self) -> u64 { + self.manifest.version + } + + /// Get the currently checked-out version details. + /// + /// This constructs a full [Version], including summary metadata derived + /// from the loaded manifest fragments. pub fn version(&self) -> Version { Version::from(self.manifest.as_ref()) } diff --git a/rust/lance/src/dataset/tests/dataset_versioning.rs b/rust/lance/src/dataset/tests/dataset_versioning.rs index 024c4254af6..e9253cc69fe 100644 --- a/rust/lance/src/dataset/tests/dataset_versioning.rs +++ b/rust/lance/src/dataset/tests/dataset_versioning.rs @@ -161,6 +161,53 @@ async fn test_strict_overwrite() { .expect("Unstrict overwrite should succeed when committing to a stale version"); } +#[tokio::test] +async fn test_version_id_fast_path() { + let test_uri = TempStrDir::default(); + let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new( + "i", + DataType::UInt32, + false, + )])); + + let data = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(UInt32Array::from_iter_values(0..5))], + ) + .unwrap(); + let reader = RecordBatchIterator::new(vec![data].into_iter().map(Ok), schema.clone()); + + let original = Dataset::write(reader, &test_uri, None).await.unwrap(); + assert_eq!(original.version_id(), 1); + assert_eq!(original.version_id(), original.version().version); + assert_eq!(original.latest_version_id().await.unwrap(), 1); + + let data = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(UInt32Array::from_iter_values(5..10))], + ) + .unwrap(); + let reader = RecordBatchIterator::new(vec![data].into_iter().map(Ok), schema); + let updated = Dataset::write( + reader, + &test_uri, + Some(WriteParams { + mode: WriteMode::Append, + ..Default::default() + }), + ) + .await + .unwrap(); + assert_eq!(updated.version_id(), 2); + assert_eq!(updated.version_id(), updated.version().version); + assert_eq!(updated.latest_version_id().await.unwrap(), 2); + + let historical = updated.checkout_version(1).await.unwrap(); + assert_eq!(historical.version_id(), 1); + assert_eq!(historical.version_id(), historical.version().version); + assert_eq!(historical.latest_version_id().await.unwrap(), 2); +} + #[rstest] #[tokio::test] async fn test_restore( From 8b8e36a214cd514d67b7a0b765b9ec7d35f584bd Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 27 Mar 2026 16:16:17 +0800 Subject: [PATCH 088/206] feat: clarify logical indices and physical index segments (#6270) This change makes the logical-index and physical-segment split explicit in the user-facing index APIs without breaking existing behavior. `describe_indices` remains the logical view, `describe_index_segments` becomes the explicit physical-segment view, and index statistics now expose `num_segments` / `segments` alongside the legacy fields for compatibility. The Rust, Python, and Java bindings now use the same model so segment-aware callers do not need to infer semantics from raw manifest metadata. I validated the Rust path with `cargo test -p lance test_optimize_delta_indices -- --nocapture` and the Java path with `./mvnw -q -Dtest=DatasetTest#testDescribeIndicesByName test`. --- java/src/main/java/org/lance/Dataset.java | 6 +++- .../org/lance/index/IndexDescription.java | 9 +++++ java/src/test/java/org/lance/DatasetTest.java | 9 +++++ python/python/lance/dataset.py | 11 +++--- python/python/lance/indices/__init__.py | 2 ++ python/python/lance/lance/__init__.pyi | 1 + python/python/tests/test_vector_index.py | 16 +++++++++ python/src/indices.rs | 35 ++++++++++--------- rust/lance-index/src/traits.rs | 7 ++++ rust/lance/src/index.rs | 15 +++++++- 10 files changed, 88 insertions(+), 23 deletions(-) diff --git a/java/src/main/java/org/lance/Dataset.java b/java/src/main/java/org/lance/Dataset.java index b0517ca1bb8..d9c58e9b54a 100644 --- a/java/src/main/java/org/lance/Dataset.java +++ b/java/src/main/java/org/lance/Dataset.java @@ -1257,7 +1257,11 @@ public List listIndexes() { /** * Get all indexes with full metadata. * - * @return list of Index objects with complete metadata including index type and fragment coverage + *

Each returned {@link Index} is a physical index segment from the manifest. Use {@link + * #describeIndices()} for the logical-index view. + * + * @return list of Index objects with complete segment metadata, including index type and fragment + * coverage */ public List getIndexes() { try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { diff --git a/java/src/main/java/org/lance/index/IndexDescription.java b/java/src/main/java/org/lance/index/IndexDescription.java index d17782eb531..1b5e5a3a8f8 100755 --- a/java/src/main/java/org/lance/index/IndexDescription.java +++ b/java/src/main/java/org/lance/index/IndexDescription.java @@ -83,6 +83,15 @@ public List getMetadata() { return metadata; } + /** + * Physical index segments for this logical index. + * + *

This is an alias for {@link #getMetadata()} with a less ambiguous name. + */ + public List getSegments() { + return metadata; + } + /** * JSON representation of index-specific details. * diff --git a/java/src/test/java/org/lance/DatasetTest.java b/java/src/test/java/org/lance/DatasetTest.java index 00e5d930323..a707b4f4a3c 100644 --- a/java/src/test/java/org/lance/DatasetTest.java +++ b/java/src/test/java/org/lance/DatasetTest.java @@ -1924,14 +1924,23 @@ public void testDescribeIndicesByName(@TempDir Path tempDir) throws Exception { assertTrue(desc.getRowsIndexed() > 0, "rowsIndexed should be positive"); assertNotNull(desc.getMetadata(), "Metadata list should not be null"); assertFalse(desc.getMetadata().isEmpty(), "Metadata list should not be empty"); + assertEquals( + desc.getMetadata(), desc.getSegments(), "segments alias should match metadata"); assertNotNull(desc.getDetailsJson(), "Details JSON should not be null"); + assertEquals(1, desc.getSegments().size(), "Expected exactly one physical segment"); + assertEquals("index1", desc.getSegments().get(0).name()); + descriptions = dataset.describeIndices(); assertEquals(2, descriptions.size(), "Expected exactly one matching index"); for (IndexDescription indexDesc : descriptions) { assertTrue(indexDesc.getRowsIndexed() > 0, "rowsIndexed should be positive"); assertNotNull(indexDesc.getMetadata(), "Metadata list should not be null"); assertFalse(indexDesc.getMetadata().isEmpty(), "Metadata list should not be empty"); + assertEquals( + indexDesc.getMetadata(), + indexDesc.getSegments(), + "segments alias should match metadata"); assertNotNull(indexDesc.getDetailsJson(), "Details JSON should not be null"); } } diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index fd2db9de351..7496746285a 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -641,12 +641,13 @@ def checkout_latest(self): def list_indices(self) -> List[Index]: """ - Returns index information for all indices in the dataset. + Returns physical index segment information for all indices in the dataset. This method is deprecated as it requires loading the statistics for each index - which can be a very expensive operation. Instead use describe_indices() to - list index information and index_statistics() to get the statistics for - individual indexes of interest. + which can be a very expensive operation. It also exposes physical index + segments directly. Instead use describe_indices() for logical index + descriptions and index_statistics() to get the statistics for individual + indexes of interest. """ warnings.warn( "The 'list_indices' method is deprecated. It may be removed in a future " @@ -657,7 +658,7 @@ def list_indices(self) -> List[Index]: return self._ds.load_indices() def describe_indices(self) -> List[IndexDescription]: - """Returns index information for all indices in the dataset.""" + """Returns logical index information aggregated across all segments.""" return self._ds.describe_indices() def index_statistics(self, index_name: str) -> Dict[str, Any]: diff --git a/python/python/lance/indices/__init__.py b/python/python/lance/indices/__init__.py index 8dfb1148345..b35e5d5b174 100644 --- a/python/python/lance/indices/__init__.py +++ b/python/python/lance/indices/__init__.py @@ -9,6 +9,7 @@ from .pq import PqModel IndexSegment = _lance.indices.IndexSegment +IndexSegmentDescription = _lance.indices.IndexSegmentDescription IndexSegmentPlan = _lance.indices.IndexSegmentPlan __all__ = [ @@ -18,6 +19,7 @@ "IvfModel", "IndexFileVersion", "IndexSegment", + "IndexSegmentDescription", "IndexSegmentPlan", ] diff --git a/python/python/lance/lance/__init__.pyi b/python/python/lance/lance/__init__.pyi index e2f70a853a1..f0be29f39ca 100644 --- a/python/python/lance/lance/__init__.pyi +++ b/python/python/lance/lance/__init__.pyi @@ -62,6 +62,7 @@ from .fragment import ( ) from .indices import IndexDescription as IndexDescription from .indices import IndexSegment as IndexSegment +from .indices import IndexSegmentDescription as IndexSegmentDescription from .indices import IndexSegmentPlan as IndexSegmentPlan from .lance import PySearchFilter from .optimize import ( diff --git a/python/python/tests/test_vector_index.py b/python/python/tests/test_vector_index.py index 2451f4684b4..0c1d5ab9ed4 100644 --- a/python/python/tests/test_vector_index.py +++ b/python/python/tests/test_vector_index.py @@ -1643,6 +1643,22 @@ def test_optimize_indices(indexed_dataset): assert stats["num_indices"] == 2 +def test_logical_and_physical_index_views(indexed_dataset): + data = create_table() + indexed_dataset = lance.write_dataset(data, indexed_dataset.uri, mode="append") + indexed_dataset.optimize.optimize_indices(num_indices_to_merge=0) + + logical_indices = indexed_dataset.describe_indices() + assert len(logical_indices) == 1 + assert logical_indices[0].name == "vector_idx" + assert len(logical_indices[0].segments) == 2 + assert all(segment.fragment_ids for segment in logical_indices[0].segments) + + stats = indexed_dataset.stats.index_stats("vector_idx") + assert stats["num_segments"] == stats["num_indices"] == 2 + assert stats["segments"] == stats["indices"] + + @pytest.mark.skip(reason="retrain is deprecated") def test_retrain_indices(indexed_dataset): data = create_table() diff --git a/python/src/indices.rs b/python/src/indices.rs index 9651c6cc00e..c811c49f831 100644 --- a/python/src/indices.rs +++ b/python/src/indices.rs @@ -580,6 +580,24 @@ pub struct PyIndexSegmentDescription { } impl PyIndexSegmentDescription { + pub fn from_metadata(segment: &lance_table::format::IndexMetadata) -> Self { + let fragment_ids = segment + .fragment_bitmap + .as_ref() + .map(|bitmap| bitmap.iter().collect::>()) + .unwrap_or_default(); + let size_bytes = segment.total_size_bytes(); + + Self { + uuid: segment.uuid.to_string(), + dataset_version_at_last_update: segment.dataset_version, + fragment_ids, + index_version: segment.index_version, + created_at: segment.created_at, + size_bytes, + } + } + pub fn __repr__(&self) -> String { format!( "IndexSegmentDescription(uuid={}, dataset_version_at_last_update={}, fragment_ids={:?}, index_version={}, created_at={:?}, size_bytes={:?})", @@ -633,22 +651,7 @@ impl PyIndexDescription { let segments = index .metadata() .iter() - .map(|segment| { - let fragment_ids = segment - .fragment_bitmap - .as_ref() - .map(|bitmap| bitmap.iter().collect::>()) - .unwrap_or_default(); - let size_bytes = segment.total_size_bytes(); - PyIndexSegmentDescription { - uuid: segment.uuid.to_string(), - dataset_version_at_last_update: segment.dataset_version, - fragment_ids, - index_version: segment.index_version, - created_at: segment.created_at, - size_bytes, - } - }) + .map(PyIndexSegmentDescription::from_metadata) .collect(); let details = index.details().unwrap_or_else(|_| "{}".to_string()); diff --git a/rust/lance-index/src/traits.rs b/rust/lance-index/src/traits.rs index 130e59cad81..0b99954a727 100644 --- a/rust/lance-index/src/traits.rs +++ b/rust/lance-index/src/traits.rs @@ -72,6 +72,13 @@ pub trait IndexDescription: Send + Sync { /// IndexMetadata for each segment of the index. fn metadata(&self) -> &[IndexMetadata]; + /// Returns the physical index segments that make up this logical index. + /// + /// This is an alias for [`Self::metadata`] with a less ambiguous name. + fn segments(&self) -> &[IndexMetadata] { + self.metadata() + } + /// Returns the index type URL /// /// This is extracted from the type url of the index details diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index 843fc8c7740..c18cce66c6e 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -1160,7 +1160,9 @@ async fn index_statistics_scalar( "index_type": index_type, "name": index_name, "num_indices": num_indices, - "indices": indices_stats, + "num_segments": num_indices, + "indices": indices_stats.clone(), + "segments": indices_stats, "num_indexed_fragments": num_indexed_fragments, "num_indexed_rows": num_indexed_rows, "num_unindexed_fragments": num_unindexed_fragments, @@ -2408,8 +2410,13 @@ mod tests { fn get_bitmap(meta: &IndexMetadata) -> Vec { meta.fragment_bitmap.as_ref().unwrap().iter().collect() } + fn assert_segment_aliases(stats: &serde_json::Value) { + assert_eq!(stats["num_segments"], stats["num_indices"]); + assert_eq!(stats["segments"], stats["indices"]); + } let stats = get_stats(&dataset, "vec_idx").await; + assert_segment_aliases(&stats); assert_eq!(stats["num_unindexed_rows"], 0); assert_eq!(stats["num_indexed_rows"], 512); assert_eq!(stats["num_indexed_fragments"], 1); @@ -2422,6 +2429,7 @@ mod tests { RecordBatchIterator::new(vec![record_batch].into_iter().map(Ok), schema.clone()); dataset.append(reader, None).await.unwrap(); let stats = get_stats(&dataset, "vec_idx").await; + assert_segment_aliases(&stats); assert_eq!(stats["num_unindexed_rows"], 512); assert_eq!(stats["num_indexed_rows"], 512); assert_eq!(stats["num_indexed_fragments"], 1); @@ -2436,6 +2444,7 @@ mod tests { .await .unwrap(); let stats = get_stats(&dataset, "vec_idx").await; + assert_segment_aliases(&stats); assert_eq!(stats["num_unindexed_rows"], 512); assert_eq!(stats["num_indexed_rows"], 512); assert_eq!(stats["num_indexed_fragments"], 1); @@ -2453,6 +2462,7 @@ mod tests { .await .unwrap(); let stats = get_stats(&dataset, "vec_idx").await; + assert_segment_aliases(&stats); assert_eq!(stats["num_unindexed_rows"], 512); assert_eq!(stats["num_indexed_rows"], 512); assert_eq!(stats["num_indexed_fragments"], 1); @@ -2463,6 +2473,7 @@ mod tests { assert_eq!(get_bitmap(&meta[0]), vec![0]); let stats = get_stats(&dataset, "other_vec_idx").await; + assert_segment_aliases(&stats); assert_eq!(stats["num_unindexed_rows"], 0); assert_eq!(stats["num_indexed_rows"], 1024); assert_eq!(stats["num_indexed_fragments"], 2); @@ -2479,6 +2490,7 @@ mod tests { .unwrap(); let stats = get_stats(&dataset, "vec_idx").await; + assert_segment_aliases(&stats); assert_eq!(stats["num_unindexed_rows"], 0); assert_eq!(stats["num_indexed_rows"], 1024); assert_eq!(stats["num_indexed_fragments"], 2); @@ -2493,6 +2505,7 @@ mod tests { .await .unwrap(); let stats = get_stats(&dataset, "other_vec_idx").await; + assert_segment_aliases(&stats); assert_eq!(stats["num_unindexed_rows"], 0); assert_eq!(stats["num_indexed_rows"], 1024); assert_eq!(stats["num_indexed_fragments"], 2); From 05c796cc1ee763982ccfb90d24192352fea73126 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 27 Mar 2026 17:07:31 +0800 Subject: [PATCH 089/206] docs: shorten core major release vote window (#6154) This updates the documented minimum voting period for core stable major releases from 1 week to 3 days. It also updates the RC voting discussion automation so generated vote windows stay aligned with the documented policy. This follows the discussion in https://github.com/lance-format/lance/discussions/6147, where feedback converged on narrowing the proposal to core major releases only while keeping patch and subproject stable releases unchanged. --- ci/create_rc_discussion.sh | 2 +- docs/src/community/voting.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/create_rc_discussion.sh b/ci/create_rc_discussion.sh index 875308f85bd..c19ce9ce22f 100755 --- a/ci/create_rc_discussion.sh +++ b/ci/create_rc_discussion.sh @@ -21,7 +21,7 @@ DISCUSSION_TITLE="[VOTE] Release Candidate ${RC_TAG}" # Determine vote duration based on release type case "$RELEASE_TYPE" in major) - VOTE_DURATION_DAYS=7 + VOTE_DURATION_DAYS=3 ;; minor) VOTE_DURATION_DAYS=3 diff --git a/docs/src/community/voting.md b/docs/src/community/voting.md index d124c0db1b3..8c5ac341e67 100644 --- a/docs/src/community/voting.md +++ b/docs/src/community/voting.md @@ -44,7 +44,7 @@ A **-1** binding vote is considered a veto for all decision types. Vetoes: | Changes in maintainers and PMC rosters | 3 (excluding the people proposed for change) | PMC | Private Mailing List | 1 week | | Incubating subproject graduation to subproject | 3 | PMC | GitHub Discussions | 3 days | | Subproject management | 1 | PMC | GitHub Discussions | N/A | -| Release a new stable major version of the core project | 3 | PMC | GitHub Discussions | 1 week | +| Release a new stable major version of the core project | 3 | PMC | GitHub Discussions | 3 days | | Release a new stable minor version of the core project | 3 | PMC | GitHub Discussions | 3 days | | Release a new stable patch version of the core project | 3 | PMC | GitHub Discussions | N/A | | Lance Format Specification modifications | 3 (excluding proposer) | PMC | GitHub Discussions (with a GitHub PR) | 1 week | From 9b438524b63a45e002a23d64e009fd424a10ebb0 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 27 Mar 2026 17:18:57 +0800 Subject: [PATCH 090/206] test: tighten multi-segment vector index coverage and cleanup (#6251) This tightens the new multi-segment vector index path added in #6220. It enforces disjoint fragment coverage when committing a segment set, adds regression coverage that grouped segment coverage matches the union of its source shard coverage, and verifies that remap only touches segments covering affected fragments. It also adds cleanup coverage for both replaced committed segments and stale uncommitted `_indices/` artifacts, and documents these contracts in the distributed indexing guide. --- docs/src/guide/distributed_indexing.md | 7 + rust/lance/src/dataset/cleanup.rs | 198 +++++++++++++++++++++++++ rust/lance/src/dataset/index.rs | 118 +++++++++++++++ rust/lance/src/index.rs | 49 ++++++ rust/lance/src/index/vector/ivf/v2.rs | 42 +++++- 5 files changed, 413 insertions(+), 1 deletion(-) diff --git a/docs/src/guide/distributed_indexing.md b/docs/src/guide/distributed_indexing.md index abf0f5e9b0f..9c75c5ceb88 100644 --- a/docs/src/guide/distributed_indexing.md +++ b/docs/src/guide/distributed_indexing.md @@ -111,6 +111,8 @@ At that point the caller has two execution choices: After the physical segments are built, publish them with `commit_existing_index_segments(...)`. +Within a single commit, built segments must have disjoint fragment coverage. + ## Internal Segmented Finalize Model Internally, Lance models distributed vector segment build as: @@ -155,5 +157,10 @@ Lance is responsible for: - merging segment storage into physical segment artifacts - committing physical segments into the manifest +If a staging root or built segment directory is never committed, it remains an +unreferenced index directory under `_indices/`. These artifacts are cleaned up +by `cleanup_old_versions(...)` using the same age-based rules as other +unreferenced index files. + This split keeps distributed scheduling outside the storage engine while still letting Lance own the on-disk index format. diff --git a/rust/lance/src/dataset/cleanup.rs b/rust/lance/src/dataset/cleanup.rs index f5162bba191..bb7e6fe2ff2 100644 --- a/rust/lance/src/dataset/cleanup.rs +++ b/rust/lance/src/dataset/cleanup.rs @@ -1182,6 +1182,7 @@ mod tests { use crate::blob::{BlobArrayBuilder, blob_field}; use crate::index::DatasetIndexExt; use crate::{ + dataset::transaction::{Operation, Transaction}, dataset::{ReadParams, WriteMode, WriteParams, builder::DatasetBuilder}, index::vector::VectorIndexParams, }; @@ -1202,6 +1203,7 @@ mod tests { use lance_table::io::commit::RenameCommitHandler; use lance_testing::datagen::{BatchGenerator, IncrementingInt32, RandomVector, some_batch}; use mock_instant::thread_local::MockClock; + use uuid::Uuid; #[derive(Debug)] struct MockObjectStore { @@ -1530,6 +1532,59 @@ mod tests { } } + async fn write_dummy_index_artifact(dataset: &Dataset, uuid: Uuid) -> Result<()> { + let index_dir = dataset.indices_dir().child(uuid.to_string()); + dataset + .object_store() + .put(&index_dir.child("index.idx"), b"idx") + .await?; + dataset + .object_store() + .put(&index_dir.child("auxiliary.idx"), b"aux") + .await?; + Ok(()) + } + + async fn write_dummy_staging_partial( + dataset: &Dataset, + staging_uuid: Uuid, + shard_uuid: Uuid, + ) -> Result<()> { + let shard_dir = dataset + .indices_dir() + .child(staging_uuid.to_string()) + .child(format!("partial_{}", shard_uuid)); + dataset + .object_store() + .put(&shard_dir.child("index.idx"), b"idx") + .await?; + dataset + .object_store() + .put(&shard_dir.child("auxiliary.idx"), b"aux") + .await?; + Ok(()) + } + + fn dummy_index_metadata( + dataset: &Dataset, + field_id: i32, + uuid: Uuid, + fragment_bitmap: impl IntoIterator, + ) -> IndexMetadata { + IndexMetadata { + uuid, + name: "some_index".to_string(), + fields: vec![field_id], + dataset_version: dataset.version().version, + fragment_bitmap: Some(fragment_bitmap.into_iter().collect()), + index_details: None, + index_version: IndexType::Vector.version(), + created_at: None, + base_id: None, + files: None, + } + } + fn blob_v2_batch(blob_len: usize) -> Box { let mut blobs = BlobArrayBuilder::new(1); blobs.push_bytes(vec![0u8; blob_len]).unwrap(); @@ -2182,6 +2237,149 @@ mod tests { assert_eq!(before_count, after_count); } + #[tokio::test] + async fn cleanup_old_replaced_segment_keeps_still_referenced_segments() { + let fixture = MockDatasetFixture::try_new().unwrap(); + fixture.create_some_data().await.unwrap(); + + let mut dataset = fixture.open().await.unwrap(); + let field_id = dataset.schema().field("indexable").unwrap().id; + + let seg_a = Uuid::new_v4(); + let seg_b = Uuid::new_v4(); + write_dummy_index_artifact(&dataset, seg_a).await.unwrap(); + write_dummy_index_artifact(&dataset, seg_b).await.unwrap(); + + let index_a = dummy_index_metadata(&dataset, field_id, seg_a, [0_u32]); + let index_b = dummy_index_metadata(&dataset, field_id, seg_b, [1_u32]); + let initial_tx = Transaction::new( + dataset.manifest.version, + Operation::CreateIndex { + new_indices: vec![index_a.clone(), index_b.clone()], + removed_indices: vec![], + }, + None, + ); + dataset + .apply_commit(initial_tx, &Default::default(), &Default::default()) + .await + .unwrap(); + + MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap()); + + let seg_c = Uuid::new_v4(); + write_dummy_index_artifact(&dataset, seg_c).await.unwrap(); + let index_c = dummy_index_metadata(&dataset, field_id, seg_c, [2_u32]); + let replace_tx = Transaction::new( + dataset.manifest.version, + Operation::CreateIndex { + new_indices: vec![index_c.clone()], + removed_indices: vec![index_a.clone()], + }, + None, + ); + dataset + .apply_commit(replace_tx, &Default::default(), &Default::default()) + .await + .unwrap(); + + let removed = fixture + .run_cleanup(utc_now() - TimeDelta::try_days(7).unwrap()) + .await + .unwrap(); + + assert_eq!(removed.index_files_removed, 2); + assert!( + !dataset + .object_store() + .exists( + &dataset + .indices_dir() + .child(seg_a.to_string()) + .child("index.idx") + ) + .await + .unwrap() + ); + assert!( + dataset + .object_store() + .exists( + &dataset + .indices_dir() + .child(seg_b.to_string()) + .child("index.idx") + ) + .await + .unwrap() + ); + assert!( + dataset + .object_store() + .exists( + &dataset + .indices_dir() + .child(seg_c.to_string()) + .child("index.idx") + ) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn cleanup_old_uncommitted_index_artifacts() { + let fixture = MockDatasetFixture::try_new().unwrap(); + fixture.create_some_data().await.unwrap(); + + let dataset = fixture.open().await.unwrap(); + let staging_uuid = Uuid::new_v4(); + let shard_uuid = Uuid::new_v4(); + let built_segment_uuid = Uuid::new_v4(); + + write_dummy_staging_partial(&dataset, staging_uuid, shard_uuid) + .await + .unwrap(); + write_dummy_index_artifact(&dataset, built_segment_uuid) + .await + .unwrap(); + + MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap()); + + let removed = fixture + .run_cleanup(utc_now() - TimeDelta::try_days(7).unwrap()) + .await + .unwrap(); + + assert_eq!(removed.old_versions, 0); + assert_eq!(removed.index_files_removed, 4); + assert!( + !dataset + .object_store() + .exists( + &dataset + .indices_dir() + .child(staging_uuid.to_string()) + .child(format!("partial_{}", shard_uuid)) + .child("index.idx"), + ) + .await + .unwrap() + ); + assert!( + !dataset + .object_store() + .exists( + &dataset + .indices_dir() + .child(built_segment_uuid.to_string()) + .child("index.idx"), + ) + .await + .unwrap() + ); + } + #[tokio::test] async fn cleanup_failed_commit_data_file() { // We should clean up data files that are written but the commit failed diff --git a/rust/lance/src/dataset/index.rs b/rust/lance/src/dataset/index.rs index 91c535468e5..fded774b151 100644 --- a/rust/lance/src/dataset/index.rs +++ b/rust/lance/src/dataset/index.rs @@ -172,3 +172,121 @@ impl LanceIndexStoreExt for LanceIndexStore { Ok(store.with_file_sizes(index.file_size_map())) } } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::dataset::WriteParams; + use crate::index::vector::VectorIndexParams; + use crate::index::{DatasetIndexExt, IndexSegment}; + use lance_datagen::{BatchCount, RowCount, array}; + use lance_index::IndexType; + use lance_linalg::distance::MetricType; + use uuid::Uuid; + + #[tokio::test] + async fn test_remapper_only_touches_segments_with_affected_fragments() { + let test_dir = tempfile::tempdir().unwrap(); + let test_uri = test_dir.path().to_str().unwrap(); + + let reader = lance_datagen::gen_batch() + .col("id", array::step::()) + .col( + "vector", + array::rand_vec::(16.into()), + ) + .into_reader_rows(RowCount::from(40), BatchCount::from(2)); + + let mut dataset = Dataset::write( + reader, + test_uri, + Some(WriteParams { + max_rows_per_file: 20, + max_rows_per_group: 20, + ..Default::default() + }), + ) + .await + .unwrap(); + + let fragments = dataset.get_fragments(); + assert!( + fragments.len() >= 2, + "expected at least two fragments for this test" + ); + let target_fragments = fragments.iter().take(2).collect::>(); + + let params = VectorIndexParams::ivf_flat(2, MetricType::L2); + let first_segment_uuid = Uuid::new_v4(); + let second_segment_uuid = Uuid::new_v4(); + let built_index = dataset + .create_index_builder(&["vector"], IndexType::Vector, ¶ms) + .name("vector_idx".to_string()) + .index_uuid(first_segment_uuid.to_string()) + .execute_uncommitted() + .await + .unwrap(); + let first_segment_dir = dataset.indices_dir().child(first_segment_uuid.to_string()); + let second_segment_dir = dataset.indices_dir().child(second_segment_uuid.to_string()); + for file_name in ["index.idx", "auxiliary.idx"] { + dataset + .object_store() + .copy( + &first_segment_dir.child(file_name), + &second_segment_dir.child(file_name), + ) + .await + .unwrap(); + } + + let segments = vec![ + IndexSegment::new( + first_segment_uuid, + [target_fragments[0].id() as u32], + built_index.index_details.clone().unwrap(), + built_index.index_version, + ), + IndexSegment::new( + second_segment_uuid, + [target_fragments[1].id() as u32], + built_index.index_details.clone().unwrap(), + built_index.index_version, + ), + ]; + + dataset + .commit_existing_index_segments("vector_idx", "vector", segments) + .await + .unwrap(); + let committed = dataset.load_indices_by_name("vector_idx").await.unwrap(); + let committed_ids = committed + .iter() + .map(|segment| segment.uuid) + .collect::>(); + let unaffected_segment_id = committed + .iter() + .find(|segment| { + segment + .fragment_bitmap + .as_ref() + .is_some_and(|bitmap| bitmap.contains(target_fragments[1].id() as u32)) + }) + .map(|segment| segment.uuid) + .expect("expected one committed segment to cover the unaffected fragment"); + + let remapper = DatasetIndexRemapperOptions::default() + .create_remapper(&dataset) + .unwrap(); + let remapped = remapper + .remap_indices(HashMap::new(), &[target_fragments[0].id() as u64]) + .await + .unwrap(); + + assert_eq!(remapped.len(), 1); + assert!(committed_ids.contains(&remapped[0].old_id)); + assert_ne!(remapped[0].old_id, unaffected_segment_id); + assert_ne!(remapped[0].new_id, unaffected_segment_id); + } +} diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index c18cce66c6e..6a88441029e 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -98,6 +98,7 @@ fn validate_index_segments(index_name: &str, segments: &[IndexSegment]) -> Resul } let mut seen_segment_ids = HashSet::with_capacity(segments.len()); + let mut covered_fragments = RoaringBitmap::new(); for segment in segments { if !seen_segment_ids.insert(segment.uuid()) { return Err(Error::invalid_input(format!( @@ -106,6 +107,13 @@ fn validate_index_segments(index_name: &str, segments: &[IndexSegment]) -> Resul index_name ))); } + if !covered_fragments.is_disjoint(segment.fragment_bitmap()) { + return Err(Error::invalid_input(format!( + "CreateIndex: overlapping fragment coverage in segment set for index '{}'", + index_name + ))); + } + covered_fragments |= segment.fragment_bitmap().clone(); } Ok(()) @@ -5388,6 +5396,47 @@ mod tests { assert!(err.to_string().contains("at least one index segment")); } + #[tokio::test] + async fn test_commit_existing_index_segments_rejects_overlapping_fragment_coverage() { + use lance_datagen::{BatchCount, RowCount, array}; + + let test_dir = tempfile::tempdir().unwrap(); + let test_uri = test_dir.path().to_str().unwrap(); + + let reader = lance_datagen::gen_batch() + .col("id", array::step::()) + .col( + "vector", + array::rand_vec::(8.into()), + ) + .into_reader_rows(RowCount::from(20), BatchCount::from(2)); + + let mut dataset = Dataset::write(reader, test_uri, None).await.unwrap(); + + let err = dataset + .commit_existing_index_segments( + "vector_idx", + "vector", + vec![ + IndexSegment::new( + Uuid::new_v4(), + [0_u32, 1_u32], + Arc::new(vector_index_details()), + IndexType::Vector.version(), + ), + IndexSegment::new( + Uuid::new_v4(), + [1_u32], + Arc::new(vector_index_details()), + IndexType::Vector.version(), + ), + ], + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("overlapping fragment coverage")); + } + #[tokio::test] async fn test_resolve_index_column_error_cases() { use lance_datagen::{BatchCount, RowCount, array}; diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 8d3b50d58c6..01577b11936 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -1967,6 +1967,23 @@ mod tests { .unwrap(); assert!(grouped_plan.len() < shard_count); assert!(grouped_plan.iter().any(|plan| plan.segments().len() > 1)); + let mut expected_fragment_coverage = grouped_plan + .iter() + .map(|plan| { + plan.segments() + .iter() + .flat_map(|partial| { + partial + .fragment_bitmap + .as_ref() + .expect("partial shard should have fragment coverage") + .iter() + }) + .sorted() + .collect::>() + }) + .collect::>(); + expected_fragment_coverage.sort(); let grouped_segments = build_distributed_segments( &mut ds_split, @@ -1976,6 +1993,15 @@ mod tests { ) .await; assert_eq!(grouped_segments.len(), grouped_plan.len()); + let mut actual_fragment_coverage = grouped_segments + .iter() + .map(|segment| segment.fragment_bitmap().iter().collect::>()) + .collect::>(); + actual_fragment_coverage.sort(); + assert_eq!( + actual_fragment_coverage, expected_fragment_coverage, + "built segment coverage should equal the union of its source partial shards", + ); async fn collect_row_ids(ds: &Dataset, queries: &[Arc]) -> Vec> { let mut ids_per_query = Vec::with_capacity(queries.len()); @@ -2020,7 +2046,21 @@ mod tests { let ids_single = collect_row_ids(&ds_single, &queries).await; let ids_split = collect_row_ids(&ds_split, &queries).await; - assert_eq!(ids_single, ids_split); + if matches!(index_type, IndexType::IvfSq) { + for (single, split) in ids_single.iter().zip(ids_split.iter()) { + assert_eq!(single.len(), split.len()); + let overlap = single + .iter() + .filter(|row_id| split.contains(row_id)) + .count(); + assert!( + overlap >= K / 3, + "single vs segmented distributed SQ index returned too little top-k overlap", + ); + } + } else { + assert_eq!(ids_single, ids_split); + } } #[tokio::test] From 92c45f67f29fc0f41c9ac268e7afe34684ed3887 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 27 Mar 2026 19:51:00 +0800 Subject: [PATCH 091/206] feat: support non-shared centroid vector index builds (#6296) This PR builds on #6294 and exposes the remaining pieces needed to construct non-shared centroid vector index builds. It adds fragment-scoped IVF/PQ training in Rust and exports the same training flow to Python, so users can train per-segment artifacts and feed them into the existing distributed build path. --- java/lance-jni/src/vector_trainer.rs | 1 + python/python/lance/indices/builder.py | 36 +++++++- .../python/lance/lance/indices/__init__.pyi | 2 + python/python/tests/test_indices.py | 52 ++++++++++++ python/src/indices.rs | 14 +++- rust/lance/src/index/create.rs | 5 +- rust/lance/src/index/vector.rs | 4 +- rust/lance/src/index/vector/builder.rs | 4 +- rust/lance/src/index/vector/ivf.rs | 19 ++++- rust/lance/src/index/vector/pq.rs | 15 +++- rust/lance/src/index/vector/utils.rs | 83 +++++++++++++++++-- 11 files changed, 214 insertions(+), 21 deletions(-) diff --git a/java/lance-jni/src/vector_trainer.rs b/java/lance-jni/src/vector_trainer.rs index 92b6afa084b..9ea164d3586 100755 --- a/java/lance-jni/src/vector_trainer.rs +++ b/java/lance-jni/src/vector_trainer.rs @@ -117,6 +117,7 @@ fn inner_train_ivf_centroids<'local>( dim, metric_type, &ivf_params, + None, Arc::new(NoopIndexBuildProgress), ))?; diff --git a/python/python/lance/indices/builder.py b/python/python/lance/indices/builder.py index 7105c9234bb..c31ea0a7a0c 100644 --- a/python/python/lance/indices/builder.py +++ b/python/python/lance/indices/builder.py @@ -66,6 +66,7 @@ def train_ivf( accelerator: Optional[Union[str, "torch.Device"]] = None, sample_rate: int = 256, max_iters: int = 50, + fragment_ids: Optional[list[int]] = None, ) -> IvfModel: """ Train IVF centroids for the given vector column. @@ -106,8 +107,10 @@ def train_ivf( some cases, k-means will not converge but will cycle between various possible minima. In these cases we must terminate or run forever. The max_iters parameter defines a cutoff at which we terminate training. + fragment_ids: list[int], optional + If provided, train using only the specified fragments from the dataset. """ - num_rows = self.dataset.count_rows() + num_rows = self._count_rows(fragment_ids) num_partitions = self._determine_num_partitions(num_partitions, num_rows) self._verify_ivf_sample_rate(sample_rate, num_partitions, num_rows) distance_type = self._normalize_distance_type(distance_type) @@ -124,9 +127,14 @@ def train_ivf( distance_type, sample_rate, max_iters, + fragment_ids, ) return IvfModel(ivf_centroids, distance_type) else: + if fragment_ids is not None: + raise NotImplementedError( + "fragment_ids is not supported with accelerator IVF training" + ) # Use accelerator to train ivf centroids from lance.vector import train_ivf_centroids_on_accelerator @@ -154,6 +162,7 @@ def train_pq( *, sample_rate: int = 256, max_iters: int = 50, + fragment_ids: Optional[list[int]] = None, ) -> PqModel: """ Train a PQ model for a given column. @@ -184,10 +193,12 @@ def train_pq( This parameter is used in the same way as in the IVF model. max_iters: int This parameter is used in the same way as in the IVF model. + fragment_ids: list[int], optional + If provided, train using only the specified fragments from the dataset. """ from lance.lance import indices - num_rows = self.dataset.count_rows() + num_rows = self._count_rows(fragment_ids) self.dataset.schema.field(self.column[0]).type.list_size num_subvectors = self._normalize_pq_params(num_subvectors, self.dimension) self._verify_pq_sample_rate(num_rows, sample_rate) @@ -201,6 +212,7 @@ def train_pq( sample_rate, max_iters, ivf_model.centroids, + fragment_ids, ) return PqModel(num_subvectors, pq_codebook) @@ -213,11 +225,17 @@ def prepare_global_ivf_pq( accelerator: Optional[Union[str, "torch.Device"]] = None, sample_rate: int = 256, max_iters: int = 50, + fragment_ids: Optional[list[int]] = None, ) -> dict: """ Perform global training for IVF+PQ using existing CPU training paths and return preprocessed artifacts for distributed builds. + Parameters + ---------- + fragment_ids: list[int], optional + If provided, train using only the specified fragments from the dataset. + Returns ------- dict @@ -239,6 +257,7 @@ def prepare_global_ivf_pq( accelerator=accelerator, # None by default (CPU path) sample_rate=sample_rate, max_iters=max_iters, + fragment_ids=fragment_ids, ) # Global PQ training using IVF residuals @@ -247,6 +266,7 @@ def prepare_global_ivf_pq( num_subvectors, sample_rate=sample_rate, max_iters=max_iters, + fragment_ids=fragment_ids, ) return {"ivf_centroids": ivf_model.centroids, "pq_codebook": pq_model.codebook} @@ -459,6 +479,18 @@ def _determine_num_partitions(self, num_partitions: Optional[int], num_rows: int return round(math.sqrt(num_rows)) return num_partitions + def _count_rows(self, fragment_ids: Optional[list[int]] = None) -> int: + if fragment_ids is None: + return self.dataset.count_rows() + + num_rows = 0 + for fragment_id in fragment_ids: + fragment = self.dataset.get_fragment(fragment_id) + if fragment is None: + raise ValueError(f"Fragment id does not exist: {fragment_id}") + num_rows += fragment.count_rows() + return num_rows + def _normalize_pq_params(self, num_subvectors: int, dimension: int): if num_subvectors is None: if dimension % 16 == 0: diff --git a/python/python/lance/lance/indices/__init__.pyi b/python/python/lance/lance/indices/__init__.pyi index e1282b675a6..3369b61c619 100644 --- a/python/python/lance/lance/indices/__init__.pyi +++ b/python/python/lance/lance/indices/__init__.pyi @@ -46,6 +46,7 @@ def train_ivf_model( distance_type: str, sample_rate: int, max_iters: int, + fragment_ids: Optional[list[int]] = None, ) -> pa.Array: ... def train_pq_model( dataset, @@ -56,6 +57,7 @@ def train_pq_model( sample_rate: int, max_iters: int, ivf_model: pa.Array, + fragment_ids: Optional[list[int]] = None, ) -> pa.Array: ... def transform_vectors( dataset, diff --git a/python/python/tests/test_indices.py b/python/python/tests/test_indices.py index f0444e4abcb..88cae659561 100644 --- a/python/python/tests/test_indices.py +++ b/python/python/tests/test_indices.py @@ -184,6 +184,58 @@ def test_gen_pq(tmpdir, rand_dataset, rand_ivf): assert pq.codebook == reloaded.codebook +def test_ivf_centroids_fragment_ids(tmpdir): + rows_per_fragment = 32 + vectors = np.concatenate( + [ + np.zeros((rows_per_fragment, DIMENSION), dtype=np.float32), + np.full((rows_per_fragment, DIMENSION), 10.0, dtype=np.float32), + ], + axis=0, + ) + vectors.shape = -1 + table = pa.Table.from_arrays( + [pa.FixedSizeListArray.from_arrays(vectors, DIMENSION)], names=["vectors"] + ) + ds = lance.write_dataset( + table, + pathlib.Path(tmpdir) / "fragment_ivf", + max_rows_per_file=rows_per_fragment, + ) + fragment_ids = [fragment.fragment_id for fragment in ds.get_fragments()] + + first_ivf = IndicesBuilder(ds, "vectors").train_ivf( + num_partitions=1, sample_rate=2, fragment_ids=[fragment_ids[0]] + ) + second_ivf = IndicesBuilder(ds, "vectors").train_ivf( + num_partitions=1, sample_rate=2, fragment_ids=[fragment_ids[1]] + ) + + first_centroid = first_ivf.centroids.values.to_numpy().reshape(-1, DIMENSION)[0] + second_centroid = second_ivf.centroids.values.to_numpy().reshape(-1, DIMENSION)[0] + + assert np.allclose(first_centroid, 0.0, atol=1e-4) + assert np.allclose(second_centroid, 10.0, atol=1e-4) + + +def test_pq_fragment_ids(rand_dataset): + fragment_id = rand_dataset.get_fragments()[0].fragment_id + ivf = IndicesBuilder(rand_dataset, "vectors").train_ivf( + num_partitions=4, + sample_rate=16, + fragment_ids=[fragment_id], + ) + + pq = IndicesBuilder(rand_dataset, "vectors").train_pq( + ivf, + sample_rate=2, + fragment_ids=[fragment_id], + ) + + assert pq.dimension == DIMENSION + assert pq.num_subvectors == NUM_SUBVECTORS + + def test_pq_invalid_sub_vectors(tmpdir, rand_dataset, rand_ivf): with pytest.raises( ValueError, diff --git a/python/src/indices.rs b/python/src/indices.rs index c811c49f831..cea7f2a968a 100644 --- a/python/src/indices.rs +++ b/python/src/indices.rs @@ -11,6 +11,7 @@ use arrow_data::ArrayData; use chrono::{DateTime, Utc}; use lance::dataset::Dataset as LanceDataset; use lance::index::vector::ivf::builder::write_vector_storage; +use lance::index::vector::pq::build_pq_model_in_fragments; use lance::index::{DatasetIndexExt, IndexSegment, IndexSegmentPlan}; use lance::io::ObjectStore; use lance_index::progress::NoopIndexBuildProgress; @@ -198,6 +199,7 @@ fn get_ivf_model(py: Python<'_>, dataset: &Dataset, index_name: &str) -> PyResul Py::new(py, PyIvfModel { inner: ivf_model }) } +#[allow(clippy::too_many_arguments)] async fn do_train_ivf_model( dataset: &Dataset, column: &str, @@ -206,6 +208,7 @@ async fn do_train_ivf_model( distance_type: &str, sample_rate: u32, max_iters: u32, + fragment_ids: Option>, ) -> PyResult { // We verify distance_type earlier so can unwrap here let distance_type = DistanceType::try_from(distance_type).unwrap(); @@ -221,6 +224,7 @@ async fn do_train_ivf_model( dimension, distance_type, ¶ms, + fragment_ids.as_deref(), Arc::new(NoopIndexBuildProgress), ) .await @@ -231,6 +235,7 @@ async fn do_train_ivf_model( #[pyfunction] #[allow(clippy::too_many_arguments)] +#[pyo3(signature=(dataset, column, dimension, num_partitions, distance_type, sample_rate, max_iters, fragment_ids=None))] fn train_ivf_model<'py>( py: Python<'py>, dataset: &Dataset, @@ -240,6 +245,7 @@ fn train_ivf_model<'py>( distance_type: &str, sample_rate: u32, max_iters: u32, + fragment_ids: Option>, ) -> PyResult> { let centroids = rt().block_on( Some(py), @@ -251,6 +257,7 @@ fn train_ivf_model<'py>( distance_type, sample_rate, max_iters, + fragment_ids, ), )??; centroids.to_pyarrow(py) @@ -266,6 +273,7 @@ async fn do_train_pq_model( sample_rate: u32, max_iters: u32, ivf_model: IvfModel, + fragment_ids: Option>, ) -> PyResult { // We verify distance_type earlier so can unwrap here let distance_type = DistanceType::try_from(distance_type).unwrap(); @@ -276,13 +284,14 @@ async fn do_train_pq_model( sample_rate: sample_rate as usize, ..Default::default() }; - let pq_model = lance::index::vector::pq::build_pq_model( + let pq_model = build_pq_model_in_fragments( dataset.ds.as_ref(), column, dimension, distance_type, ¶ms, Some(&ivf_model), + fragment_ids.as_deref(), ) .await .infer_error()?; @@ -291,6 +300,7 @@ async fn do_train_pq_model( #[pyfunction] #[allow(clippy::too_many_arguments)] +#[pyo3(signature=(dataset, column, dimension, num_subvectors, distance_type, sample_rate, max_iters, ivf_centroids, fragment_ids=None))] fn train_pq_model<'py>( py: Python<'py>, dataset: &Dataset, @@ -301,6 +311,7 @@ fn train_pq_model<'py>( sample_rate: u32, max_iters: u32, ivf_centroids: PyArrowType, + fragment_ids: Option>, ) -> PyResult> { let ivf_centroids = ivf_centroids.0; let ivf_centroids = FixedSizeListArray::from(ivf_centroids); @@ -321,6 +332,7 @@ fn train_pq_model<'py>( sample_rate, max_iters, ivf_model, + fragment_ids, ), )??; codebook.to_pyarrow(py) diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index 0bf9fdd283c..7403e2b24c9 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -640,8 +640,9 @@ mod tests { use crate::utils::test::{DatagenExt, FragmentCount, FragmentRowCount}; use arrow::datatypes::{Float32Type, Int32Type}; use arrow_array::cast::AsArray; - use arrow_array::{FixedSizeListArray, RecordBatchIterator}; - use arrow_array::{Int32Array, RecordBatch, StringArray}; + use arrow_array::{ + FixedSizeListArray, Int32Array, RecordBatch, RecordBatchIterator, StringArray, + }; use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; use lance_arrow::FixedSizeListArrayExt; use lance_core::utils::tempfile::TempStrDir; diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index 4b5b113cae9..a60d4941a07 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -569,7 +569,6 @@ pub(crate) async fn build_distributed_vector_index( stages ))); }; - IvfIndexBuilder::::new( filtered_dataset, column.to_owned(), @@ -664,7 +663,6 @@ pub(crate) async fn build_distributed_vector_index( stages ))); }; - IvfIndexBuilder::::new( filtered_dataset, column.to_owned(), @@ -2141,6 +2139,7 @@ mod tests { dim, MetricType::L2, &ivf_params, + None, noop_progress(), ) .await @@ -2193,6 +2192,7 @@ mod tests { dim, MetricType::L2, &ivf_params, + None, noop_progress(), ) .await diff --git a/rust/lance/src/index/vector/builder.rs b/rust/lance/src/index/vector/builder.rs index 1f871046b71..017d50319f8 100644 --- a/rust/lance/src/index/vector/builder.rs +++ b/rust/lance/src/index/vector/builder.rs @@ -405,6 +405,7 @@ impl IvfIndexBuilder dim, self.distance_type, ivf_params, + None, self.progress.clone(), ) .await @@ -434,7 +435,8 @@ impl IvfIndexBuilder sample_size_hint ); let training_data = - utils::maybe_sample_training_data(dataset, &self.column, sample_size_hint).await?; + utils::maybe_sample_training_data(dataset, &self.column, sample_size_hint, None) + .await?; info!( "Finished loading training data in {:02} seconds", start.elapsed().as_secs_f32() diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 90864154094..d58cbae2fcd 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -1223,6 +1223,7 @@ pub async fn build_ivf_model( dim: usize, metric_type: MetricType, params: &IvfBuildParams, + fragment_ids: Option<&[u32]>, progress: std::sync::Arc, ) -> Result { let num_partitions = params.num_partitions.unwrap(); @@ -1245,7 +1246,8 @@ pub async fn build_ivf_model( "Loading training data for IVF. Sample size: {}", sample_size_hint ); - let training_data = maybe_sample_training_data(dataset, column, sample_size_hint).await?; + let training_data = + maybe_sample_training_data(dataset, column, sample_size_hint, fragment_ids).await?; info!( "Finished loading training data in {:02} seconds", start.elapsed().as_secs_f32() @@ -1302,8 +1304,16 @@ async fn build_ivf_model_and_pq( get_vector_type(dataset.schema(), column)?; let dim = get_vector_dim(dataset.schema(), column)?; - let ivf_model = - build_ivf_model(dataset, column, dim, metric_type, ivf_params, progress).await?; + let ivf_model = build_ivf_model( + dataset, + column, + dim, + metric_type, + ivf_params, + None, + progress, + ) + .await?; let ivf_residual = if matches!(metric_type, MetricType::Cosine | MetricType::L2) { Some(&ivf_model) @@ -3287,6 +3297,7 @@ mod tests { DIM, MetricType::L2, &ivf_params, + None, lance_index::progress::noop_progress(), ) .await @@ -3322,6 +3333,7 @@ mod tests { DIM, MetricType::Cosine, &ivf_params, + None, lance_index::progress::noop_progress(), ) .await @@ -3881,6 +3893,7 @@ mod tests { DIM, MetricType::L2, &ivf_params, + None, progress, ) .await diff --git a/rust/lance/src/index/vector/pq.rs b/rust/lance/src/index/vector/pq.rs index d89999d921c..615f1b9c829 100644 --- a/rust/lance/src/index/vector/pq.rs +++ b/rust/lance/src/index/vector/pq.rs @@ -502,6 +502,18 @@ pub async fn build_pq_model( metric_type: MetricType, params: &PQBuildParams, ivf: Option<&IvfModel>, +) -> Result { + build_pq_model_in_fragments(dataset, column, dim, metric_type, params, ivf, None).await +} + +pub async fn build_pq_model_in_fragments( + dataset: &Dataset, + column: &str, + dim: usize, + metric_type: MetricType, + params: &PQBuildParams, + ivf: Option<&IvfModel>, + fragment_ids: Option<&[u32]>, ) -> Result { let num_codes = 2_usize.pow(params.num_bits as u32); @@ -542,7 +554,7 @@ pub async fn build_pq_model( ); let start = std::time::Instant::now(); let mut training_data = - maybe_sample_training_data(dataset, column, expected_sample_size).await?; + maybe_sample_training_data(dataset, column, expected_sample_size, fragment_ids).await?; info!( "Finished loading training data in {:02} seconds", start.elapsed().as_secs_f32() @@ -712,6 +724,7 @@ mod tests { DIM, MetricType::Cosine, &ivf_params, + None, lance_index::progress::noop_progress(), ) .await diff --git a/rust/lance/src/index/vector/utils.rs b/rust/lance/src/index/vector/utils.rs index b20d659d6f3..f00a81b764d 100644 --- a/rust/lance/src/index/vector/utils.rs +++ b/rust/lance/src/index/vector/utils.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use arrow::array::ArrayData; use arrow::datatypes::DataType; use arrow_array::new_empty_array; -use arrow_array::{Array, ArrayRef, FixedSizeListArray, RecordBatch, cast::AsArray}; +use arrow_array::{Array, ArrayRef, FixedSizeListArray, RecordBatch, UInt32Array, cast::AsArray}; use arrow_buffer::{Buffer, MutableBuffer}; use futures::StreamExt; use lance_arrow::DataTypeExt; @@ -86,6 +86,7 @@ async fn estimate_multivector_vectors_per_row( dataset: &Dataset, column: &str, num_rows: usize, + fragments: Option<&[u32]>, ) -> Result { if num_rows == 0 { return Ok(1030); @@ -96,7 +97,9 @@ async fn estimate_multivector_vectors_per_row( // Try a few random samples first (fast path). let sample_batch_size = std::cmp::min(64, num_rows); for _ in 0..8 { - let batch = dataset.sample(sample_batch_size, &projection, None).await?; + let batch = dataset + .sample(sample_batch_size, &projection, fragments) + .await?; let array = get_column_from_batch(&batch, column)?; let list_array = array.as_list::(); for i in 0..list_array.len() { @@ -114,6 +117,9 @@ async fn estimate_multivector_vectors_per_row( // flakiness when values are extremely sparse. let mut scanner = dataset.scan(); scanner.project(&[column])?; + if let Some(fragments) = fragments { + scanner.with_fragments(resolve_scan_fragments(dataset, fragments)?); + } let column_expr = lance_datafusion::logical_expr::field_path_to_expr(column)?; scanner.filter_expr(column_expr.is_not_null()); scanner.limit(Some(std::cmp::min(num_rows, 1024) as i64), None)?; @@ -261,8 +267,15 @@ pub async fn maybe_sample_training_data( dataset: &Dataset, column: &str, sample_size_hint: usize, + fragment_ids: Option<&[u32]>, ) -> Result { - let num_rows = dataset.count_rows(None).await?; + let num_rows = if let Some(fragment_ids) = fragment_ids { + let mut scanner = dataset.scan(); + scanner.with_fragments(resolve_scan_fragments(dataset, fragment_ids)?); + scanner.count_rows().await? as usize + } else { + dataset.count_rows(None).await? + }; let vector_field = dataset.schema().field(column).ok_or(Error::index(format!( "Sample training data: column {} does not exist in schema", @@ -291,7 +304,8 @@ pub async fn maybe_sample_training_data( // Set a minimum sample size of 128 to avoid too small samples, // it's not a problem because 128 multivectors is just about 64 MiB let vectors_per_row = - estimate_multivector_vectors_per_row(dataset, column, num_rows).await?; + estimate_multivector_vectors_per_row(dataset, column, num_rows, fragment_ids) + .await?; sample_size_hint.div_ceil(vectors_per_row).max(128) } _ => sample_size_hint, @@ -306,11 +320,12 @@ pub async fn maybe_sample_training_data( num_rows, vector_field, is_nullable, + fragment_ids, ) .await } else { // too small to require sampling - let batch = scan_all_training_data(dataset, column, is_nullable).await?; + let batch = scan_all_training_data(dataset, column, is_nullable, fragment_ids).await?; vector_column_to_fsl(&batch, column) } } @@ -378,9 +393,13 @@ async fn scan_all_training_data( dataset: &Dataset, column: &str, is_nullable: bool, + fragment_ids: Option<&[u32]>, ) -> Result { let mut scanner = dataset.scan(); scanner.project(&[column])?; + if let Some(fragment_ids) = fragment_ids { + scanner.with_fragments(resolve_scan_fragments(dataset, fragment_ids)?); + } if is_nullable { let column_expr = lance_datafusion::logical_expr::field_path_to_expr(column)?; scanner.filter_expr(column_expr.is_not_null()); @@ -406,14 +425,38 @@ async fn sample_training_data( num_rows: usize, vector_field: &lance_core::datatypes::Field, is_nullable: bool, + fragment_ids: Option<&[u32]>, ) -> Result { + if fragment_ids.is_some() { + if !is_nullable { + let projection = dataset.schema().project(&[column])?; + let batch = dataset + .sample(sample_size_hint, &projection, fragment_ids) + .await?; + return vector_column_to_fsl(&batch, column); + } + + let batch = scan_all_training_data(dataset, column, is_nullable, fragment_ids).await?; + let training_data = vector_column_to_fsl(&batch, column)?; + if training_data.len() <= sample_size_hint { + return Ok(training_data); + } + let indices = UInt32Array::from_iter_values( + generate_random_indices(training_data.len(), sample_size_hint) + .into_iter() + .map(|index| index as u32), + ); + let sampled = arrow_select::take::take(&training_data, &indices, None)?; + return Ok(sampled.as_fixed_size_list().clone()); + } + let byte_width = vector_field .data_type() .byte_width_opt() .unwrap_or(4 * 1024); match vector_field.data_type() { - DataType::FixedSizeList(_, _) if !is_nullable => { + DataType::FixedSizeList(_, _) if !is_nullable && fragment_ids.is_none() => { sample_fsl_uniform( dataset, column, @@ -454,6 +497,28 @@ fn sample_training_data_scan( )) } +fn resolve_scan_fragments( + dataset: &Dataset, + fragment_ids: &[u32], +) -> Result> { + let mut ordered_ids = fragment_ids.to_vec(); + ordered_ids.sort_unstable(); + let fragments = dataset.get_frags_from_ordered_ids(&ordered_ids); + if let Some(missing_id) = fragments + .iter() + .zip(ordered_ids.iter()) + .find_map(|(fragment, fragment_id)| fragment.is_none().then_some(*fragment_id)) + { + return Err(Error::invalid_input(format!( + "Unknown fragment id {missing_id} in training fragment filter" + ))); + } + Ok(fragments + .into_iter() + .map(|fragment| fragment.unwrap().metadata().clone()) + .collect()) +} + /// Build a FixedSizeListArray from raw flat value bytes. fn fsl_values_to_array( field: &lance_core::datatypes::Field, @@ -821,7 +886,7 @@ mod tests { .await .unwrap(); - let training_data = maybe_sample_training_data(&dataset, "mv", 1000) + let training_data = maybe_sample_training_data(&dataset, "mv", 1000, None) .await .unwrap(); assert_eq!(training_data.len(), 1000); @@ -897,7 +962,7 @@ mod tests { .await .unwrap(); - let training_data = maybe_sample_training_data(&dataset, "vec", sample_size) + let training_data = maybe_sample_training_data(&dataset, "vec", sample_size, None) .await .unwrap(); @@ -983,7 +1048,7 @@ mod tests { .await .unwrap(); - let n = estimate_multivector_vectors_per_row(&dataset, "mv", nrows) + let n = estimate_multivector_vectors_per_row(&dataset, "mv", nrows, None) .await .unwrap(); assert_eq!(n, 1030); From 55311f8895140959cb5253be29cff7da016abce4 Mon Sep 17 00:00:00 2001 From: hushengquan <45221305+hushengquan@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:15:59 +0800 Subject: [PATCH 092/206] feat(python): add storage_options to IvfModel and PqModel save/load (#6312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #6311 ## What Add an optional `storage_options` keyword argument to `IvfModel.save()`, `IvfModel.load()`, `PqModel.save()`, and `PqModel.load()`, and forward it to the underlying `LanceFileWriter` / `LanceFileReader`. ## Why These methods accept cloud storage URIs (e.g. `s3://`, `gs://`) but previously had no way to pass credentials or backend-specific configuration. Users were forced to rely on environment variables for authentication, which breaks down when dealing with multiple storage backends that require different credentials. Both `LanceFileWriter` and `LanceFileReader` already support `storage_options` — this change simply threads the parameter through. ## Change Summary - **`python/python/lance/indices/ivf.py`**: Add `storage_options` parameter to `IvfModel.save()` and `IvfModel.load()`, forward to `LanceFileWriter` / `LanceFileReader`. - **`python/python/lance/indices/pq.py`**: Same change for `PqModel.save()` and `PqModel.load()`. ## Usage ```python from lance.indices.ivf import IvfModel from lance.indices.pq import PqModel opts = {"aws_access_key_id": "...", "aws_secret_access_key": "...", "region": "us-east-1"} # Save ivf_model.save("s3://bucket/ivf.lance", storage_options=opts) pq_model.save("s3://bucket/pq.lance", storage_options=opts) # Load ivf_model = IvfModel.load("s3://bucket/ivf.lance", storage_options=opts) pq_model = PqModel.load("s3://bucket/pq.lance", storage_options=opts) --- python/python/lance/indices/ivf.py | 13 ++++++++++--- python/python/lance/indices/pq.py | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/python/python/lance/indices/ivf.py b/python/python/lance/indices/ivf.py index fa92f744d55..fef19dde73a 100644 --- a/python/python/lance/indices/ivf.py +++ b/python/python/lance/indices/ivf.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors +from typing import Dict, Optional + import pyarrow as pa from lance.file import LanceFileReader, LanceFileWriter @@ -24,7 +26,7 @@ def num_partitions(self) -> int: """ return len(self.centroids) - def save(self, uri: str): + def save(self, uri: str, *, storage_options: Optional[Dict[str, str]] = None): """ Save the IVF model to a lance file. @@ -34,6 +36,8 @@ def save(self, uri: str): uri: str The URI to save the model to. The URI can be a local file path or a cloud storage path. + storage_options : optional, dict + Extra options for the storage backend (e.g. S3 credentials). """ with LanceFileWriter( uri, @@ -41,12 +45,13 @@ def save(self, uri: str): [pa.field("centroids", self.centroids.type)], metadata={b"distance_type": self.distance_type.encode()}, ), + storage_options=storage_options, ) as writer: batch = pa.table([self.centroids], names=["centroids"]) writer.write_batch(batch) @classmethod - def load(cls, uri: str): + def load(cls, uri: str, *, storage_options: Optional[Dict[str, str]] = None): """ Load an IVF model from a lance file. @@ -56,8 +61,10 @@ def load(cls, uri: str): uri: str The URI to load the model from. The URI can be a local file path or a cloud storage path. + storage_options : optional, dict + Extra options for the storage backend (e.g. S3 credentials). """ - reader = LanceFileReader(uri) + reader = LanceFileReader(uri, storage_options=storage_options) num_rows = reader.metadata().num_rows metadata = reader.metadata().schema.metadata distance_type = metadata[b"distance_type"].decode() diff --git a/python/python/lance/indices/pq.py b/python/python/lance/indices/pq.py index 09f34f04dfe..b3aeb50bcbe 100644 --- a/python/python/lance/indices/pq.py +++ b/python/python/lance/indices/pq.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors +from typing import Dict, Optional + import pyarrow as pa from lance.file import LanceFileReader, LanceFileWriter @@ -23,7 +25,7 @@ def dimension(self): """The dimension of the vectors this model was trained on""" return self.codebook.type.list_size - def save(self, uri: str): + def save(self, uri: str, *, storage_options: Optional[Dict[str, str]] = None): """ Save the PQ model to a lance file. @@ -33,6 +35,8 @@ def save(self, uri: str): uri: str The URI to save the model to. The URI can be a local file path or a cloud storage path. + storage_options : optional, dict + Extra options for the storage backend (e.g. S3 credentials). """ with LanceFileWriter( uri, @@ -40,12 +44,13 @@ def save(self, uri: str): [pa.field("codebook", self.codebook.type)], metadata={b"num_subvectors": str(self.num_subvectors).encode()}, ), + storage_options=storage_options, ) as writer: batch = pa.table([self.codebook], names=["codebook"]) writer.write_batch(batch) @classmethod - def load(cls, uri: str): + def load(cls, uri: str, *, storage_options: Optional[Dict[str, str]] = None): """ Load a PQ model from a lance file. @@ -55,8 +60,10 @@ def load(cls, uri: str): uri: str The URI to load the model from. The URI can be a local file path or a cloud storage path. + storage_options : optional, dict + Extra options for the storage backend (e.g. S3 credentials). """ - reader = LanceFileReader(uri) + reader = LanceFileReader(uri, storage_options=storage_options) num_rows = reader.metadata().num_rows metadata = reader.metadata().schema.metadata num_subvectors = int(metadata[b"num_subvectors"].decode()) From 36e8b2d263bbbf606aa599a57c9b6f7cbd7d6664 Mon Sep 17 00:00:00 2001 From: Enwei Jiao Date: Fri, 27 Mar 2026 21:01:21 +0800 Subject: [PATCH 093/206] perf: optimize stable row_id index build from O(rows) to O(fragments) (#6310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When `enable_stable_row_id` is enabled, the first `take_rows()` call triggers a full `RowIdIndex` build. On large datasets this cold start was extremely slow (18.9s for 968M rows, 220s for 4.26B rows). ### Root Causes **1. O(total_rows) segment expansion in `decompose_sequence`** The original code expanded every `U64Segment` element-by-element, even for `Range` segments with no deletions. For a `Range(0..273711)` with no deletions, this meant 273K iterations, deletion vector checks, temporary allocations, and re-compression — only to produce the same Range back. Across 18,243 fragments averaging 233K rows, this totaled **4.26 billion iterations** with ~32 GB of temporary allocations. **2. O(N²) fragment lookup in `load_row_id_index`** The original code called `fragments.iter().find()` (O(N) linear search) for each of N fragments, resulting in O(N²) comparisons. `try_join_all` spawned all N futures at once, overwhelming the async runtime. `get_deletion_vector()` was called unconditionally even for fragments without deletion files. ### Solution **Fix 1: O(1) fast path for Range segments without deletions.** When a fragment has no deletions and its row_id sequence is a `Range`, construct the index chunk directly without iterating. **Fix 2: HashMap lookup + conditional deletion vector loading.** Use a `HashMap` for O(1) lookup, `buffer_unordered` for controlled concurrency, and skip `get_deletion_vector()` when there's no deletion file. ### Results | Dataset | Before | After | Speedup | |---------|--------|-------|---------| | 968M rows, 3,540 fragments | 18.9s | 150ms | **126x** | | 4.26B rows, 18,243 fragments | 220s | 89ms | **2,471x** | ## Test plan - [x] All existing rowids tests pass (45 tests) - [x] Clippy clean - [x] New test `test_large_range_segments_no_deletions` validates fast path correctness at boundaries and performance (100 fragments × 250K rows completes < 1s) - [ ] Verify on real datasets with deletions to ensure slow path correctness 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-table/src/rowids/index.rs | 217 ++++++++++++++++++++++++--- rust/lance/src/dataset/rowids.rs | 30 ++-- 2 files changed, 212 insertions(+), 35 deletions(-) diff --git a/rust/lance-table/src/rowids/index.rs b/rust/lance-table/src/rowids/index.rs index 0bf4eb76027..7ad04bd961d 100644 --- a/rust/lance-table/src/rowids/index.rs +++ b/rust/lance-table/src/rowids/index.rs @@ -97,6 +97,7 @@ fn decompose_sequence( ) -> Vec<(RangeInclusive, (U64Segment, U64Segment))> { let mut start_address: u64 = RowAddress::first_row(frag_index.fragment_id).into(); let mut current_offset = 0u32; + let no_deletions = frag_index.deletion_vector.is_empty(); frag_index .row_id_sequence @@ -105,38 +106,80 @@ fn decompose_sequence( .filter_map(|segment| { let segment_len = segment.len(); - let active_pairs: Vec<(u64, u64)> = segment - .iter() - .enumerate() - .filter_map(|(i, row_id)| { - let row_offset = current_offset + i as u32; - if !frag_index.deletion_vector.contains(row_offset) { - let address = start_address + i as u64; - Some((row_id, address)) - } else { - None - } - }) - .collect(); + let result = if no_deletions { + decompose_segment_no_deletions(segment, start_address) + } else { + decompose_segment_with_deletions( + segment, + start_address, + current_offset, + &frag_index.deletion_vector, + ) + }; current_offset += segment_len as u32; start_address += segment_len as u64; - if active_pairs.is_empty() { - return None; - } - - let row_ids: Vec = active_pairs.iter().map(|(rid, _)| *rid).collect(); - let addresses: Vec = active_pairs.iter().map(|(_, addr)| *addr).collect(); - - let row_id_segment = U64Segment::from_iter(row_ids.iter().copied()); - let address_segment = U64Segment::from_iter(addresses.iter().copied()); + result + }) + .collect() +} - let coverage = row_id_segment.range()?; +/// Build an IndexChunk from a list of (row_id, address) pairs. +fn build_chunk_from_pairs(pairs: Vec<(u64, u64)>) -> Option { + if pairs.is_empty() { + return None; + } + let (row_ids, addresses): (Vec, Vec) = pairs.into_iter().unzip(); + let row_id_segment = U64Segment::from_iter(row_ids); + let address_segment = U64Segment::from_iter(addresses); + let coverage = row_id_segment.range()?; + Some((coverage, (row_id_segment, address_segment))) +} +/// Fast path: no deletions. O(1) for Range segments. +fn decompose_segment_no_deletions(segment: &U64Segment, start_address: u64) -> Option { + match segment { + U64Segment::Range(range) if !range.is_empty() => { + let len = range.end - range.start; + let row_id_segment = U64Segment::Range(range.clone()); + let address_segment = U64Segment::Range(start_address..start_address + len); + let coverage = range.start..=range.end - 1; Some((coverage, (row_id_segment, address_segment))) + } + _ if segment.is_empty() => None, + _ => { + // Non-Range segments: must iterate to build address mapping. + let pairs: Vec<(u64, u64)> = segment + .iter() + .enumerate() + .map(|(i, row_id)| (row_id, start_address + i as u64)) + .collect(); + build_chunk_from_pairs(pairs) + } + } +} + +/// Slow path: has deletions, must check each row. +fn decompose_segment_with_deletions( + segment: &U64Segment, + start_address: u64, + current_offset: u32, + deletion_vector: &DeletionVector, +) -> Option { + let pairs: Vec<(u64, u64)> = segment + .iter() + .enumerate() + .filter_map(|(i, row_id)| { + let row_offset = current_offset + i as u32; + if !deletion_vector.contains(row_offset) { + Some((row_id, start_address + i as u64)) + } else { + None + } }) - .collect() + .collect(); + build_chunk_from_pairs(pairs) } type IndexChunk = (RangeInclusive, (U64Segment, U64Segment)); @@ -547,6 +590,132 @@ mod tests { }) } + #[test] + fn test_large_range_segments_no_deletions() { + // Simulates a real-world scenario: many fragments with large Range segments + // and no deletions. Before optimization, this would iterate over all rows + // (O(total_rows)). After optimization, it's O(num_fragments). + let rows_per_fragment = 250_000u64; + let num_fragments = 100u32; + let mut offset = 0u64; + + let fragment_indices: Vec = (0..num_fragments) + .map(|frag_id| { + let start = offset; + offset += rows_per_fragment; + FragmentRowIdIndex { + fragment_id: frag_id, + row_id_sequence: Arc::new(RowIdSequence(vec![U64Segment::Range( + start..start + rows_per_fragment, + )])), + deletion_vector: Arc::new(DeletionVector::default()), + } + }) + .collect(); + + let start = std::time::Instant::now(); + let index = RowIdIndex::new(&fragment_indices).unwrap(); + let elapsed = start.elapsed(); + + // Verify correctness at boundaries + assert_eq!(index.get(0), Some(RowAddress::new_from_parts(0, 0))); + assert_eq!( + index.get(rows_per_fragment - 1), + Some(RowAddress::new_from_parts(0, rows_per_fragment as u32 - 1)) + ); + assert_eq!( + index.get(rows_per_fragment), + Some(RowAddress::new_from_parts(1, 0)) + ); + let last_row = num_fragments as u64 * rows_per_fragment - 1; + assert_eq!( + index.get(last_row), + Some(RowAddress::new_from_parts( + num_fragments - 1, + rows_per_fragment as u32 - 1 + )) + ); + assert_eq!(index.get(last_row + 1), None); + + // With the optimization, building an index for 25M rows across 100 fragments + // should complete in well under 1 second (typically < 1ms). + assert!( + elapsed.as_secs() < 1, + "Index build took {:?} for {} fragments x {} rows = {} total rows. \ + This suggests the O(rows) -> O(fragments) optimization is not working.", + elapsed, + num_fragments, + rows_per_fragment, + num_fragments as u64 * rows_per_fragment, + ); + } + + #[test] + fn test_large_range_segments_with_deletions() { + let rows_per_fragment = 1_000u64; + let num_fragments = 10u32; + let mut offset = 0u64; + + let fragment_indices: Vec = (0..num_fragments) + .map(|frag_id| { + let start = offset; + offset += rows_per_fragment; + + // Delete every 3rd row (offsets 0, 3, 6, ...) within each fragment. + let mut deleted = roaring::RoaringBitmap::new(); + for i in (0..rows_per_fragment as u32).step_by(3) { + deleted.insert(i); + } + + FragmentRowIdIndex { + fragment_id: frag_id, + row_id_sequence: Arc::new(RowIdSequence(vec![U64Segment::Range( + start..start + rows_per_fragment, + )])), + deletion_vector: Arc::new(DeletionVector::Bitmap(deleted)), + } + }) + .collect(); + + let index = RowIdIndex::new(&fragment_indices).unwrap(); + + // Deleted rows (offset 0, 3, 6, ...) should not be found. + // Row ID 0 has offset 0 in fragment 0 -> deleted. + assert_eq!(index.get(0), None); + // Row ID 3 has offset 3 in fragment 0 -> deleted. + assert_eq!(index.get(3), None); + + // Non-deleted rows should resolve correctly. + // Row ID 1 has offset 1 in fragment 0 -> address (frag=0, row=1). + assert_eq!(index.get(1), Some(RowAddress::new_from_parts(0, 1))); + // Row ID 2 has offset 2 in fragment 0 -> address (frag=0, row=2). + assert_eq!(index.get(2), Some(RowAddress::new_from_parts(0, 2))); + // Row ID 4 has offset 4 in fragment 0 -> address (frag=0, row=4). + assert_eq!(index.get(4), Some(RowAddress::new_from_parts(0, 4))); + + // Check second fragment: row IDs start at 1000. + // Row ID 1000 has offset 0 in fragment 1 -> deleted. + assert_eq!(index.get(rows_per_fragment), None); + // Row ID 1001 has offset 1 in fragment 1 -> address (frag=1, row=1). + assert_eq!( + index.get(rows_per_fragment + 1), + Some(RowAddress::new_from_parts(1, 1)) + ); + + // Last fragment, last non-deleted row. + // Row ID 9999 has offset 999 in fragment 9 -> 999 % 3 == 0 -> deleted. + let last_row = num_fragments as u64 * rows_per_fragment - 1; + assert_eq!(index.get(last_row), None); + // Row ID 9998 has offset 998 -> 998 % 3 == 2 -> not deleted. + assert_eq!( + index.get(last_row - 1), + Some(RowAddress::new_from_parts(num_fragments - 1, 998)) + ); + + // Out of range. + assert_eq!(index.get(last_row + 1), None); + } + proptest::proptest! { #[test] fn test_new_index_robustness(row_ids in arbitrary_row_ids(0..5, 0..32)) { diff --git a/rust/lance/src/dataset/rowids.rs b/rust/lance/src/dataset/rowids.rs index 447868fca99..d40a9adfd50 100644 --- a/rust/lance/src/dataset/rowids.rs +++ b/rust/lance/src/dataset/rowids.rs @@ -92,19 +92,25 @@ async fn load_row_id_index(dataset: &Dataset) -> Result>() .await?; + let fragments = dataset.get_fragments(); + let fragment_map: std::collections::HashMap = + fragments.iter().map(|f| (f.id() as u32, f)).collect(); + let fragment_indices: Vec<_> = - futures::future::try_join_all(sequences.into_iter().map(|(fragment_id, sequence)| { - let dataset = dataset.clone(); + futures::stream::iter(sequences.into_iter().map(|(fragment_id, sequence)| { + let fragment = fragment_map + .get(&fragment_id) + .expect("Fragment should exist"); + let has_deletion_file = fragment.metadata().deletion_file.is_some(); + let fragment_clone = (*fragment).clone(); async move { - let fragments = dataset.get_fragments(); - let fragment = fragments - .iter() - .find(|f| f.id() as u32 == fragment_id) - .expect("Fragment should exist"); - - let deletion_vector = match fragment.get_deletion_vector().await { - Ok(Some(dv)) => dv, - Ok(None) | Err(_) => Arc::new(DeletionVector::default()), + let deletion_vector = if has_deletion_file { + match fragment_clone.get_deletion_vector().await { + Ok(Some(dv)) => dv, + Ok(None) | Err(_) => Arc::new(DeletionVector::default()), + } + } else { + Arc::new(DeletionVector::default()) }; Ok::(FragmentRowIdIndex { @@ -114,6 +120,8 @@ async fn load_row_id_index(dataset: &Dataset) -> Result Date: Fri, 27 Mar 2026 09:23:27 -0700 Subject: [PATCH 094/206] feat: move rate limiting to the object store (#6293) Closes #6239 --------- Co-authored-by: Claude Opus 4.6 --- docs/src/guide/object_store.md | 4 +- docs/src/guide/performance.md | 30 +- rust/lance-io/src/object_store.rs | 2 +- .../src/object_store/providers/aws.rs | 22 +- .../src/object_store/providers/azure.rs | 22 +- .../src/object_store/providers/gcp.rs | 22 +- rust/lance-io/src/object_store/throttle.rs | 537 ++++++++++++++++-- rust/lance-io/src/scheduler.rs | 116 +--- 8 files changed, 577 insertions(+), 178 deletions(-) diff --git a/docs/src/guide/object_store.md b/docs/src/guide/object_store.md index a1f0c2341ef..1710e3b5100 100644 --- a/docs/src/guide/object_store.md +++ b/docs/src/guide/object_store.md @@ -38,8 +38,8 @@ These options apply to all object stores. | `proxy_url` | URL of a proxy server to use for requests. Default, `None`. | | `proxy_ca_certificate` | PEM-formatted CA certificate for proxy connections | | `proxy_excludes` | List of hosts that bypass proxy. This is a comma separated list of domains and IP masks. Any subdomain of the provided domain will be bypassed. For example, `example.com, 192.168.1.0/24` would bypass `https://api.example.com`, `https://www.example.com`, and any IP in the range `192.168.1.0/24`. | -| `client_max_retries` | Number of times for a s3 client to retry the request. Default, `10`. | -| `client_retry_timeout` | Timeout for a s3 client to retry the request in seconds. Default, `180`. | +| `client_max_retries` | Number of times for the object store client to retry the request. Default, `3`. | +| `client_retry_timeout` | Timeout for the object store client to retry the request in seconds. Default, `180`. | ## S3 Configuration diff --git a/docs/src/guide/performance.md b/docs/src/guide/performance.md index 11ca2e23b72..fb94b94e7ed 100644 --- a/docs/src/guide/performance.md +++ b/docs/src/guide/performance.md @@ -163,11 +163,31 @@ In summary, scans could use up to `(2 * io_buffer_size) + (batch_size * num_comp Keep in mind that `io_buffer_size` is a soft limit (e.g. we cannot read less than one page at a time right now) and so it is not necessarily a bug if you see memory usage exceed this limit by a small margin. -The above limits refer to limits per-scan. There is an additional limit on the number of IOPS that is applied -across the entire process. This limit is specified by the `LANCE_PROCESS_IO_THREADS_LIMIT` environment variable. -The default is 128 which is more than enough for most workloads. You can increase this limit if you are working -with a high-throughput workload. You can even disable this limit entirely by setting it to zero. Note that this -can often lead to issues with excessive retries and timeouts from the object store. +### Cloud Store Throttling + +Cloud object stores (S3, GCS, Azure) are automatically wrapped with an AIMD (Additive Increase / Multiplicative +Decrease) rate limiter. When the store returns throttle errors (HTTP 429/503), the request rate decreases +multiplicatively. During sustained success, the rate increases additively. This applies to all operations +(reads, writes, deletes, lists) and replaces the old `LANCE_PROCESS_IO_THREADS_LIMIT` process-wide cap. + +Local and in-memory stores are **not** throttled. + +The AIMD throttle can be tuned via storage options or environment variables. Storage options take precedence +over environment variables: + +| Setting | Storage Option Key | Env Var | Default | +| ------------------ | ------------------------------- | ------------------------------- | ------- | +| Initial rate | `lance_aimd_initial_rate` | `LANCE_AIMD_INITIAL_RATE` | 2000 | +| Min rate | `lance_aimd_min_rate` | `LANCE_AIMD_MIN_RATE` | 1 | +| Max rate | `lance_aimd_max_rate` | `LANCE_AIMD_MAX_RATE` | 5000 | +| Decrease factor | `lance_aimd_decrease_factor` | `LANCE_AIMD_DECREASE_FACTOR` | 0.5 | +| Additive increment | `lance_aimd_additive_increment` | `LANCE_AIMD_ADDITIVE_INCREMENT` | 300 | +| Burst capacity | `lance_aimd_burst_capacity` | `LANCE_AIMD_BURST_CAPACITY` | 100 | + +These initial settings are balanced and should work for most +use cases. For example, S3 can typically get up to 5000 +req/s and with these settings we should get there in about +10 seconds. ## Indexes diff --git a/rust/lance-io/src/object_store.rs b/rust/lance-io/src/object_store.rs index c0d0acf51a2..4eb1e4ff403 100644 --- a/rust/lance-io/src/object_store.rs +++ b/rust/lance-io/src/object_store.rs @@ -874,7 +874,7 @@ impl StorageOptions { .iter() .find(|(key, _)| key.eq_ignore_ascii_case("client_max_retries")) .and_then(|(_, value)| value.parse::().ok()) - .unwrap_or(10) + .unwrap_or(3) } /// Seconds of timeout to set in RetryConfig for object store client diff --git a/rust/lance-io/src/object_store/providers/aws.rs b/rust/lance-io/src/object_store/providers/aws.rs index 2055a7144d2..a06fcaef140 100644 --- a/rust/lance-io/src/object_store/providers/aws.rs +++ b/rust/lance-io/src/object_store/providers/aws.rs @@ -30,6 +30,7 @@ use crate::object_store::{ DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore, ObjectStoreParams, ObjectStoreProvider, StorageOptions, StorageOptionsAccessor, StorageOptionsProvider, + throttle::{AimdThrottleConfig, AimdThrottledStore}, }; use lance_core::error::{Error, Result}; @@ -44,12 +45,12 @@ impl AwsStoreProvider { storage_options: &StorageOptions, is_s3_express: bool, ) -> Result> { - let max_retries = storage_options.client_max_retries(); - let retry_timeout = storage_options.client_retry_timeout(); + // Use a low retry count since the AIMD throttle layer handles + // throttle recovery with its own retry loop. let retry_config = RetryConfig { backoff: Default::default(), - max_retries, - retry_timeout: Duration::from_secs(retry_timeout), + max_retries: storage_options.client_max_retries(), + retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()), }; let mut s3_storage_options = storage_options.as_s3_options(); @@ -159,6 +160,19 @@ impl ObjectStoreProvider for AwsStoreProvider { self.build_amazon_s3_store(&mut base_path, params, &storage_options, is_s3_express) .await? }; + let throttle_config = AimdThrottleConfig::from_storage_options(params.storage_options())?; + let inner = if throttle_config.is_disabled() { + inner + } else if storage_options.client_max_retries() == 0 { + log::warn!( + "AIMD throttle disabled: the current implementation relies on the object store \ + client surfacing retry errors, which requires client_max_retries > 0. \ + No throttle or retry layer will be applied." + ); + inner + } else { + Arc::new(AimdThrottledStore::new(inner, throttle_config)?) as Arc + }; Ok(ObjectStore { inner, diff --git a/rust/lance-io/src/object_store/providers/azure.rs b/rust/lance-io/src/object_store/providers/azure.rs index ba9c8a34831..647d324bd3e 100644 --- a/rust/lance-io/src/object_store/providers/azure.rs +++ b/rust/lance-io/src/object_store/providers/azure.rs @@ -21,6 +21,7 @@ use url::Url; use crate::object_store::{ DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore, ObjectStoreParams, ObjectStoreProvider, StorageOptions, + throttle::{AimdThrottleConfig, AimdThrottledStore}, }; use lance_core::error::{Error, Result}; @@ -113,12 +114,12 @@ impl AzureBlobStoreProvider { base_path: &Url, storage_options: &StorageOptions, ) -> Result> { - let max_retries = storage_options.client_max_retries(); - let retry_timeout = storage_options.client_retry_timeout(); + // Use a low retry count since the AIMD throttle layer handles + // throttle recovery with its own retry loop. let retry_config = RetryConfig { backoff: Default::default(), - max_retries, - retry_timeout: Duration::from_secs(retry_timeout), + max_retries: storage_options.client_max_retries(), + retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()), }; let mut builder = MicrosoftAzureBuilder::new() @@ -163,6 +164,19 @@ impl ObjectStoreProvider for AzureBlobStoreProvider { self.build_microsoft_azure_store(&base_path, &storage_options) .await? }; + let throttle_config = AimdThrottleConfig::from_storage_options(params.storage_options())?; + let inner = if throttle_config.is_disabled() { + inner + } else if storage_options.client_max_retries() == 0 { + log::warn!( + "AIMD throttle disabled: the current implementation relies on the object store \ + client surfacing retry errors, which requires client_max_retries > 0. \ + No throttle or retry layer will be applied." + ); + inner + } else { + Arc::new(AimdThrottledStore::new(inner, throttle_config)?) as Arc + }; Ok(ObjectStore { inner, diff --git a/rust/lance-io/src/object_store/providers/gcp.rs b/rust/lance-io/src/object_store/providers/gcp.rs index 7bfe3715566..f234a8e31ee 100644 --- a/rust/lance-io/src/object_store/providers/gcp.rs +++ b/rust/lance-io/src/object_store/providers/gcp.rs @@ -16,6 +16,7 @@ use url::Url; use crate::object_store::{ DEFAULT_CLOUD_BLOCK_SIZE, DEFAULT_CLOUD_IO_PARALLELISM, DEFAULT_MAX_IOP_SIZE, ObjectStore, ObjectStoreParams, ObjectStoreProvider, StorageOptions, + throttle::{AimdThrottleConfig, AimdThrottledStore}, }; use lance_core::error::{Error, Result}; @@ -58,12 +59,12 @@ impl GcsStoreProvider { base_path: &Url, storage_options: &StorageOptions, ) -> Result> { - let max_retries = storage_options.client_max_retries(); - let retry_timeout = storage_options.client_retry_timeout(); + // Use a low retry count since the AIMD throttle layer handles + // throttle recovery with its own retry loop. let retry_config = RetryConfig { backoff: Default::default(), - max_retries, - retry_timeout: Duration::from_secs(retry_timeout), + max_retries: storage_options.client_max_retries(), + retry_timeout: Duration::from_secs(storage_options.client_retry_timeout()), }; let mut builder = GoogleCloudStorageBuilder::new() @@ -108,6 +109,19 @@ impl ObjectStoreProvider for GcsStoreProvider { self.build_google_cloud_store(&base_path, &storage_options) .await? }; + let throttle_config = AimdThrottleConfig::from_storage_options(params.storage_options())?; + let inner = if throttle_config.is_disabled() { + inner + } else if storage_options.client_max_retries() == 0 { + log::warn!( + "AIMD throttle disabled: the current implementation relies on the object store \ + client surfacing retry errors, which requires client_max_retries > 0. \ + No throttle or retry layer will be applied." + ); + inner + } else { + Arc::new(AimdThrottledStore::new(inner, throttle_config)?) as Arc + }; Ok(ObjectStore { inner, diff --git a/rust/lance-io/src/object_store/throttle.rs b/rust/lance-io/src/object_store/throttle.rs index 378bcff4c7d..a632ddda7e0 100644 --- a/rust/lance-io/src/object_store/throttle.rs +++ b/rust/lance-io/src/object_store/throttle.rs @@ -20,6 +20,7 @@ //! let throttled = AimdThrottledStore::new(target, AimdThrottleConfig::default()).unwrap(); //! ``` +use std::collections::HashMap; use std::fmt::{Debug, Display, Formatter}; use std::ops::Range; use std::sync::Arc; @@ -34,8 +35,9 @@ use object_store::{ GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult, Result as OSResult, UploadPart, }; +use rand::Rng; use tokio::sync::Mutex; -use tracing::debug; +use tracing::{debug, warn}; /// Check whether an `object_store::Error` represents a throttle response /// (HTTP 429 / 503) from a cloud object store. @@ -81,6 +83,12 @@ pub struct AimdThrottleConfig { pub list: AimdConfig, /// Maximum tokens that can accumulate for bursts (shared across all categories). pub burst_capacity: u32, + /// Maximum number of retries for throttle errors within the AIMD layer. + pub max_retries: usize, + /// Minimum backoff in milliseconds between retry attempts. + pub min_backoff_ms: u64, + /// Maximum backoff in milliseconds between retry attempts. + pub max_backoff_ms: u64, } impl Default for AimdThrottleConfig { @@ -92,6 +100,9 @@ impl Default for AimdThrottleConfig { delete: aimd.clone(), list: aimd, burst_capacity: 100, + max_retries: 3, + min_backoff_ms: 100, + max_backoff_ms: 300, } } } @@ -134,12 +145,157 @@ impl AimdThrottleConfig { Self { list: aimd, ..self } } + /// Returns `true` when the AIMD throttle layer should be bypassed entirely. + pub fn is_disabled(&self) -> bool { + self.max_retries == 0 + } + pub fn with_burst_capacity(self, burst_capacity: u32) -> Self { Self { burst_capacity, ..self } } + + /// Build an `AimdThrottleConfig` from storage options and environment variables. + /// + /// Storage options take precedence over environment variables, which take + /// precedence over defaults. A single AIMD config is applied to all four + /// operation categories (read/write/delete/list). + /// + /// | Setting | Storage Option Key | Env Var | Default | + /// |----------------------|----------------------------------|----------------------------------|---------| + /// | Initial rate | `lance_aimd_initial_rate` | `LANCE_AIMD_INITIAL_RATE` | 2000 | + /// | Min rate | `lance_aimd_min_rate` | `LANCE_AIMD_MIN_RATE` | 1 | + /// | Max rate | `lance_aimd_max_rate` | `LANCE_AIMD_MAX_RATE` | 5000 | + /// | Decrease factor | `lance_aimd_decrease_factor` | `LANCE_AIMD_DECREASE_FACTOR` | 0.5 | + /// | Additive increment | `lance_aimd_additive_increment` | `LANCE_AIMD_ADDITIVE_INCREMENT` | 300 | + /// | Burst capacity | `lance_aimd_burst_capacity` | `LANCE_AIMD_BURST_CAPACITY` | 100 | + /// | Max retries | `lance_aimd_max_retries` | `LANCE_AIMD_MAX_RETRIES` | 3 | + /// | Min backoff ms | `lance_aimd_min_backoff_ms` | `LANCE_AIMD_MIN_BACKOFF_MS` | 100 | + /// | Max backoff ms | `lance_aimd_max_backoff_ms` | `LANCE_AIMD_MAX_BACKOFF_MS` | 300 | + pub fn from_storage_options( + storage_options: Option<&HashMap>, + ) -> lance_core::Result { + fn resolve_f64( + key: &str, + storage_options: Option<&HashMap>, + default: f64, + ) -> lance_core::Result { + let env_key = key.to_ascii_uppercase(); + if let Some(val) = storage_options.and_then(|opts| opts.get(key)) { + val.parse::().map_err(|_| { + lance_core::Error::invalid_input(format!( + "Invalid value for storage option '{key}': '{val}'" + )) + }) + } else if let Ok(val) = std::env::var(&env_key) { + val.parse::().map_err(|_| { + lance_core::Error::invalid_input(format!( + "Invalid value for env var '{env_key}': '{val}'" + )) + }) + } else { + Ok(default) + } + } + + fn resolve_u32( + key: &str, + storage_options: Option<&HashMap>, + default: u32, + ) -> lance_core::Result { + let env_key = key.to_ascii_uppercase(); + if let Some(val) = storage_options.and_then(|opts| opts.get(key)) { + val.parse::().map_err(|_| { + lance_core::Error::invalid_input(format!( + "Invalid value for storage option '{key}': '{val}'" + )) + }) + } else if let Ok(val) = std::env::var(&env_key) { + val.parse::().map_err(|_| { + lance_core::Error::invalid_input(format!( + "Invalid value for env var '{env_key}': '{val}'" + )) + }) + } else { + Ok(default) + } + } + + fn resolve_usize( + key: &str, + storage_options: Option<&HashMap>, + default: usize, + ) -> lance_core::Result { + let env_key = key.to_ascii_uppercase(); + if let Some(val) = storage_options.and_then(|opts| opts.get(key)) { + val.parse::().map_err(|_| { + lance_core::Error::invalid_input(format!( + "Invalid value for storage option '{key}': '{val}'" + )) + }) + } else if let Ok(val) = std::env::var(&env_key) { + val.parse::().map_err(|_| { + lance_core::Error::invalid_input(format!( + "Invalid value for env var '{env_key}': '{val}'" + )) + }) + } else { + Ok(default) + } + } + + fn resolve_u64( + key: &str, + storage_options: Option<&HashMap>, + default: u64, + ) -> lance_core::Result { + let env_key = key.to_ascii_uppercase(); + if let Some(val) = storage_options.and_then(|opts| opts.get(key)) { + val.parse::().map_err(|_| { + lance_core::Error::invalid_input(format!( + "Invalid value for storage option '{key}': '{val}'" + )) + }) + } else if let Ok(val) = std::env::var(&env_key) { + val.parse::().map_err(|_| { + lance_core::Error::invalid_input(format!( + "Invalid value for env var '{env_key}': '{val}'" + )) + }) + } else { + Ok(default) + } + } + + let initial_rate = resolve_f64("lance_aimd_initial_rate", storage_options, 2000.0)?; + let min_rate = resolve_f64("lance_aimd_min_rate", storage_options, 1.0)?; + let max_rate = resolve_f64("lance_aimd_max_rate", storage_options, 5000.0)?; + let decrease_factor = resolve_f64("lance_aimd_decrease_factor", storage_options, 0.5)?; + let additive_increment = + resolve_f64("lance_aimd_additive_increment", storage_options, 300.0)?; + let burst_capacity = resolve_u32("lance_aimd_burst_capacity", storage_options, 100)?; + let max_retries = resolve_usize("lance_aimd_max_retries", storage_options, 3)?; + let min_backoff_ms = resolve_u64("lance_aimd_min_backoff_ms", storage_options, 100)?; + let max_backoff_ms = resolve_u64("lance_aimd_max_backoff_ms", storage_options, 300)?; + + let aimd = AimdConfig::default() + .with_initial_rate(initial_rate) + .with_min_rate(min_rate) + .with_max_rate(max_rate) + .with_decrease_factor(decrease_factor) + .with_additive_increment(additive_increment); + + Ok(Self { + max_retries, + min_backoff_ms, + max_backoff_ms, + ..Self::default() + .with_aimd(aimd) + .with_burst_capacity(burst_capacity) + }) + } } struct TokenBucketState { @@ -153,10 +309,19 @@ struct OperationThrottle { controller: AimdController, bucket: Mutex, burst_capacity: f64, + max_retries: usize, + min_backoff_ms: u64, + max_backoff_ms: u64, } impl OperationThrottle { - fn new(aimd_config: AimdConfig, burst_capacity: f64) -> lance_core::Result { + fn new( + aimd_config: AimdConfig, + burst_capacity: f64, + max_retries: usize, + min_backoff_ms: u64, + max_backoff_ms: u64, + ) -> lance_core::Result { let initial_rate = aimd_config.initial_rate; let controller = AimdController::new(aimd_config)?; Ok(Self { @@ -167,6 +332,9 @@ impl OperationThrottle { rate: initial_rate, }), burst_capacity, + max_retries, + min_backoff_ms, + max_backoff_ms, }) } @@ -212,36 +380,72 @@ impl OperationThrottle { let outcome = match result { Ok(_) => RequestOutcome::Success, Err(err) if is_throttle_error(err) => { - debug!("Throttle error detected in stream, decreasing rate"); + debug!("Throttle error detected in stream"); RequestOutcome::Throttled } Err(_) => RequestOutcome::Success, }; + let prev_rate = self.controller.current_rate(); let new_rate = self.controller.record_outcome(outcome); + if new_rate < prev_rate { + warn!( + previous_rate = format!("{prev_rate:.1}"), + new_rate = format!("{new_rate:.1}"), + "AIMD throttle: rate reduced due to throttle errors" + ); + } if let Ok(mut bucket) = self.bucket.try_lock() { bucket.rate = new_rate; } } /// Execute an operation with throttling: acquire token, run, classify result. + /// On throttle errors, retries up to `max_retries` times with a random + /// backoff between `min_backoff_ms` and `max_backoff_ms` between attempts. async fn throttled(&self, f: F) -> OSResult where - F: FnOnce() -> Fut, + F: Fn() -> Fut, Fut: std::future::Future>, { - self.acquire_token().await; - let result = f().await; - let outcome = match &result { - Ok(_) => RequestOutcome::Success, - Err(err) if is_throttle_error(err) => { - debug!("Throttle error detected, decreasing rate"); - RequestOutcome::Throttled + for attempt in 0..=self.max_retries { + self.acquire_token().await; + let result = f().await; + let outcome = match &result { + Ok(_) => RequestOutcome::Success, + Err(err) if is_throttle_error(err) => { + debug!("Throttle error detected"); + RequestOutcome::Throttled + } + Err(_) => RequestOutcome::Success, // Non-throttle errors don't indicate capacity problems + }; + let prev_rate = self.controller.current_rate(); + let new_rate = self.controller.record_outcome(outcome); + if new_rate < prev_rate { + warn!( + previous_rate = format!("{prev_rate:.1}"), + new_rate = format!("{new_rate:.1}"), + "AIMD throttle: rate reduced due to throttle errors" + ); } - Err(_) => RequestOutcome::Success, // Non-throttle errors don't indicate capacity problems - }; - let new_rate = self.controller.record_outcome(outcome); - self.update_bucket_rate(new_rate).await; - result + self.update_bucket_rate(new_rate).await; + + match &result { + Err(err) if is_throttle_error(err) && attempt < self.max_retries => { + let backoff_ms = + rand::rng().random_range(self.min_backoff_ms..=self.max_backoff_ms); + debug!( + attempt = attempt + 1, + max_retries = self.max_retries, + backoff_ms, + "Retrying after throttle error" + ); + tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await; + continue; + } + _ => return result, + } + } + unreachable!() } } @@ -254,11 +458,17 @@ impl Debug for OperationThrottle { } } -/// A [`MultipartUpload`] wrapper that throttles `put_part` and observes -/// outcomes from `put_part` and `complete`, feeding them back to the write -/// AIMD controller. +/// A [`MultipartUpload`] wrapper that throttles and retries `put_part`, +/// `complete`, and `abort`, feeding outcomes back to the write AIMD +/// controller. +/// +/// Uses a `std::sync::Mutex` (not `tokio::sync::Mutex`) so that aborted +/// futures cannot cause deadlocks — the guard is always dropped +/// deterministically. The lock is held only briefly for the sync +/// `put_part` dispatch; `complete`/`abort` hold it across their await but +/// are never called concurrently with part uploads. struct ThrottledMultipartUpload { - target: Box, + target: Arc>>, write: Arc, } @@ -272,23 +482,66 @@ impl Debug for ThrottledMultipartUpload { impl MultipartUpload for ThrottledMultipartUpload { fn put_part(&mut self, data: PutPayload) -> UploadPart { let write = Arc::clone(&self.write); - let fut = self.target.put_part(data); + let target = Arc::clone(&self.target); Box::pin(async move { - write.acquire_token().await; - let result = fut.await; - write.observe_outcome(&result); - result + write + .throttled(|| { + // The let binding is intentional: it ensures the + // MutexGuard is dropped before the future is awaited. + #[allow(clippy::let_and_return)] + let fut = target.lock().unwrap().put_part(data.clone()); + fut + }) + .await }) } async fn complete(&mut self) -> OSResult { - let result = self.target.complete().await; - self.write.observe_outcome(&result); - result + // &mut self guarantees no concurrent put_part futures are alive, + // so get_mut always succeeds (Arc refcount == 1). + let target = Arc::get_mut(&mut self.target) + .expect("complete called while put_part futures are still alive") + .get_mut() + .unwrap(); + for attempt in 0..=self.write.max_retries { + self.write.acquire_token().await; + let result = target.complete().await; + self.write.observe_outcome(&result); + + match &result { + Err(err) if is_throttle_error(err) && attempt < self.write.max_retries => { + let backoff_ms = rand::rng() + .random_range(self.write.min_backoff_ms..=self.write.max_backoff_ms); + tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await; + continue; + } + _ => return result, + } + } + unreachable!() } async fn abort(&mut self) -> OSResult<()> { - self.target.abort().await + let target = Arc::get_mut(&mut self.target) + .expect("abort called while put_part futures are still alive") + .get_mut() + .unwrap(); + for attempt in 0..=self.write.max_retries { + self.write.acquire_token().await; + let result = target.abort().await; + self.write.observe_outcome(&result); + + match &result { + Err(err) if is_throttle_error(err) && attempt < self.write.max_retries => { + let backoff_ms = rand::rng() + .random_range(self.write.min_backoff_ms..=self.write.max_backoff_ms); + tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await; + continue; + } + _ => return result, + } + } + unreachable!() } } @@ -339,12 +592,39 @@ impl AimdThrottledStore { config: AimdThrottleConfig, ) -> lance_core::Result { let burst = config.burst_capacity as f64; + let max_retries = config.max_retries; + let min_backoff_ms = config.min_backoff_ms; + let max_backoff_ms = config.max_backoff_ms; Ok(Self { target, - read: Arc::new(OperationThrottle::new(config.read, burst)?), - write: Arc::new(OperationThrottle::new(config.write, burst)?), - delete: Arc::new(OperationThrottle::new(config.delete, burst)?), - list: Arc::new(OperationThrottle::new(config.list, burst)?), + read: Arc::new(OperationThrottle::new( + config.read, + burst, + max_retries, + min_backoff_ms, + max_backoff_ms, + )?), + write: Arc::new(OperationThrottle::new( + config.write, + burst, + max_retries, + min_backoff_ms, + max_backoff_ms, + )?), + delete: Arc::new(OperationThrottle::new( + config.delete, + burst, + max_retries, + min_backoff_ms, + max_backoff_ms, + )?), + list: Arc::new(OperationThrottle::new( + config.list, + burst, + max_retries, + min_backoff_ms, + max_backoff_ms, + )?), }) } } @@ -354,7 +634,7 @@ impl AimdThrottledStore { impl ObjectStore for AimdThrottledStore { async fn put(&self, location: &Path, bytes: PutPayload) -> OSResult { self.write - .throttled(|| self.target.put(location, bytes)) + .throttled(|| self.target.put(location, bytes.clone())) .await } @@ -365,7 +645,7 @@ impl ObjectStore for AimdThrottledStore { opts: PutOptions, ) -> OSResult { self.write - .throttled(|| self.target.put_opts(location, bytes, opts)) + .throttled(|| self.target.put_opts(location, bytes.clone(), opts.clone())) .await } @@ -375,7 +655,7 @@ impl ObjectStore for AimdThrottledStore { .throttled(|| self.target.put_multipart(location)) .await?; Ok(Box::new(ThrottledMultipartUpload { - target, + target: Arc::new(std::sync::Mutex::new(target)), write: Arc::clone(&self.write), })) } @@ -387,10 +667,10 @@ impl ObjectStore for AimdThrottledStore { ) -> OSResult> { let target = self .write - .throttled(|| self.target.put_multipart_opts(location, opts)) + .throttled(|| self.target.put_multipart_opts(location, opts.clone())) .await?; Ok(Box::new(ThrottledMultipartUpload { - target, + target: Arc::new(std::sync::Mutex::new(target)), write: Arc::clone(&self.write), })) } @@ -401,7 +681,7 @@ impl ObjectStore for AimdThrottledStore { async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult { self.read - .throttled(|| self.target.get_opts(location, options)) + .throttled(|| self.target.get_opts(location, options.clone())) .await } @@ -1133,10 +1413,11 @@ mod tests { let throttled = mock.throttle_count.load(Ordering::Relaxed); let total_mock = successes + throttled; - // Reader-side count must match mock-side count. - assert_eq!( - total_reader_requests, total_mock, - "Reader-side count ({total_reader_requests}) != mock-side count ({total_mock})" + // Mock-side count >= reader-side count because the AIMD layer retries + // throttle errors internally, causing multiple mock calls per reader call. + assert!( + total_mock >= total_reader_requests, + "Mock-side count ({total_mock}) should be >= reader-side count ({total_reader_requests})" ); // Mock capacity is 30/100ms = 300 req/s. Over 2s the theoretical max is @@ -1160,4 +1441,174 @@ mod tests { "AIMD should limit total requests, got {total_mock}" ); } + + /// A mock store that returns a configurable number of throttle errors + /// before succeeding on `get` operations. Used to test the retry logic + /// inside `OperationThrottle::throttled()`. + struct RetryTestMockStore { + inner: InMemory, + /// Number of throttle errors remaining before success. + errors_remaining: std::sync::Mutex, + /// Total number of `get` calls observed. + get_call_count: AtomicU64, + } + + impl RetryTestMockStore { + fn new(errors_before_success: usize) -> Self { + Self { + inner: InMemory::new(), + errors_remaining: std::sync::Mutex::new(errors_before_success), + get_call_count: AtomicU64::new(0), + } + } + } + + impl Display for RetryTestMockStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "RetryTestMockStore") + } + } + + impl Debug for RetryTestMockStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RetryTestMockStore").finish() + } + } + + #[async_trait] + impl ObjectStore for RetryTestMockStore { + async fn put(&self, location: &Path, bytes: PutPayload) -> OSResult { + self.inner.put(location, bytes).await + } + async fn put_opts( + &self, + location: &Path, + bytes: PutPayload, + opts: PutOptions, + ) -> OSResult { + self.inner.put_opts(location, bytes, opts).await + } + async fn put_multipart(&self, location: &Path) -> OSResult> { + self.inner.put_multipart(location).await + } + async fn put_multipart_opts( + &self, + location: &Path, + opts: PutMultipartOptions, + ) -> OSResult> { + self.inner.put_multipart_opts(location, opts).await + } + async fn get(&self, location: &Path) -> OSResult { + self.get_call_count.fetch_add(1, Ordering::Relaxed); + let should_error = { + let mut remaining = self.errors_remaining.lock().unwrap(); + if *remaining > 0 { + *remaining -= 1; + true + } else { + false + } + }; + if should_error { + Err(object_store::Error::Generic { + store: "RetryTestMock", + source: "request failed, after 3 retries, max_retries: 3, retry_timeout: 30s" + .into(), + }) + } else { + self.inner.get(location).await + } + } + async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult { + self.inner.get_opts(location, options).await + } + async fn get_range(&self, location: &Path, range: Range) -> OSResult { + self.inner.get_range(location, range).await + } + async fn get_ranges(&self, location: &Path, ranges: &[Range]) -> OSResult> { + self.inner.get_ranges(location, ranges).await + } + async fn head(&self, location: &Path) -> OSResult { + self.inner.head(location).await + } + async fn delete(&self, location: &Path) -> OSResult<()> { + self.inner.delete(location).await + } + fn delete_stream<'a>( + &'a self, + locations: BoxStream<'a, OSResult>, + ) -> BoxStream<'a, OSResult> { + self.inner.delete_stream(locations) + } + fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult> { + self.inner.list(prefix) + } + fn list_with_offset( + &self, + prefix: Option<&Path>, + offset: &Path, + ) -> BoxStream<'static, OSResult> { + self.inner.list_with_offset(prefix, offset) + } + async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult { + self.inner.list_with_delimiter(prefix).await + } + async fn copy(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.copy(from, to).await + } + async fn rename(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.rename(from, to).await + } + async fn rename_if_not_exists(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.rename_if_not_exists(from, to).await + } + async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> OSResult<()> { + self.inner.copy_if_not_exists(from, to).await + } + } + + #[tokio::test] + async fn test_throttled_retries_on_throttle_error_then_succeeds() { + // Mock returns 2 throttle errors then succeeds (within MAX_RETRIES=3) + let mock = Arc::new(RetryTestMockStore::new(2)); + let path = Path::from("test/retry.txt"); + mock.put(&path, PutPayload::from_static(b"retry data")) + .await + .unwrap(); + + let config = AimdThrottleConfig::default(); + let throttled = + AimdThrottledStore::new(mock.clone() as Arc, config).unwrap(); + + let result = throttled.get(&path).await; + assert!(result.is_ok(), "Expected success after retries"); + + let bytes = result.unwrap().bytes().await.unwrap(); + assert_eq!(bytes.as_ref(), b"retry data"); + + // Should have called get 3 times total: 2 failures + 1 success + assert_eq!(mock.get_call_count.load(Ordering::Relaxed), 3); + } + + #[tokio::test] + async fn test_throttled_fails_after_max_retries_exceeded() { + // Mock returns 4 throttle errors (more than MAX_RETRIES=3), + // so all 4 attempts (initial + 3 retries) will fail. + let mock = Arc::new(RetryTestMockStore::new(10)); + let path = Path::from("test/fail.txt"); + mock.put(&path, PutPayload::from_static(b"fail data")) + .await + .unwrap(); + + let config = AimdThrottleConfig::default(); + let throttled = + AimdThrottledStore::new(mock.clone() as Arc, config).unwrap(); + + let result = throttled.get(&path).await; + assert!(result.is_err(), "Expected error after max retries"); + assert!(is_throttle_error(&result.unwrap_err())); + + // Should have called get 4 times: initial attempt + 3 retries + assert_eq!(mock.get_call_count.load(Ordering::Relaxed), 4); + } } diff --git a/rust/lance-io/src/scheduler.rs b/rust/lance-io/src/scheduler.rs index e27e9519425..7199c4d1b91 100644 --- a/rust/lance-io/src/scheduler.rs +++ b/rust/lance-io/src/scheduler.rs @@ -13,7 +13,7 @@ use std::ops::Range; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Instant; -use tokio::sync::{Notify, Semaphore, SemaphorePermit}; +use tokio::sync::Notify; use lance_core::{Error, Result}; @@ -32,19 +32,6 @@ const BACKPRESSURE_DEBOUNCE: u64 = 60; static IOPS_COUNTER: AtomicU64 = AtomicU64::new(0); // Global counter of how many bytes were read by the scheduler static BYTES_READ_COUNTER: AtomicU64 = AtomicU64::new(0); -// By default, we limit the number of IOPS across the entire process to 128 -// -// In theory this is enough for ~10GBps on S3 following the guidelines to issue -// 1 IOP per 80MBps. In practice, I have noticed slightly better performance going -// up to 256. -// -// However, non-S3 stores (e.g. GCS, Azure) can suffer significantly from too many -// concurrent IOPS. For safety, we set the default to 128 and let the user override -// this if needed. -// -// Note: this only limits things that run through the scheduler. It does not limit -// IOPS from other sources like writing or commits. -static DEFAULT_PROCESS_IOPS_LIMIT: i32 = 128; pub fn iops_counter() -> u64 { IOPS_COUNTER.load(Ordering::Acquire) @@ -54,97 +41,6 @@ pub fn bytes_read_counter() -> u64 { BYTES_READ_COUNTER.load(Ordering::Acquire) } -// There are two structures that control the I/O scheduler concurrency. First, -// we have a hard limit on the number of IOPS that can be issued concurrently. -// This limit is process-wide. -// -// Second, we try and limit how many I/O requests can be buffered in memory without -// being consumed by a decoder of some kind. This limit is per-scheduler. We cannot -// make this limit process wide without introducing deadlock (because the decoder for -// file 0 might be waiting on IOPS blocked by a queue filled with requests for file 1) -// and vice-versa. -// -// There is also a per-scan limit on the number of IOPS that can be issued concurrently. -// -// The process-wide limit exists when users need a hard limit on the number of parallel -// IOPS, e.g. due to port availability limits or to prevent multiple scans from saturating -// the network. (Note: a process-wide limit of X will not necessarily limit the number of -// open TCP connections to exactly X. The underlying object store may open more connections -// anyways) -// -// However, it can be too tough in some cases, e.g. when some scans are reading from -// cloud storage and other scans are reading from local disk. In these cases users don't -// need to set a process-limit and can rely on the per-scan limits. - -// The IopsQuota enforces the first of the above limits, it is the per-process hard cap -// on the number of IOPS that can be issued concurrently. -// -// The per-scan limits are enforced by IoQueue -struct IopsQuota { - // An Option is used here to avoid mutex overhead if no limit is set - iops_avail: Option, -} - -/// A reservation on the global IOPS quota -/// -/// When the reservation is dropped, the IOPS quota is released unless -/// [`Self::forget`] is called. -struct IopsReservation<'a> { - value: Option>, -} - -impl IopsReservation<'_> { - // Forget the reservation, so it won't be released on drop - fn forget(&mut self) { - if let Some(value) = self.value.take() { - value.forget(); - } - } -} - -impl IopsQuota { - // By default, we throttle the number of scan IOPS across the entire process - // - // However, the user can disable this by setting the environment variable - // LANCE_PROCESS_IO_THREADS_LIMIT to zero (or a negative integer). - fn new() -> Self { - let initial_capacity = std::env::var("LANCE_PROCESS_IO_THREADS_LIMIT") - .map(|s| { - s.parse::().unwrap_or_else(|_| { - log::warn!("Ignoring invalid LANCE_PROCESS_IO_THREADS_LIMIT: {}", s); - DEFAULT_PROCESS_IOPS_LIMIT - }) - }) - .unwrap_or(DEFAULT_PROCESS_IOPS_LIMIT); - let iops_avail = if initial_capacity <= 0 { - None - } else { - Some(Semaphore::new(initial_capacity as usize)) - }; - Self { iops_avail } - } - - // Return a reservation on the global IOPS quota - fn release(&self) { - if let Some(iops_avail) = self.iops_avail.as_ref() { - iops_avail.add_permits(1); - } - } - - // Acquire a reservation on the global IOPS quota - async fn acquire(&self) -> IopsReservation<'_> { - if let Some(iops_avail) = self.iops_avail.as_ref() { - IopsReservation { - value: Some(iops_avail.acquire().await.unwrap()), - } - } else { - IopsReservation { value: None } - } - } -} - -static IOPS_QUOTA: std::sync::LazyLock = std::sync::LazyLock::new(IopsQuota::new); - // We want to allow requests that have a lower priority than any // currently in-flight request. This helps avoid potential deadlocks // related to backpressure. Unfortunately, it is quite expensive to @@ -303,17 +199,8 @@ impl IoQueue { async fn pop(&self) -> Option { loop { { - // First, grab a reservation on the global IOPS quota - // If we then get a task to run, transfer the reservation - // to the task. Otherwise, the reservation will be released - // when iop_res is dropped. - let mut iop_res = IOPS_QUOTA.acquire().await; - // Next, try and grab a reservation from the queue let mut state = self.state.lock().unwrap(); if let Some(task) = state.next_task() { - // Reservation successfully acquired, we will release the global - // global reservation after task has run. - iop_res.forget(); return Some(task); } @@ -501,7 +388,6 @@ impl IoTask { range_end = self.to_read.end, "File I/O completed" ); - IOPS_QUOTA.release(); (self.when_done)(bytes); } } From 75b8d2a514ee78e13b9af28f2076e8667c40ad04 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Fri, 27 Mar 2026 22:47:18 +0000 Subject: [PATCH 095/206] chore: bump to 5.0.0-beta.1 based on breaking change detection --- .bumpversion.toml | 2 +- Cargo.lock | 176 ++++++++++++++++---------------------- Cargo.toml | 40 ++++----- java/lance-jni/Cargo.lock | 142 +++++++++++++++--------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 142 +++++++++++++++--------------- python/Cargo.toml | 2 +- 8 files changed, 239 insertions(+), 269 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 2e52c6aa2b3..750cc27b46e 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "4.1.0-beta.3" +current_version = "5.0.0-beta.1" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 951c11ddf37..c419cbc1a7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,21 +114,6 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse 0.2.7", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstream" version = "1.0.0" @@ -136,7 +121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", - "anstyle-parse 1.0.0", + "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -150,15 +135,6 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - [[package]] name = "anstyle-parse" version = "1.0.0" @@ -670,9 +646,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -1519,9 +1495,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -1633,7 +1609,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream 1.0.0", + "anstream", "anstyle", "clap_lex", "strsim 0.11.1", @@ -1659,9 +1635,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -3106,9 +3082,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -3116,11 +3092,11 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ - "anstream 0.6.21", + "anstream", "anstyle", "env_filter", "jiff", @@ -3360,7 +3336,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "lance-datagen", @@ -4525,19 +4501,21 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "jsonb" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a901f06163d352fbe41c3c2ff5e08b75330a003cc941e988fb501022f5421e6" +checksum = "eb98fb29636087c40ad0d1274d9a30c0c1e83e03ae93f6e7e89247b37fcc6953" dependencies = [ "byteorder", "ethnum", @@ -4546,11 +4524,11 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float 5.1.0", + "ordered-float 5.2.0", "rand 0.9.2", - "ryu", "serde", "serde_json", + "zmij", ] [[package]] @@ -4579,7 +4557,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "all_asserts", "approx", @@ -4675,7 +4653,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4711,7 +4689,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrayref", "paste", @@ -4720,7 +4698,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4760,7 +4738,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4794,7 +4772,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4814,7 +4792,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4861,7 +4839,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "all_asserts", "arrow", @@ -4887,7 +4865,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4930,7 +4908,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -4944,7 +4922,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "approx", "arrow", @@ -5025,7 +5003,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -5073,7 +5051,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "approx", "arrow-array", @@ -5094,7 +5072,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -5109,7 +5087,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -5127,7 +5105,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -5186,7 +5164,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -5232,7 +5210,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "proc-macro2", "quote", @@ -5241,7 +5219,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "arrow-schema", @@ -5252,7 +5230,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "clap", "lance-core", @@ -5400,9 +5378,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.11.0", "libc", @@ -5778,9 +5756,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -5981,9 +5959,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-format" @@ -6291,9 +6269,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "0218004a4aae742209bee9c3cef05672f6b2708be36a50add8eb613b1f2a4008" dependencies = [ "num-traits", ] @@ -6839,9 +6817,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", @@ -7962,9 +7940,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simdutf8" @@ -8061,11 +8039,11 @@ dependencies = [ [[package]] name = "spade" -version = "2.15.0" +version = "2.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", "num-traits", "robust", "smallvec", @@ -8798,9 +8776,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.7+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15b06e6c39068c203e7c1d0bc3944796d867449e7668ef7fa5ea43727cb846e" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", "toml_datetime", @@ -9099,9 +9077,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -9198,9 +9176,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -9304,9 +9282,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -9317,23 +9295,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9341,9 +9315,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", @@ -9354,9 +9328,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] @@ -9410,9 +9384,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 518b7879379..5df59d793c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=4.1.0-beta.3", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=4.1.0-beta.3", path = "./rust/lance-arrow" } -lance-core = { version = "=4.1.0-beta.3", path = "./rust/lance-core" } -lance-datafusion = { version = "=4.1.0-beta.3", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=4.1.0-beta.3", path = "./rust/lance-datagen" } -lance-encoding = { version = "=4.1.0-beta.3", path = "./rust/lance-encoding" } -lance-file = { version = "=4.1.0-beta.3", path = "./rust/lance-file" } -lance-geo = { version = "=4.1.0-beta.3", path = "./rust/lance-geo" } -lance-index = { version = "=4.1.0-beta.3", path = "./rust/lance-index" } -lance-io = { version = "=4.1.0-beta.3", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=4.1.0-beta.3", path = "./rust/lance-linalg" } -lance-namespace = { version = "=4.1.0-beta.3", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=4.1.0-beta.3", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=4.1.0-beta.3", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=5.0.0-beta.1", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=5.0.0-beta.1", path = "./rust/lance-arrow" } +lance-core = { version = "=5.0.0-beta.1", path = "./rust/lance-core" } +lance-datafusion = { version = "=5.0.0-beta.1", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=5.0.0-beta.1", path = "./rust/lance-datagen" } +lance-encoding = { version = "=5.0.0-beta.1", path = "./rust/lance-encoding" } +lance-file = { version = "=5.0.0-beta.1", path = "./rust/lance-file" } +lance-geo = { version = "=5.0.0-beta.1", path = "./rust/lance-geo" } +lance-index = { version = "=5.0.0-beta.1", path = "./rust/lance-index" } +lance-io = { version = "=5.0.0-beta.1", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=5.0.0-beta.1", path = "./rust/lance-linalg" } +lance-namespace = { version = "=5.0.0-beta.1", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=5.0.0-beta.1", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=5.0.0-beta.1", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=4.1.0-beta.3", path = "./rust/lance-table" } -lance-test-macros = { version = "=4.1.0-beta.3", path = "./rust/lance-test-macros" } -lance-testing = { version = "=4.1.0-beta.3", path = "./rust/lance-testing" } +lance-table = { version = "=5.0.0-beta.1", path = "./rust/lance-table" } +lance-test-macros = { version = "=5.0.0-beta.1", path = "./rust/lance-test-macros" } +lance-testing = { version = "=5.0.0-beta.1", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=4.1.0-beta.3", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=5.0.0-beta.1", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=4.1.0-beta.3", path = "./rust/compression/fsst" } +fsst = { version = "=5.0.0-beta.1", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index df85f7c2759..44ce34125fd 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -74,9 +74,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -95,9 +95,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -523,9 +523,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -1085,9 +1085,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -1155,9 +1155,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -2223,9 +2223,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -2233,9 +2233,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -2389,7 +2389,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3330,19 +3330,21 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "jsonb" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a901f06163d352fbe41c3c2ff5e08b75330a003cc941e988fb501022f5421e6" +checksum = "eb98fb29636087c40ad0d1274d9a30c0c1e83e03ae93f6e7e89247b37fcc6953" dependencies = [ "byteorder", "ethnum", @@ -3351,11 +3353,11 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float 5.1.0", + "ordered-float 5.2.0", "rand 0.9.2", - "ryu", "serde", "serde_json", + "zmij", ] [[package]] @@ -3375,7 +3377,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3441,7 +3443,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3461,7 +3463,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrayref", "paste", @@ -3470,7 +3472,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3507,7 +3509,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3540,7 +3542,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3558,7 +3560,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -3595,7 +3597,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -3627,7 +3629,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -3641,7 +3643,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3709,7 +3711,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3750,7 +3752,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-schema", @@ -3784,7 +3786,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3800,7 +3802,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -3813,7 +3815,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -3858,7 +3860,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3985,9 +3987,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] @@ -4183,9 +4185,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -4310,9 +4312,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -4501,9 +4503,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "0218004a4aae742209bee9c3cef05672f6b2708be36a50add8eb613b1f2a4008" dependencies = [ "num-traits", ] @@ -5775,9 +5777,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simdutf8" @@ -5863,11 +5865,11 @@ dependencies = [ [[package]] name = "spade" -version = "2.15.0" +version = "2.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", "num-traits", "robust", "smallvec", @@ -6407,9 +6409,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.7+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15b06e6c39068c203e7c1d0bc3944796d867449e7668ef7fa5ea43727cb846e" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", "toml_datetime", @@ -6638,9 +6640,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -6704,9 +6706,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -6777,9 +6779,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -6790,23 +6792,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6814,9 +6812,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", @@ -6827,9 +6825,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] @@ -6883,9 +6881,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 1db28faac72..bfd6ecaa13d 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 9127f55d9b8..047fb50082c 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 4.1.0-beta.3 + 5.0.0-beta.1 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index 61586caef09..5b90e60c644 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -128,9 +128,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -149,9 +149,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -620,9 +620,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -1244,9 +1244,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -1317,9 +1317,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -2641,9 +2641,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -2651,9 +2651,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -2818,7 +2818,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3789,19 +3789,21 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "jsonb" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a901f06163d352fbe41c3c2ff5e08b75330a003cc941e988fb501022f5421e6" +checksum = "eb98fb29636087c40ad0d1274d9a30c0c1e83e03ae93f6e7e89247b37fcc6953" dependencies = [ "byteorder", "ethnum", @@ -3810,11 +3812,11 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float 5.1.0", + "ordered-float 5.2.0", "rand 0.9.2", - "ryu", "serde", "serde_json", + "zmij", ] [[package]] @@ -3843,7 +3845,7 @@ dependencies = [ [[package]] name = "lance" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3910,7 +3912,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3930,7 +3932,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrayref", "paste", @@ -3939,7 +3941,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3976,7 +3978,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4009,7 +4011,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4027,7 +4029,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4064,7 +4066,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4096,7 +4098,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -4110,7 +4112,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -4181,7 +4183,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -4222,7 +4224,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4238,7 +4240,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -4251,7 +4253,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -4296,7 +4298,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4485,9 +4487,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.11.0", "libc", @@ -4836,9 +4838,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -4963,9 +4965,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -5163,9 +5165,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "0218004a4aae742209bee9c3cef05672f6b2708be36a50add8eb613b1f2a4008" dependencies = [ "num-traits", ] @@ -5660,7 +5662,7 @@ dependencies = [ [[package]] name = "pylance" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -6681,9 +6683,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simdutf8" @@ -6769,11 +6771,11 @@ dependencies = [ [[package]] name = "spade" -version = "2.15.0" +version = "2.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", "num-traits", "robust", "smallvec", @@ -7365,9 +7367,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.7+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15b06e6c39068c203e7c1d0bc3944796d867449e7668ef7fa5ea43727cb846e" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", "toml_datetime", @@ -7649,9 +7651,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -7727,9 +7729,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -7806,9 +7808,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -7819,23 +7821,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7843,9 +7841,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", @@ -7856,9 +7854,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] @@ -7912,9 +7910,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/python/Cargo.toml b/python/Cargo.toml index f35f81a9cc9..91e804565df 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "4.1.0-beta.3" +version = "5.0.0-beta.1" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 2a4daf13016ef48a964216e6fd5f39564e5dff5f Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Fri, 27 Mar 2026 23:02:01 -0700 Subject: [PATCH 096/206] docs: add conflict handling and FRI guidance (#6304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add a "Conflict Handling" section to the performance guide explaining how concurrent operation conflicts affect throughput, with common conflict examples and a link to the transaction spec - Add a "Fragment Reuse Index" subsection to the performance guide describing how the FRI avoids compaction/index conflicts, with a Python API snippet - Add an "Impacts" section to the FRI spec covering conflict resolution changes, index load cost, and FRI growth/cleanup ## Test plan - [ ] Verify doc links resolve correctly (conflict resolution anchor, FRI spec link) - [ ] Review rendered markdown for formatting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 --- .../format/table/index/system/frag_reuse.md | 26 +++++++++++- docs/src/guide/performance.md | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/docs/src/format/table/index/system/frag_reuse.md b/docs/src/format/table/index/system/frag_reuse.md index f0ba6acab2f..a508b52b920 100644 --- a/docs/src/format/table/index/system/frag_reuse.md +++ b/docs/src/format/table/index/system/frag_reuse.md @@ -31,5 +31,27 @@ The index accumulates a new **reuse version** every time a compaction is execute As long as all the scalar and vector indices are created after the specific reuse version, the indices are all caught up and the specific reuse version can be trimmed. -It is expected that the user schedules an additional process to trim the index periodically -to keep the list of reuse versions in control. \ No newline at end of file +## Impacts + +### Conflict Resolution + +The presence of the Fragment Reuse Index changes how Lance detects conflicts between concurrent +operations. Operations that would normally conflict with compaction (such as index building) can +proceed without conflict when the FRI is in use. For full details on how conflict detection is +affected, see [conflict resolution](../../transaction.md#conflict-resolution). + +### Index Load Cost + +When the FRI is present, indices must be remapped at load time. Each time an index is loaded into +the cache, the FRI is applied to translate old row addresses to current ones. This adds a small +cost to index loading but does not affect query performance once the index is cached. + +### FRI Growth and Cleanup + +The FRI grows with each compaction. Every compaction that defers index remapping adds a new reuse +version to the index. Over time, this can accumulate and increase the cost of index loading since +more address translations must be applied. + +Once all scalar and vector indices have been rebuilt past a given reuse version, that version is no +longer needed and can be trimmed. Users should schedule a periodic process to trim stale reuse +versions and keep the FRI size under control. \ No newline at end of file diff --git a/docs/src/guide/performance.md b/docs/src/guide/performance.md index fb94b94e7ed..6fab095a7cf 100644 --- a/docs/src/guide/performance.md +++ b/docs/src/guide/performance.md @@ -189,6 +189,47 @@ use cases. For example, S3 can typically get up to 5000 req/s and with these settings we should get there in about 10 seconds. +## Conflict Handling + +Lance supports concurrent operations on the same table using optimistic concurrency control. When two +operations conflict, one of them must be retried. Retries are handled automatically but they repeat +work that has already been done, which can hurt throughput. Understanding and minimizing conflicts is +important for maintaining good performance in write-heavy workloads. + +Common sources of conflicts include: + +- Concurrent compaction and index building, since both need to modify the same indices +- Update operations that affect the same fragments, since both need to rewrite the same data files + +For more details on which operations conflict with each other, see +[conflict resolution](../format/table/transaction.md#conflict-resolution). + +### Fragment Reuse Index + +Compaction is one of the most expensive write operations because it rewrites data files and, by +default, remaps all indices to reflect the new row addresses. When compaction and index building +run concurrently, they often conflict because both need to modify the same indices. This typically +causes the compaction to fail and retry, and repeated failures can cause table layout to degrade +over time. + +The Fragment Reuse Index (FRI) solves this by allowing compaction to skip the index remap step. +Instead of immediately updating indices, compaction records a mapping from old fragment row +addresses to new ones. When indices are loaded into the cache, the FRI is applied to translate +the old row addresses to the current ones. This adds a small cost to index load time but does +not affect query performance once the index is cached. + +This decoupling means compaction and index building no longer conflict, which is especially +valuable for tables that are continuously ingesting data while also maintaining indices. + +To enable the FRI, set `defer_index_remap=True` when compacting: + +```python +dataset.optimize.compact_files(defer_index_remap=True) +``` + +For details on the index format and usage patterns, see the +[Fragment Reuse Index specification](../format/table/index/system/frag_reuse.md). + ## Indexes Training and searching indexes can have unique requirements for compute and memory. This section provides some From 7342abf8566d3198318f2141a07ee9ed04b26b35 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Sun, 29 Mar 2026 15:43:23 -0700 Subject: [PATCH 097/206] feat: pluggable index cache via CacheBackend trait (#6222) Previously the Session's index cache was hardcoded to Moka. This adds a `CacheBackend` trait so users can provide their own cache implementation, with `MokaCacheBackend` as the default. Serialization and zero-IO reconstruction are in the follow-up PR #6223. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-core/src/cache.rs | 806 ----------------- rust/lance-core/src/cache/backend.rs | 122 +++ rust/lance-core/src/cache/mod.rs | 811 ++++++++++++++++++ rust/lance-core/src/cache/moka.rs | 138 +++ .../src/encodings/logical/primitive.rs | 4 + rust/lance-file/src/previous/reader.rs | 11 +- rust/lance-index/src/scalar/bitmap.rs | 4 + rust/lance-index/src/scalar/btree.rs | 4 + rust/lance-index/src/scalar/inverted/index.rs | 8 + rust/lance-index/src/scalar/ngram.rs | 4 + rust/lance-index/src/scalar/rtree.rs | 4 + rust/lance/src/dataset/builder.rs | 32 +- rust/lance/src/dataset/fragment.rs | 4 + rust/lance/src/index.rs | 16 + rust/lance/src/index/vector/ivf.rs | 4 + rust/lance/src/index/vector/ivf/v2.rs | 6 + rust/lance/src/lib.rs | 2 +- rust/lance/src/session.rs | 48 +- rust/lance/src/session/caches.rs | 24 +- rust/lance/src/session/index_caches.rs | 12 + 20 files changed, 1236 insertions(+), 828 deletions(-) delete mode 100644 rust/lance-core/src/cache.rs create mode 100644 rust/lance-core/src/cache/backend.rs create mode 100644 rust/lance-core/src/cache/mod.rs create mode 100644 rust/lance-core/src/cache/moka.rs diff --git a/rust/lance-core/src/cache.rs b/rust/lance-core/src/cache.rs deleted file mode 100644 index 6ceea807116..00000000000 --- a/rust/lance-core/src/cache.rs +++ /dev/null @@ -1,806 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -//! Cache implementation - -use std::any::{Any, TypeId}; -use std::borrow::Cow; -use std::sync::{ - Arc, - atomic::{AtomicU64, Ordering}, -}; - -use futures::{Future, FutureExt}; -use moka::future::Cache; - -use crate::Result; - -pub use deepsize::{Context, DeepSizeOf}; - -type ArcAny = Arc; - -#[derive(Clone)] -pub struct SizedRecord { - record: ArcAny, - size_accessor: Arc usize + Send + Sync>, -} - -impl std::fmt::Debug for SizedRecord { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SizedRecord") - .field("record", &self.record) - .finish() - } -} - -impl DeepSizeOf for SizedRecord { - fn deep_size_of_children(&self, _: &mut Context) -> usize { - (self.size_accessor)(&self.record) - } -} - -impl SizedRecord { - fn new(record: Arc) -> Self { - // +8 for the size of the Arc pointer itself - let size_accessor = - |record: &ArcAny| -> usize { record.downcast_ref::().unwrap().deep_size_of() + 8 }; - Self { - record, - size_accessor: Arc::new(size_accessor), - } - } -} - -#[derive(Clone)] -pub struct LanceCache { - cache: Arc>, - prefix: String, - hits: Arc, - misses: Arc, -} - -impl std::fmt::Debug for LanceCache { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("LanceCache") - .field("cache", &self.cache) - .finish() - } -} - -impl DeepSizeOf for LanceCache { - fn deep_size_of_children(&self, _: &mut Context) -> usize { - self.cache - .iter() - .map(|(_, v)| (v.size_accessor)(&v.record)) - .sum() - } -} - -impl LanceCache { - pub fn with_capacity(capacity: usize) -> Self { - let cache = Cache::builder() - .max_capacity(capacity as u64) - .weigher(|_, v: &SizedRecord| { - (v.size_accessor)(&v.record).try_into().unwrap_or(u32::MAX) - }) - .support_invalidation_closures() - .build(); - Self { - cache: Arc::new(cache), - prefix: String::new(), - hits: Arc::new(AtomicU64::new(0)), - misses: Arc::new(AtomicU64::new(0)), - } - } - - pub fn no_cache() -> Self { - Self { - cache: Arc::new(Cache::new(0)), - prefix: String::new(), - hits: Arc::new(AtomicU64::new(0)), - misses: Arc::new(AtomicU64::new(0)), - } - } - - /// Appends a prefix to the cache key - /// - /// If this cache already has a prefix, the new prefix will be appended to - /// the existing one. - /// - /// Prefixes are used to create a namespace for the cache keys to avoid - /// collisions between different caches. - pub fn with_key_prefix(&self, prefix: &str) -> Self { - Self { - cache: self.cache.clone(), - prefix: format!("{}{}/", self.prefix, prefix), - hits: self.hits.clone(), - misses: self.misses.clone(), - } - } - - fn get_key(&self, key: &str) -> String { - if self.prefix.is_empty() { - key.to_string() - } else { - format!("{}/{}", self.prefix, key) - } - } - - /// Invalidate all entries in the cache that start with the given prefix - /// - /// The given prefix is appended to the existing prefix of the cache. If you - /// want to invalidate all at the current prefix, pass an empty string. - pub fn invalidate_prefix(&self, prefix: &str) { - let full_prefix = format!("{}{}", self.prefix, prefix); - self.cache - .invalidate_entries_if(move |(key, _typeid), _value| key.starts_with(&full_prefix)) - .expect("Cache configured correctly"); - } - - pub async fn size(&self) -> usize { - self.cache.run_pending_tasks().await; - self.cache.entry_count() as usize - } - - pub fn approx_size(&self) -> usize { - self.cache.entry_count() as usize - } - - pub async fn size_bytes(&self) -> usize { - self.cache.run_pending_tasks().await; - self.approx_size_bytes() - } - - pub fn approx_size_bytes(&self) -> usize { - self.cache.weighted_size() as usize - } - - async fn insert(&self, key: &str, metadata: Arc) { - let key = self.get_key(key); - let record = SizedRecord::new(metadata); - tracing::trace!( - target: "lance_cache::insert", - key = key, - type_id = std::any::type_name::(), - size = (record.size_accessor)(&record.record), - ); - self.cache.insert((key, TypeId::of::()), record).await; - } - - pub async fn insert_unsized( - &self, - key: &str, - metadata: Arc, - ) { - // In order to make the data Sized, we wrap in another pointer. - self.insert(key, Arc::new(metadata)).await - } - - async fn get(&self, key: &str) -> Option> { - let key = self.get_key(key); - if let Some(metadata) = self.cache.get(&(key, TypeId::of::())).await { - self.hits.fetch_add(1, Ordering::Relaxed); - Some(metadata.record.clone().downcast::().unwrap()) - } else { - self.misses.fetch_add(1, Ordering::Relaxed); - None - } - } - - pub async fn get_unsized( - &self, - key: &str, - ) -> Option> { - let outer = self.get::>(key).await?; - Some(outer.as_ref().clone()) - } - - /// Get an item - /// - /// If it exists in the cache return that - /// - /// If it doesn't then run `loader` to load the item, insert into cache, and return - async fn get_or_insert( - &self, - key: String, - loader: F, - ) -> Result> - where - F: FnOnce(&str) -> Fut, - Fut: Future> + Send, - { - let full_key = self.get_key(&key); - let cache_key = (full_key, TypeId::of::()); - - // Use optionally_get_with to handle concurrent requests - let hits = self.hits.clone(); - let misses = self.misses.clone(); - - // Use oneshot channels to track both errors and whether init was run - let (error_tx, error_rx) = tokio::sync::oneshot::channel(); - let (init_run_tx, mut init_run_rx) = tokio::sync::oneshot::channel(); - - let init = Box::pin(async move { - let _ = init_run_tx.send(()); - misses.fetch_add(1, Ordering::Relaxed); - match loader(&key).await { - Ok(value) => Some(SizedRecord::new(Arc::new(value))), - Err(e) => { - let _ = error_tx.send(e); - None - } - } - }); - - match self.cache.optionally_get_with(cache_key, init).await { - Some(metadata) => { - // Check if init was run or if this was a cache hit - match init_run_rx.try_recv() { - Ok(()) => { - // Init was run, miss was already recorded - } - Err(_) => { - // Init was not run, this is a cache hit - hits.fetch_add(1, Ordering::Relaxed); - } - } - Ok(metadata.record.clone().downcast::().unwrap()) - } - None => { - // The loader returned an error, retrieve it from the channel - match error_rx.await { - Ok(err) => Err(err), - Err(_) => Err(crate::Error::internal( - "Failed to retrieve error from cache loader", - )), - } - } - } - } - - pub async fn stats(&self) -> CacheStats { - self.cache.run_pending_tasks().await; - CacheStats { - hits: self.hits.load(Ordering::Relaxed), - misses: self.misses.load(Ordering::Relaxed), - num_entries: self.cache.entry_count() as usize, - size_bytes: self.cache.weighted_size() as usize, - } - } - - pub async fn clear(&self) { - self.cache.invalidate_all(); - self.cache.run_pending_tasks().await; - self.hits.store(0, Ordering::Relaxed); - self.misses.store(0, Ordering::Relaxed); - } - - // CacheKey-based methods - pub async fn insert_with_key(&self, cache_key: &K, metadata: Arc) - where - K: CacheKey, - K::ValueType: DeepSizeOf + Send + Sync + 'static, - { - self.insert(&cache_key.key(), metadata).boxed().await - } - - pub async fn get_with_key(&self, cache_key: &K) -> Option> - where - K: CacheKey, - K::ValueType: DeepSizeOf + Send + Sync + 'static, - { - self.get::(&cache_key.key()).boxed().await - } - - pub async fn get_or_insert_with_key( - &self, - cache_key: K, - loader: F, - ) -> Result> - where - K: CacheKey, - K::ValueType: DeepSizeOf + Send + Sync + 'static, - F: FnOnce() -> Fut, - Fut: Future> + Send, - { - let key_str = cache_key.key().into_owned(); - Box::pin(self.get_or_insert(key_str, |_| loader())).await - } - - pub async fn insert_unsized_with_key(&self, cache_key: &K, metadata: Arc) - where - K: UnsizedCacheKey, - K::ValueType: DeepSizeOf + Send + Sync + 'static, - { - self.insert_unsized(&cache_key.key(), metadata) - .boxed() - .await - } - - pub async fn get_unsized_with_key(&self, cache_key: &K) -> Option> - where - K: UnsizedCacheKey, - K::ValueType: DeepSizeOf + Send + Sync + 'static, - { - self.get_unsized::(&cache_key.key()) - .boxed() - .await - } -} - -/// A weak reference to a LanceCache, used by indices to avoid circular references. -/// When the original cache is dropped, operations on this will gracefully no-op. -#[derive(Clone, Debug)] -pub struct WeakLanceCache { - inner: std::sync::Weak>, - prefix: String, - hits: Arc, - misses: Arc, -} - -impl WeakLanceCache { - /// Create a weak reference from a strong LanceCache - pub fn from(cache: &LanceCache) -> Self { - Self { - inner: Arc::downgrade(&cache.cache), - prefix: cache.prefix.clone(), - hits: cache.hits.clone(), - misses: cache.misses.clone(), - } - } - - /// Appends a prefix to the cache key - pub fn with_key_prefix(&self, prefix: &str) -> Self { - Self { - inner: self.inner.clone(), - prefix: format!("{}{}/", self.prefix, prefix), - hits: self.hits.clone(), - misses: self.misses.clone(), - } - } - - fn get_key(&self, key: &str) -> String { - if self.prefix.is_empty() { - key.to_string() - } else { - format!("{}/{}", self.prefix, key) - } - } - - /// Get an item from cache if the cache is still alive - pub async fn get(&self, key: &str) -> Option> { - let cache = self.inner.upgrade()?; - let key = self.get_key(key); - if let Some(metadata) = cache.get(&(key, TypeId::of::())).await { - self.hits.fetch_add(1, Ordering::Relaxed); - Some(metadata.record.clone().downcast::().unwrap()) - } else { - self.misses.fetch_add(1, Ordering::Relaxed); - None - } - } - - /// Insert an item if the cache is still alive - /// Returns true if the item was inserted, false if the cache is no longer available - pub async fn insert( - &self, - key: &str, - value: Arc, - ) -> bool { - if let Some(cache) = self.inner.upgrade() { - let key = self.get_key(key); - let record = SizedRecord::new(value); - cache.insert((key, TypeId::of::()), record).await; - true - } else { - log::warn!("WeakLanceCache: cache no longer available, unable to insert item"); - false - } - } - - /// Get or insert an item, computing it if necessary - pub async fn get_or_insert(&self, key: &str, f: F) -> Result> - where - T: DeepSizeOf + Send + Sync + 'static, - F: FnOnce() -> Fut, - Fut: Future> + Send, - { - if let Some(cache) = self.inner.upgrade() { - let full_key = self.get_key(key); - let cache_key = (full_key.clone(), TypeId::of::()); - - // Use optionally_get_with to handle concurrent requests properly - let hits = self.hits.clone(); - let misses = self.misses.clone(); - - // Track whether init was run (for metrics) - let (init_run_tx, mut init_run_rx) = tokio::sync::oneshot::channel(); - let (error_tx, error_rx) = tokio::sync::oneshot::channel(); - - let init = Box::pin(async move { - let _ = init_run_tx.send(()); - misses.fetch_add(1, Ordering::Relaxed); - match f().await { - Ok(value) => Some(SizedRecord::new(Arc::new(value))), - Err(e) => { - let _ = error_tx.send(e); - None - } - } - }); - - match cache.optionally_get_with(cache_key, init).await { - Some(record) => { - // Check if init was run or if this was a cache hit - match init_run_rx.try_recv() { - Ok(()) => { - // Init was run, miss was already recorded - } - Err(_) => { - // Init was not run, this was a cache hit - hits.fetch_add(1, Ordering::Relaxed); - } - } - Ok(record.record.clone().downcast::().unwrap()) - } - None => { - // Init returned None, which means there was an error - match error_rx.await { - Ok(e) => Err(e), - Err(_) => Err(crate::Error::internal( - "Failed to receive error from cache init function".to_string(), - )), - } - } - } - } else { - log::warn!("WeakLanceCache: cache no longer available, computing without caching"); - f().await.map(Arc::new) - } - } - - /// Get or insert an item with a cache key type - pub async fn get_or_insert_with_key( - &self, - cache_key: K, - loader: F, - ) -> Result> - where - K: CacheKey, - K::ValueType: DeepSizeOf + Send + Sync + 'static, - F: FnOnce() -> Fut, - Fut: Future> + Send, - { - let key_str = cache_key.key().into_owned(); - self.get_or_insert(&key_str, loader).await - } - - /// Insert with a cache key type - /// Returns true if the item was inserted, false if the cache is no longer available - pub async fn insert_with_key(&self, cache_key: &K, value: Arc) -> bool - where - K: CacheKey, - K::ValueType: DeepSizeOf + Send + Sync + 'static, - { - let key_str = cache_key.key().into_owned(); - self.insert(&key_str, value).await - } - - /// Get with a cache key type - pub async fn get_with_key(&self, cache_key: &K) -> Option> - where - K: CacheKey, - K::ValueType: DeepSizeOf + Send + Sync + 'static, - { - let key_str = cache_key.key().into_owned(); - self.get(&key_str).await - } - - /// Get unsized item from cache - pub async fn get_unsized( - &self, - key: &str, - ) -> Option> { - // For unsized types, we store Arc directly - let cache = self.inner.upgrade()?; - let key = self.get_key(key); - if let Some(metadata) = cache.get(&(key, TypeId::of::>())).await { - metadata - .record - .clone() - .downcast::>() - .ok() - .map(|arc| arc.as_ref().clone()) - } else { - None - } - } - - /// Insert unsized item into cache - pub async fn insert_unsized( - &self, - key: &str, - value: Arc, - ) { - if let Some(cache) = self.inner.upgrade() { - let key = self.get_key(key); - let record = SizedRecord::new(Arc::new(value)); - cache.insert((key, TypeId::of::>()), record).await; - } else { - log::warn!("WeakLanceCache: cache no longer available, unable to insert unsized item"); - } - } - - /// Get unsized with a cache key type - pub async fn get_unsized_with_key(&self, cache_key: &K) -> Option> - where - K: UnsizedCacheKey, - K::ValueType: DeepSizeOf + Send + Sync + 'static, - { - let key_str = cache_key.key(); - self.get_unsized(&key_str).await - } - - /// Insert unsized with a cache key type - pub async fn insert_unsized_with_key(&self, cache_key: &K, value: Arc) - where - K: UnsizedCacheKey, - K::ValueType: DeepSizeOf + Send + Sync + 'static, - { - let key_str = cache_key.key(); - self.insert_unsized(&key_str, value).await - } -} - -pub trait CacheKey { - type ValueType; - - fn key(&self) -> Cow<'_, str>; -} - -pub trait UnsizedCacheKey { - type ValueType: ?Sized; - - fn key(&self) -> Cow<'_, str>; -} - -#[derive(Debug, Clone)] -pub struct CacheStats { - /// Number of times `get`, `get_unsized`, or `get_or_insert` found an item in the cache. - pub hits: u64, - /// Number of times `get`, `get_unsized`, or `get_or_insert` did not find an item in the cache. - pub misses: u64, - /// Number of entries currently in the cache. - pub num_entries: usize, - /// Total size in bytes of all entries in the cache. - pub size_bytes: usize, -} - -impl CacheStats { - pub fn hit_ratio(&self) -> f32 { - if self.hits + self.misses == 0 { - 0.0 - } else { - self.hits as f32 / (self.hits + self.misses) as f32 - } - } - - pub fn miss_ratio(&self) -> f32 { - if self.hits + self.misses == 0 { - 0.0 - } else { - self.misses as f32 / (self.hits + self.misses) as f32 - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_cache_bytes() { - let item = Arc::new(vec![1, 2, 3]); - let item_size = item.deep_size_of(); // Size of Arc> - let capacity = 10 * item_size; - - let cache = LanceCache::with_capacity(capacity); - assert_eq!(cache.size_bytes().await, 0); - assert_eq!(cache.approx_size_bytes(), 0); - - let item = Arc::new(vec![1, 2, 3]); - cache.insert("key", item.clone()).await; - assert_eq!(cache.size().await, 1); - assert_eq!(cache.size_bytes().await, item_size); - assert_eq!(cache.approx_size_bytes(), item_size); - - let retrieved = cache.get::>("key").await.unwrap(); - assert_eq!(*retrieved, *item); - - // Test eviction based on size - for i in 0..20 { - cache - .insert(&format!("key_{}", i), Arc::new(vec![i, i, i])) - .await; - } - assert_eq!(cache.size_bytes().await, capacity); - assert_eq!(cache.size().await, 10); - } - - #[tokio::test] - async fn test_cache_trait_objects() { - #[derive(Debug, DeepSizeOf)] - struct MyType(i32); - - trait MyTrait: DeepSizeOf + Send + Sync + Any { - fn as_any(&self) -> &dyn Any; - } - - impl MyTrait for MyType { - fn as_any(&self) -> &dyn Any { - self - } - } - - let item = Arc::new(MyType(42)); - let item_dyn: Arc = item; - - let cache = LanceCache::with_capacity(1000); - cache.insert_unsized("test", item_dyn).await; - - let retrieved = cache.get_unsized::("test").await.unwrap(); - let retrieved = retrieved.as_any().downcast_ref::().unwrap(); - assert_eq!(retrieved.0, 42); - } - - #[tokio::test] - async fn test_cache_stats_basic() { - let cache = LanceCache::with_capacity(1000); - - // Initially no hits or misses - let stats = cache.stats().await; - assert_eq!(stats.hits, 0); - assert_eq!(stats.misses, 0); - - // Miss on first get - let result = cache.get::>("nonexistent"); - assert!(result.await.is_none()); - let stats = cache.stats().await; - assert_eq!(stats.hits, 0); - assert_eq!(stats.misses, 1); - - // Insert and then hit - cache.insert("key1", Arc::new(vec![1, 2, 3])).await; - let result = cache.get::>("key1"); - assert!(result.await.is_some()); - let stats = cache.stats().await; - assert_eq!(stats.hits, 1); - assert_eq!(stats.misses, 1); - - // Another hit - let result = cache.get::>("key1"); - assert!(result.await.is_some()); - let stats = cache.stats().await; - assert_eq!(stats.hits, 2); - assert_eq!(stats.misses, 1); - - // Another miss - let result = cache.get::>("nonexistent2"); - assert!(result.await.is_none()); - let stats = cache.stats().await; - assert_eq!(stats.hits, 2); - assert_eq!(stats.misses, 2); - } - - #[tokio::test] - async fn test_cache_stats_with_prefixes() { - let base_cache = LanceCache::with_capacity(1000); - let prefixed_cache = base_cache.with_key_prefix("test"); - - // Stats should be shared between base and prefixed cache - let stats = base_cache.stats().await; - assert_eq!(stats.hits, 0); - assert_eq!(stats.misses, 0); - - let stats = prefixed_cache.stats().await; - assert_eq!(stats.hits, 0); - assert_eq!(stats.misses, 0); - - // Miss on prefixed cache - let result = prefixed_cache.get::>("key1"); - assert!(result.await.is_none()); - - // Both should show the miss - let stats = base_cache.stats().await; - assert_eq!(stats.hits, 0); - assert_eq!(stats.misses, 1); - - let stats = prefixed_cache.stats().await; - assert_eq!(stats.hits, 0); - assert_eq!(stats.misses, 1); - - // Insert through prefixed cache and hit - prefixed_cache.insert("key1", Arc::new(vec![1, 2, 3])).await; - let result = prefixed_cache.get::>("key1"); - assert!(result.await.is_some()); - - // Both should show the hit - let stats = base_cache.stats().await; - assert_eq!(stats.hits, 1); - assert_eq!(stats.misses, 1); - - let stats = prefixed_cache.stats().await; - assert_eq!(stats.hits, 1); - assert_eq!(stats.misses, 1); - } - - #[tokio::test] - async fn test_cache_stats_unsized() { - #[derive(Debug, DeepSizeOf)] - struct MyType(i32); - - trait MyTrait: DeepSizeOf + Send + Sync + Any {} - - impl MyTrait for MyType {} - - let cache = LanceCache::with_capacity(1000); - - // Miss on unsized get - let result = cache.get_unsized::("test"); - assert!(result.await.is_none()); - let stats = cache.stats().await; - assert_eq!(stats.hits, 0); - assert_eq!(stats.misses, 1); - - // Insert and hit on unsized - let item = Arc::new(MyType(42)); - let item_dyn: Arc = item; - cache.insert_unsized("test", item_dyn).await; - - let result = cache.get_unsized::("test"); - assert!(result.await.is_some()); - let stats = cache.stats().await; - assert_eq!(stats.hits, 1); - assert_eq!(stats.misses, 1); - } - - #[tokio::test] - async fn test_cache_stats_get_or_insert() { - let cache = LanceCache::with_capacity(1000); - - // First call should be a miss and load the value - let result: Arc> = cache - .get_or_insert("key1".to_string(), |_key| async { Ok(vec![1, 2, 3]) }) - .await - .unwrap(); - assert_eq!(*result, vec![1, 2, 3]); - - let stats = cache.stats().await; - assert_eq!(stats.hits, 0); - assert_eq!(stats.misses, 1); - - // Second call should be a hit - let result: Arc> = cache - .get_or_insert("key1".to_string(), |_key| async { - panic!("Should not be called") - }) - .await - .unwrap(); - assert_eq!(*result, vec![1, 2, 3]); - - let stats = cache.stats().await; - assert_eq!(stats.hits, 1); - assert_eq!(stats.misses, 1); - - // Different key should be another miss - let result: Arc> = cache - .get_or_insert("key2".to_string(), |_key| async { Ok(vec![4, 5, 6]) }) - .await - .unwrap(); - assert_eq!(*result, vec![4, 5, 6]); - - let stats = cache.stats().await; - assert_eq!(stats.hits, 1); - assert_eq!(stats.misses, 2); - } -} diff --git a/rust/lance-core/src/cache/backend.rs b/rust/lance-core/src/cache/backend.rs new file mode 100644 index 00000000000..54b24944ab0 --- /dev/null +++ b/rust/lance-core/src/cache/backend.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Backend interface for cache implementors. +//! +//! This module defines the trait that custom cache backends must implement, +//! along with the key and entry types they operate on. Most callers should +//! use [`LanceCache`](super::LanceCache) instead of interacting with +//! backends directly. + +use std::any::Any; +use std::pin::Pin; +use std::sync::Arc; + +use async_trait::async_trait; +use futures::Future; + +use crate::Result; + +/// A type-erased cache entry. +pub type CacheEntry = Arc; + +/// Structured cache key passed to [`CacheBackend`] methods. +/// +/// Composed of three parts: +/// - **prefix**: scopes the key to a dataset or index (e.g. `"s3://bucket/dataset/"`) +/// - **key**: identifies the specific entry (e.g. `"42"` for a version number) +/// - **type_name**: distinguishes different value types stored under the same +/// user key (e.g. `"Vec"`) +/// +/// [`LanceCache`](super::LanceCache) constructs these automatically from +/// [`CacheKey`](super::CacheKey) values; backend authors receive them +/// ready-made. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct InternalCacheKey { + prefix: Arc, + key: Arc, + type_name: &'static str, +} + +impl InternalCacheKey { + pub fn new(prefix: Arc, key: Arc, type_name: &'static str) -> Self { + Self { + prefix, + key, + type_name, + } + } + + pub fn prefix(&self) -> &str { + &self.prefix + } + + pub fn key(&self) -> &str { + &self.key + } + + pub fn type_name(&self) -> &'static str { + self.type_name + } + + /// Returns true if this key's prefix starts with the given string. + pub fn starts_with(&self, prefix: &str) -> bool { + self.prefix.starts_with(prefix) + } +} + +/// Low-level pluggable cache backend. +/// +/// Implementations store entries keyed by [`InternalCacheKey`] and return +/// type-erased [`CacheEntry`] values. +/// [`LanceCache`](super::LanceCache) handles key construction and type safety; +/// backend authors only need to implement storage and eviction. +#[async_trait] +pub trait CacheBackend: Send + Sync + std::fmt::Debug { + /// Look up an entry by its key. + async fn get(&self, key: &InternalCacheKey) -> Option; + + /// Store an entry. `size_bytes` is used for eviction accounting. + async fn insert(&self, key: &InternalCacheKey, entry: CacheEntry, size_bytes: usize); + + /// Get an existing entry or compute it from `loader`. + /// + /// Implementations should deduplicate concurrent loads for the same key + /// so the loader runs at most once. + /// + /// Returns `(entry, was_cached)` where `was_cached` is `true` if the entry + /// was already present in the cache (the loader was not invoked). + async fn get_or_insert<'a>( + &self, + key: &InternalCacheKey, + loader: Pin> + Send + 'a>>, + ) -> Result<(CacheEntry, bool)>; + + /// Remove all entries whose prefix starts with the given string. + async fn invalidate_prefix(&self, prefix: &str); + + /// Remove all entries. + async fn clear(&self); + + /// Number of entries currently stored (may flush pending operations). + async fn num_entries(&self) -> usize; + + /// Total weighted size in bytes of all stored entries (may flush pending operations). + async fn size_bytes(&self) -> usize; + + /// Approximate number of entries, callable from synchronous contexts. + /// Backends that cannot provide this cheaply should return 0. + fn approx_num_entries(&self) -> usize { + 0 + } + + /// Approximate weighted size in bytes, callable from synchronous contexts. + /// Used by `DeepSizeOf` to report cache memory usage. + /// Backends that cannot provide this cheaply should return 0. + /// + /// Assumes entries do not share underlying buffers; if they do, the + /// returned total may overcount. + fn approx_size_bytes(&self) -> usize { + 0 + } +} diff --git a/rust/lance-core/src/cache/mod.rs b/rust/lance-core/src/cache/mod.rs new file mode 100644 index 00000000000..43bb233df72 --- /dev/null +++ b/rust/lance-core/src/cache/mod.rs @@ -0,0 +1,811 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Lance cache system. +//! +//! ## For cache users +//! +//! Use [`LanceCache`] (or [`WeakLanceCache`]) to store and retrieve typed +//! values. Define a [`CacheKey`] (or [`UnsizedCacheKey`] for trait objects) to +//! describe what you're caching and its type. +//! +//! ## For backend implementors +//! +//! Implement [`CacheBackend`] to provide a custom storage layer (disk, Redis, +//! etc.). Backends receive [`InternalCacheKey`] keys and type-erased +//! [`CacheEntry`] values — the typed wrapping is handled by [`LanceCache`]. +//! See the [`backend`] module for details. + +pub mod backend; +mod moka; + +pub use backend::{CacheBackend, CacheEntry, InternalCacheKey}; +pub use moka::MokaCacheBackend; + +use std::borrow::Cow; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use futures::{Future, FutureExt}; + +use crate::Result; + +pub use deepsize::{Context, DeepSizeOf}; + +// --------------------------------------------------------------------------- +// CacheKey / UnsizedCacheKey — typed key traits for cache users +// --------------------------------------------------------------------------- + +/// Typed cache key for sized value types. +/// +/// Implement this trait to define a new type of cached entry. [`LanceCache`] +/// uses the key string and type name to construct an [`InternalCacheKey`] +/// for the backend. +/// +/// # Example +/// +/// ```ignore +/// struct MyKey { id: u64 } +/// +/// impl CacheKey for MyKey { +/// type ValueType = MyData; +/// fn key(&self) -> Cow<'_, str> { self.id.to_string().into() } +/// fn type_name() -> &'static str { "MyData" } +/// } +/// ``` +pub trait CacheKey { + type ValueType: 'static; + + fn key(&self) -> Cow<'_, str>; + + /// Short, stable string identifying this value type. + /// + /// Two `CacheKey` impls that store different `ValueType`s **must** return + /// different type names; if they collide, gets will silently return `None` + /// due to failed downcasts. + /// + /// Use a short literal (e.g. `"Vec"`), not + /// `std::any::type_name` — the latter is not guaranteed stable across + /// compiler versions or build configurations. + fn type_name() -> &'static str; +} + +/// Like [`CacheKey`] but for unsized value types (e.g. `dyn Trait`). +/// +/// The cache wraps values in an extra `Arc` layer internally; callers pass +/// and receive `Arc` where `T: ?Sized`. +pub trait UnsizedCacheKey { + type ValueType: 'static + ?Sized; + + fn key(&self) -> Cow<'_, str>; + + /// Short, stable string identifying this value type. + /// See [`CacheKey::type_name`] for requirements. + fn type_name() -> &'static str; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Size of a cached `Arc`, accounting for the Arc overhead (two atomic counters). +fn cache_entry_size(value: &T) -> usize { + value.deep_size_of() + std::mem::size_of::() * 2 +} + +/// Build an [`InternalCacheKey`] from a cache's prefix, a user key string, +/// and a type name. +fn build_key(prefix: &Arc, key: &str, type_name: &'static str) -> InternalCacheKey { + InternalCacheKey::new(prefix.clone(), Arc::from(key), type_name) +} + +// --------------------------------------------------------------------------- +// LanceCache — typed wrapper around dyn CacheBackend +// --------------------------------------------------------------------------- + +/// Typed cache wrapper that handles key construction and type safety. +/// +/// Internally delegates to a [`CacheBackend`]. The default backend is +/// [`MokaCacheBackend`]; pass a custom backend via [`LanceCache::with_backend`]. +#[derive(Clone)] +pub struct LanceCache { + cache: Arc, + prefix: Arc, + hits: Arc, + misses: Arc, +} + +impl std::fmt::Debug for LanceCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LanceCache") + .field("cache", &self.cache) + .finish() + } +} + +impl DeepSizeOf for LanceCache { + fn deep_size_of_children(&self, _: &mut Context) -> usize { + self.cache.approx_size_bytes() + } +} + +impl LanceCache { + pub fn with_capacity(capacity: usize) -> Self { + Self { + cache: Arc::new(MokaCacheBackend::with_capacity(capacity)), + prefix: Arc::from(""), + hits: Arc::new(AtomicU64::new(0)), + misses: Arc::new(AtomicU64::new(0)), + } + } + + /// Create a cache backed by a custom [`CacheBackend`]. + pub fn with_backend(backend: Arc) -> Self { + Self { + cache: backend, + prefix: Arc::from(""), + hits: Arc::new(AtomicU64::new(0)), + misses: Arc::new(AtomicU64::new(0)), + } + } + + pub fn no_cache() -> Self { + Self { + cache: Arc::new(MokaCacheBackend::no_cache()), + prefix: Arc::from(""), + hits: Arc::new(AtomicU64::new(0)), + misses: Arc::new(AtomicU64::new(0)), + } + } + + /// Create a cache with the given backend and an exact prefix string. + /// Unlike `with_key_prefix`, this sets the prefix verbatim (no trailing slash added). + pub fn with_backend_and_prefix(backend: Arc, prefix: String) -> Self { + Self { + cache: backend, + prefix: Arc::from(prefix), + hits: Arc::new(AtomicU64::new(0)), + misses: Arc::new(AtomicU64::new(0)), + } + } + + /// Appends a prefix to the cache key. + pub fn with_key_prefix(&self, prefix: &str) -> Self { + Self { + cache: self.cache.clone(), + prefix: Arc::from(format!("{}{}/", self.prefix, prefix)), + hits: self.hits.clone(), + misses: self.misses.clone(), + } + } + + /// Invalidate all entries whose prefix starts with the given string. + pub async fn invalidate_prefix(&self, prefix: &str) { + let full_prefix = format!("{}{}", self.prefix, prefix); + self.cache.invalidate_prefix(&full_prefix).await; + } + + pub async fn size(&self) -> usize { + self.cache.num_entries().await + } + + pub fn approx_size(&self) -> usize { + self.cache.approx_num_entries() + } + + pub async fn size_bytes(&self) -> usize { + self.cache.size_bytes().await + } + + // -- Sized insert/get (internal, shared by sized and unsized paths) -------- + + async fn insert_with_id( + &self, + key: &str, + type_name: &'static str, + metadata: Arc, + ) { + let size = cache_entry_size(&*metadata); + let cache_key = build_key(&self.prefix, key, type_name); + self.cache.insert(&cache_key, metadata, size).await; + } + + async fn get_with_id( + &self, + key: &str, + type_name: &'static str, + ) -> Option> { + let cache_key = build_key(&self.prefix, key, type_name); + if let Some(entry) = self.cache.get(&cache_key).await { + match entry.downcast::() { + Ok(val) => { + self.hits.fetch_add(1, Ordering::Relaxed); + Some(val) + } + Err(_) => { + // Type mismatch: the backend returned a different concrete + // type than expected (e.g. a disk cache may store + // intermediate state). Treat as a miss. + self.misses.fetch_add(1, Ordering::Relaxed); + None + } + } + } else { + self.misses.fetch_add(1, Ordering::Relaxed); + None + } + } + + // -- Stats / clear -------------------------------------------------------- + + pub async fn stats(&self) -> CacheStats { + CacheStats { + hits: self.hits.load(Ordering::Relaxed), + misses: self.misses.load(Ordering::Relaxed), + num_entries: self.cache.num_entries().await, + size_bytes: self.cache.size_bytes().await, + } + } + + pub async fn clear(&self) { + self.cache.clear().await; + self.hits.store(0, Ordering::Relaxed); + self.misses.store(0, Ordering::Relaxed); + } + + // -- CacheKey-based methods ----------------------------------------------- + + pub async fn insert_with_key(&self, cache_key: &K, metadata: Arc) + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + self.insert_with_id(&cache_key.key(), K::type_name(), metadata) + .boxed() + .await + } + + pub async fn get_with_key(&self, cache_key: &K) -> Option> + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + self.get_with_id::(&cache_key.key(), K::type_name()) + .boxed() + .await + } + + pub async fn get_or_insert_with_key( + &self, + cache_key: K, + loader: F, + ) -> Result> + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + F: FnOnce() -> Fut + Send, + Fut: Future> + Send, + { + let key = build_key(&self.prefix, &cache_key.key(), K::type_name()); + + let typed_loader = Box::pin(async move { + let value = loader().await?; + let arc = Arc::new(value); + let size = cache_entry_size(&*arc); + Ok((arc as CacheEntry, size)) + }); + + let (entry, was_cached) = self.cache.get_or_insert(&key, typed_loader).await?; + + if was_cached { + self.hits.fetch_add(1, Ordering::Relaxed); + } else { + self.misses.fetch_add(1, Ordering::Relaxed); + } + + Ok(entry.downcast::().unwrap()) + } + + pub async fn insert_unsized_with_key(&self, cache_key: &K, metadata: Arc) + where + K: UnsizedCacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + self.insert_with_id(&cache_key.key(), K::type_name(), Arc::new(metadata)) + .boxed() + .await + } + + pub async fn get_unsized_with_key(&self, cache_key: &K) -> Option> + where + K: UnsizedCacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + let outer = self + .get_with_id::>(&cache_key.key(), K::type_name()) + .boxed() + .await?; + Some(outer.as_ref().clone()) + } +} + +// --------------------------------------------------------------------------- +// WeakLanceCache +// --------------------------------------------------------------------------- + +/// A weak reference to a LanceCache, used by indices to avoid circular references. +/// When the original cache is dropped, operations on this will gracefully no-op. +#[derive(Clone, Debug)] +pub struct WeakLanceCache { + inner: std::sync::Weak, + prefix: Arc, + hits: Arc, + misses: Arc, +} + +impl WeakLanceCache { + pub fn from(cache: &LanceCache) -> Self { + Self { + inner: Arc::downgrade(&cache.cache), + prefix: cache.prefix.clone(), + hits: cache.hits.clone(), + misses: cache.misses.clone(), + } + } + + pub fn with_key_prefix(&self, prefix: &str) -> Self { + Self { + inner: self.inner.clone(), + prefix: Arc::from(format!("{}{}/", self.prefix, prefix)), + hits: self.hits.clone(), + misses: self.misses.clone(), + } + } + + /// The key prefix used for all entries in this cache. + pub fn prefix(&self) -> &str { + &self.prefix + } + + pub async fn get_with_key(&self, cache_key: &K) -> Option> + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + let cache = self.inner.upgrade()?; + let key = build_key(&self.prefix, &cache_key.key(), K::type_name()); + if let Some(entry) = cache.get(&key).await { + self.hits.fetch_add(1, Ordering::Relaxed); + Some(entry.downcast::().unwrap()) + } else { + self.misses.fetch_add(1, Ordering::Relaxed); + None + } + } + + pub async fn insert_with_key(&self, cache_key: &K, value: Arc) -> bool + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + if let Some(cache) = self.inner.upgrade() { + let size = cache_entry_size(&*value); + let key = build_key(&self.prefix, &cache_key.key(), K::type_name()); + cache.insert(&key, value, size).await; + true + } else { + log::warn!("WeakLanceCache: cache no longer available, unable to insert item"); + false + } + } + + /// Get or insert an item, computing it if necessary. + /// + /// Deduplication of concurrent loads is handled by the backend. + pub async fn get_or_insert_with_key( + &self, + cache_key: K, + loader: F, + ) -> Result> + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + F: FnOnce() -> Fut + Send, + Fut: Future> + Send, + { + if let Some(cache) = self.inner.upgrade() { + let key = build_key(&self.prefix, &cache_key.key(), K::type_name()); + let typed_loader = Box::pin(async move { + let value = loader().await?; + let arc = Arc::new(value); + let size = cache_entry_size(&*arc); + Ok((arc as CacheEntry, size)) + }); + let (entry, was_cached) = cache.get_or_insert(&key, typed_loader).await?; + if was_cached { + self.hits.fetch_add(1, Ordering::Relaxed); + } else { + self.misses.fetch_add(1, Ordering::Relaxed); + } + Ok(entry.downcast::().unwrap()) + } else { + log::warn!("WeakLanceCache: cache no longer available, computing without caching"); + loader().await.map(Arc::new) + } + } + + pub async fn get_unsized_with_key(&self, cache_key: &K) -> Option> + where + K: UnsizedCacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + let cache = self.inner.upgrade()?; + let key = build_key(&self.prefix, &cache_key.key(), K::type_name()); + if let Some(entry) = cache.get(&key).await { + entry + .downcast::>() + .ok() + .map(|arc| arc.as_ref().clone()) + } else { + None + } + } + + pub async fn insert_unsized_with_key(&self, cache_key: &K, value: Arc) + where + K: UnsizedCacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + if let Some(cache) = self.inner.upgrade() { + let wrapper = Arc::new(value); + let size = cache_entry_size(&*wrapper); + let key = build_key(&self.prefix, &cache_key.key(), K::type_name()); + cache.insert(&key, wrapper, size).await; + } else { + log::warn!("WeakLanceCache: cache no longer available, unable to insert unsized item"); + } + } +} + +// --------------------------------------------------------------------------- +// CacheStats +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct CacheStats { + /// Number of times `get`, `get_unsized`, or `get_or_insert` found an item in the cache. + pub hits: u64, + /// Number of times `get`, `get_unsized`, or `get_or_insert` did not find an item in the cache. + pub misses: u64, + /// Number of entries currently in the cache. + pub num_entries: usize, + /// Total size in bytes of all entries in the cache. + pub size_bytes: usize, +} + +impl CacheStats { + pub fn hit_ratio(&self) -> f32 { + if self.hits + self.misses == 0 { + 0.0 + } else { + self.hits as f32 / (self.hits + self.misses) as f32 + } + } + + pub fn miss_ratio(&self) -> f32 { + if self.hits + self.misses == 0 { + 0.0 + } else { + self.misses as f32 / (self.hits + self.misses) as f32 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::marker::PhantomData; + + struct TestKey { + key: String, + _phantom: PhantomData, + } + + impl TestKey { + fn new(key: &str) -> Self { + Self { + key: key.to_string(), + _phantom: PhantomData, + } + } + } + + impl CacheKey for TestKey { + type ValueType = T; + fn key(&self) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(&self.key) + } + fn type_name() -> &'static str { + std::any::type_name::() + } + } + + /// Test helper: an UnsizedCacheKey for trait object values. + struct TestUnsizedKey { + key: String, + _phantom: PhantomData, + } + + impl TestUnsizedKey { + fn new(key: &str) -> Self { + Self { + key: key.to_string(), + _phantom: PhantomData, + } + } + } + + impl UnsizedCacheKey for TestUnsizedKey { + type ValueType = T; + fn key(&self) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(&self.key) + } + fn type_name() -> &'static str { + std::any::type_name::() + } + } + + #[tokio::test] + async fn test_cache_bytes() { + let item = Arc::new(vec![1, 2, 3]); + let item_size = item.deep_size_of(); + let capacity = 10 * item_size; + let cache = LanceCache::with_capacity(capacity); + + cache + .insert_with_key(&TestKey::>::new("key"), item.clone()) + .await; + assert_eq!(cache.size().await, 1); + + let retrieved = cache + .get_with_key(&TestKey::>::new("key")) + .await + .unwrap(); + assert_eq!(*retrieved, *item); + + for i in 0..20 { + cache + .insert_with_key( + &TestKey::>::new(&format!("key_{}", i)), + Arc::new(vec![i, i, i]), + ) + .await; + } + assert!(cache.size_bytes().await <= capacity); + } + + #[tokio::test] + async fn test_cache_trait_objects() { + #[derive(Debug, DeepSizeOf)] + struct MyType(i32); + + trait MyTrait: DeepSizeOf + Send + Sync + std::any::Any { + fn as_any(&self) -> &dyn std::any::Any; + } + + impl MyTrait for MyType { + fn as_any(&self) -> &dyn std::any::Any { + self + } + } + + let item: Arc = Arc::new(MyType(42)); + let cache = LanceCache::with_capacity(1000); + cache + .insert_unsized_with_key(&TestUnsizedKey::::new("test"), item) + .await; + + let retrieved = cache + .get_unsized_with_key(&TestUnsizedKey::::new("test")) + .await + .unwrap(); + assert_eq!(retrieved.as_any().downcast_ref::().unwrap().0, 42); + } + + #[tokio::test] + async fn test_cache_stats_basic() { + let cache = LanceCache::with_capacity(1000); + assert_eq!(cache.stats().await.hits, 0); + + // Miss + assert!( + cache + .get_with_key(&TestKey::>::new("x")) + .await + .is_none() + ); + assert_eq!(cache.stats().await.misses, 1); + + // Insert then hit + cache + .insert_with_key(&TestKey::new("k"), Arc::new(vec![1, 2, 3])) + .await; + assert!( + cache + .get_with_key(&TestKey::>::new("k")) + .await + .is_some() + ); + assert_eq!(cache.stats().await.hits, 1); + } + + #[tokio::test] + async fn test_cache_stats_with_prefixes() { + let base = LanceCache::with_capacity(1000); + let prefixed = base.with_key_prefix("ns"); + + assert!( + prefixed + .get_with_key(&TestKey::>::new("k")) + .await + .is_none() + ); + assert_eq!(base.stats().await.misses, 1); + + prefixed + .insert_with_key(&TestKey::new("k"), Arc::new(vec![1])) + .await; + assert!( + prefixed + .get_with_key(&TestKey::>::new("k")) + .await + .is_some() + ); + assert_eq!(base.stats().await.hits, 1); + } + + #[tokio::test] + async fn test_cache_get_or_insert() { + let cache = LanceCache::with_capacity(1000); + + let v: Arc> = cache + .get_or_insert_with_key(TestKey::>::new("k"), || async { + Ok(vec![1, 2, 3]) + }) + .await + .unwrap(); + assert_eq!(*v, vec![1, 2, 3]); + assert_eq!(cache.stats().await.misses, 1); + assert_eq!(cache.stats().await.hits, 0); + + // Second call should not invoke loader and should be a hit + let v: Arc> = cache + .get_or_insert_with_key(TestKey::>::new("k"), || async { + panic!("should not be called") + }) + .await + .unwrap(); + assert_eq!(*v, vec![1, 2, 3]); + assert_eq!(cache.stats().await.hits, 1); + } + + #[tokio::test] + async fn test_custom_backend() { + use async_trait::async_trait; + use tokio::sync::Mutex; + + #[derive(Debug)] + struct HashMapBackend { + map: Mutex>, + } + + impl HashMapBackend { + fn new() -> Self { + Self { + map: Mutex::new(HashMap::new()), + } + } + } + + #[async_trait] + impl CacheBackend for HashMapBackend { + async fn get(&self, key: &InternalCacheKey) -> Option { + self.map.lock().await.get(key).map(|(e, _)| e.clone()) + } + async fn insert(&self, key: &InternalCacheKey, entry: CacheEntry, size_bytes: usize) { + self.map + .lock() + .await + .insert(key.clone(), (entry, size_bytes)); + } + async fn get_or_insert<'a>( + &self, + key: &InternalCacheKey, + loader: std::pin::Pin< + Box> + Send + 'a>, + >, + ) -> Result<(CacheEntry, bool)> { + if let Some((entry, _)) = self.map.lock().await.get(key) { + Ok((entry.clone(), true)) + } else { + let (entry, size) = loader.await?; + self.map + .lock() + .await + .insert(key.clone(), (entry.clone(), size)); + Ok((entry, false)) + } + } + async fn invalidate_prefix(&self, prefix: &str) { + self.map.lock().await.retain(|k, _| !k.starts_with(prefix)); + } + async fn clear(&self) { + self.map.lock().await.clear(); + } + async fn num_entries(&self) -> usize { + self.map.lock().await.len() + } + async fn size_bytes(&self) -> usize { + self.map.lock().await.values().map(|(_, s)| *s).sum() + } + } + + let cache = LanceCache::with_backend(Arc::new(HashMapBackend::new())); + + cache + .insert_with_key(&TestKey::new("k"), Arc::new(vec![1, 2, 3])) + .await; + assert!( + cache + .get_with_key(&TestKey::>::new("k")) + .await + .is_some() + ); + // Different type at same key = miss + assert!( + cache + .get_with_key(&TestKey::>::new("k")) + .await + .is_none() + ); + } + + #[tokio::test] + async fn test_get_or_insert_dedup() { + use std::sync::atomic::AtomicUsize; + + let load_count = Arc::new(AtomicUsize::new(0)); + let cache = LanceCache::with_capacity(10000); + + let (barrier_tx, _) = tokio::sync::broadcast::channel::<()>(1); + let mut handles = Vec::new(); + for _ in 0..5 { + let cache = cache.clone(); + let load_count = load_count.clone(); + let mut barrier_rx = barrier_tx.subscribe(); + handles.push(tokio::spawn(async move { + barrier_rx.recv().await.ok(); + cache + .get_or_insert_with_key(TestKey::>::new("key"), || { + let load_count = load_count.clone(); + async move { + load_count.fetch_add(1, Ordering::SeqCst); + tokio::task::yield_now().await; + Ok(vec![1, 2, 3]) + } + }) + .await + })); + } + barrier_tx.send(()).unwrap(); + for h in handles { + let result: Arc> = h.await.unwrap().unwrap(); + assert_eq!(*result, vec![1, 2, 3]); + } + + assert_eq!(load_count.load(Ordering::SeqCst), 1); + } +} diff --git a/rust/lance-core/src/cache/moka.rs b/rust/lance-core/src/cache/moka.rs new file mode 100644 index 00000000000..05cb1e5909f --- /dev/null +++ b/rust/lance-core/src/cache/moka.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use async_trait::async_trait; +use futures::Future; + +use crate::Result; + +use super::backend::{CacheBackend, CacheEntry, InternalCacheKey}; + +/// Internal record stored in the moka cache. +#[derive(Clone, Debug)] +struct MokaCacheEntry { + entry: CacheEntry, + size_bytes: usize, +} + +/// Default [`CacheBackend`] backed by a [moka](https://crates.io/crates/moka) cache. +/// +/// Provides weighted-capacity eviction and concurrent-load deduplication +/// via moka's built-in `optionally_get_with`. +pub struct MokaCacheBackend { + cache: moka::future::Cache, +} + +impl std::fmt::Debug for MokaCacheBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MokaCacheBackend") + .field("entry_count", &self.cache.entry_count()) + .finish() + } +} + +impl MokaCacheBackend { + pub fn with_capacity(capacity: usize) -> Self { + let cache = moka::future::Cache::builder() + .max_capacity(capacity as u64) + .weigher(|_, v: &MokaCacheEntry| v.size_bytes.try_into().unwrap_or(u32::MAX)) + .support_invalidation_closures() + .build(); + Self { cache } + } + + pub fn no_cache() -> Self { + Self { + cache: moka::future::Cache::new(0), + } + } +} + +#[async_trait] +impl CacheBackend for MokaCacheBackend { + async fn get(&self, key: &InternalCacheKey) -> Option { + self.cache.get(key).await.map(|r| r.entry) + } + + async fn insert(&self, key: &InternalCacheKey, entry: CacheEntry, size_bytes: usize) { + self.cache + .insert(key.clone(), MokaCacheEntry { entry, size_bytes }) + .await; + } + + async fn get_or_insert<'a>( + &self, + key: &InternalCacheKey, + loader: Pin> + Send + 'a>>, + ) -> Result<(CacheEntry, bool)> { + // Use moka's built-in dedup: optionally_get_with runs the init future + // at most once per key, even under concurrent access. + let (error_tx, error_rx) = tokio::sync::oneshot::channel(); + + // Track whether the loader actually ran (= cache miss). + let was_miss = Arc::new(AtomicBool::new(false)); + let was_miss_clone = was_miss.clone(); + + let init = async move { + was_miss_clone.store(true, Ordering::Relaxed); + match loader.await { + Ok((entry, size_bytes)) => Some(MokaCacheEntry { entry, size_bytes }), + Err(e) => { + let _ = error_tx.send(e); + None + } + } + }; + + let owned_key = key.clone(); + match self.cache.optionally_get_with(owned_key, init).await { + Some(record) => { + let was_cached = !was_miss.load(Ordering::Relaxed); + Ok((record.entry, was_cached)) + } + None => match error_rx.await { + Ok(err) => Err(err), + Err(_) => Err(crate::Error::internal( + "Failed to retrieve error from cache loader", + )), + }, + } + } + + async fn invalidate_prefix(&self, prefix: &str) { + let prefix = prefix.to_owned(); + self.cache + .invalidate_entries_if(move |key, _value| key.starts_with(&prefix)) + .expect("Cache configured correctly"); + } + + async fn clear(&self) { + self.cache.invalidate_all(); + self.cache.run_pending_tasks().await; + } + + async fn num_entries(&self) -> usize { + self.cache.run_pending_tasks().await; + self.cache.entry_count() as usize + } + + async fn size_bytes(&self) -> usize { + self.cache.run_pending_tasks().await; + self.cache.weighted_size() as usize + } + + fn approx_num_entries(&self) -> usize { + self.cache.entry_count() as usize + } + + fn approx_size_bytes(&self) -> usize { + // Iterate rather than using `weighted_size()` because moka's + // weighted_size can be stale without `run_pending_tasks()`, which + // is async and can't be called from this synchronous context. + self.cache.iter().map(|(_, v)| v.size_bytes).sum() + } +} diff --git a/rust/lance-encoding/src/encodings/logical/primitive.rs b/rust/lance-encoding/src/encodings/logical/primitive.rs index 0e3db7e9a54..f4f8b6e8627 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive.rs @@ -3416,6 +3416,10 @@ impl CacheKey for FieldDataCacheKey { fn key(&self) -> std::borrow::Cow<'_, str> { self.column_index.to_string().into() } + + fn type_name() -> &'static str { + "FieldData" + } } impl StructuralFieldScheduler for StructuralPrimitiveFieldScheduler { diff --git a/rust/lance-file/src/previous/reader.rs b/rust/lance-file/src/previous/reader.rs index 863aca1afc6..9e1fc175d04 100644 --- a/rust/lance-file/src/previous/reader.rs +++ b/rust/lance-file/src/previous/reader.rs @@ -83,12 +83,19 @@ impl<'a, T> StringCacheKey<'a, T> { } } -impl CacheKey for StringCacheKey<'_, T> { +impl CacheKey for StringCacheKey<'_, T> { type ValueType = T; fn key(&self) -> Cow<'_, str> { self.key.into() } + + fn type_name() -> &'static str { + // This is a private, crate-internal key that is only instantiated with + // a single concrete T within one build, so std::any::type_name is fine + // here — there is no cross-crate collision risk. + std::any::type_name::() + } } impl FileReader { @@ -238,7 +245,7 @@ impl FileReader { loader: F, ) -> Result> where - F: Fn(&str) -> Fut, + F: Fn(&str) -> Fut + Send + Sync, Fut: Future> + Send, { if let Some(cache) = cache { diff --git a/rust/lance-index/src/scalar/bitmap.rs b/rust/lance-index/src/scalar/bitmap.rs index 9a08acd4d67..ea68d4891a6 100644 --- a/rust/lance-index/src/scalar/bitmap.rs +++ b/rust/lance-index/src/scalar/bitmap.rs @@ -129,6 +129,10 @@ impl CacheKey for BitmapKey { fn key(&self) -> std::borrow::Cow<'_, str> { format!("{}", self.value.0).into() } + + fn type_name() -> &'static str { + "Bitmap" + } } impl BitmapIndex { diff --git a/rust/lance-index/src/scalar/btree.rs b/rust/lance-index/src/scalar/btree.rs index 17c180dbf4a..3fa05dfd131 100644 --- a/rust/lance-index/src/scalar/btree.rs +++ b/rust/lance-index/src/scalar/btree.rs @@ -990,6 +990,10 @@ impl CacheKey for BTreePageKey { fn key(&self) -> std::borrow::Cow<'_, str> { format!("page-{}", self.page_number).into() } + + fn type_name() -> &'static str { + "BTreePage" + } } /// Note: this is very similar to the IVF index except we store the IVF part in a btree diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index c07d26c74c5..783e7671dc0 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -1930,6 +1930,10 @@ impl CacheKey for PostingListKey { fn key(&self) -> std::borrow::Cow<'_, str> { format!("postings-{}", self.token_id).into() } + + fn type_name() -> &'static str { + "PostingList" + } } #[derive(Debug, Clone)] @@ -1943,6 +1947,10 @@ impl CacheKey for PositionKey { fn key(&self) -> std::borrow::Cow<'_, str> { format!("positions-{}", self.token_id).into() } + + fn type_name() -> &'static str { + "Position" + } } #[derive(Debug, Clone, PartialEq)] diff --git a/rust/lance-index/src/scalar/ngram.rs b/rust/lance-index/src/scalar/ngram.rs index 486efe6e034..4e614d99d99 100644 --- a/rust/lance-index/src/scalar/ngram.rs +++ b/rust/lance-index/src/scalar/ngram.rs @@ -170,6 +170,10 @@ impl CacheKey for NGramPostingListKey { fn key(&self) -> std::borrow::Cow<'_, str> { format!("posting-list-{}", self.row_offset).into() } + + fn type_name() -> &'static str { + "NGramPostingList" + } } impl NGramPostingList { diff --git a/rust/lance-index/src/scalar/rtree.rs b/rust/lance-index/src/scalar/rtree.rs index 9600c94823b..920a59bb4b2 100644 --- a/rust/lance-index/src/scalar/rtree.rs +++ b/rust/lance-index/src/scalar/rtree.rs @@ -249,6 +249,10 @@ impl CacheKey for RTreeCacheKey { Self::Nulls => "nulls".into(), } } + + fn type_name() -> &'static str { + "RTree" + } } #[derive(Clone)] diff --git a/rust/lance/src/dataset/builder.rs b/rust/lance/src/dataset/builder.rs index b1981d05a28..3671d1ebd1a 100644 --- a/rust/lance/src/dataset/builder.rs +++ b/rust/lance/src/dataset/builder.rs @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use std::{collections::HashMap, sync::Arc, time::Duration}; +use lance_core::cache::CacheBackend; + use super::refs::{Ref, Refs}; use super::{DEFAULT_INDEX_CACHE_SIZE, DEFAULT_METADATA_CACHE_SIZE, ReadParams, WriteParams}; use crate::dataset::branch_location::BranchLocation; @@ -37,6 +39,8 @@ pub struct DatasetBuilder { /// Metadata cache size for the fragment metadata. If it is zero, metadata /// cache is disabled. metadata_cache_size_bytes: usize, + /// Custom index cache backend. If set, overrides `index_cache_size_bytes`. + index_cache_backend: Option>, /// Optional pre-loaded manifest to avoid loading it again. manifest: Option, session: Option>, @@ -73,6 +77,7 @@ impl DatasetBuilder { Self { index_cache_size_bytes: DEFAULT_INDEX_CACHE_SIZE, metadata_cache_size_bytes: DEFAULT_METADATA_CACHE_SIZE, + index_cache_backend: None, table_uri: table_uri.as_ref().to_string(), options: ObjectStoreParams::default(), commit_handler: None, @@ -177,6 +182,15 @@ impl DatasetBuilder { self } + /// Use a custom index cache backend. + /// + /// When set, this overrides `with_index_cache_size_bytes` — the custom + /// backend is responsible for its own capacity management. + pub fn with_index_cache_backend(mut self, backend: Arc) -> Self { + self.index_cache_backend = Some(backend); + self + } + /// Set the cache size for indices. Set to zero, to disable the cache. #[deprecated(since = "0.30.0", note = "Use `with_index_cache_size_bytes` instead")] pub fn with_index_cache_size(mut self, cache_size: usize) -> Self { @@ -576,13 +590,21 @@ impl DatasetBuilder { } } + let index_cache_backend = self.index_cache_backend.take(); let session = match self.session.as_ref() { Some(session) => session.clone(), - None => Arc::new(Session::new( - self.index_cache_size_bytes, - self.metadata_cache_size_bytes, - Default::default(), - )), + None => match index_cache_backend { + Some(backend) => Arc::new(Session::with_index_cache_backend( + backend, + self.metadata_cache_size_bytes, + Default::default(), + )), + None => Arc::new(Session::new( + self.index_cache_size_bytes, + self.metadata_cache_size_bytes, + Default::default(), + )), + }, }; let target_ref = self.version.clone(); diff --git a/rust/lance/src/dataset/fragment.rs b/rust/lance/src/dataset/fragment.rs index 5be98a9b23d..986fe8a8443 100644 --- a/rust/lance/src/dataset/fragment.rs +++ b/rust/lance/src/dataset/fragment.rs @@ -1879,6 +1879,10 @@ impl CacheKey for FileMetadataCacheKey { fn key(&self) -> std::borrow::Cow<'_, str> { "".into() } + + fn type_name() -> &'static str { + "FileMetadata" + } } impl From for Fragment { diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index 6a88441029e..320d8a7501f 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -172,6 +172,10 @@ impl UnsizedCacheKey for ScalarIndexCacheKey<'_> { self.uuid.into() } } + + fn type_name() -> &'static str { + "ScalarIndex" + } } #[derive(Debug, Clone)] @@ -196,6 +200,10 @@ impl UnsizedCacheKey for VectorIndexCacheKey<'_> { self.uuid.into() } } + + fn type_name() -> &'static str { + "VectorIndex" + } } #[derive(Debug, Clone)] @@ -220,6 +228,10 @@ impl CacheKey for FragReuseIndexCacheKey<'_> { self.uuid.into() } } + + fn type_name() -> &'static str { + "FragReuseIndex" + } } #[derive(Debug, Clone)] @@ -244,6 +256,10 @@ impl CacheKey for MemWalCacheKey<'_> { self.uuid.to_string().into() } } + + fn type_name() -> &'static str { + "MemWalIndex" + } } // Whether to auto-migrate a dataset when we encounter corruption. diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index d58cbae2fcd..f741b9ae4a4 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -127,6 +127,10 @@ impl UnsizedCacheKey for LegacyIVFPartitionKey { fn key(&self) -> std::borrow::Cow<'_, str> { format!("ivf-{}", self.partition_id).into() } + + fn type_name() -> &'static str { + "LegacyIVFPartition" + } } /// IVF Index. diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 01577b11936..5cf8190136c 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -96,6 +96,12 @@ impl CacheKey for IVFPartit fn key(&self) -> std::borrow::Cow<'_, str> { format!("ivf-{}", self.partition_id).into() } + + fn type_name() -> &'static str { + // Using type_name is safe here: the impl is in the same crate as the + // types, so the monomorphized pointer is consistent. + std::any::type_name::>() + } } /// IVF Index. diff --git a/rust/lance/src/lib.rs b/rust/lance/src/lib.rs index 934be0e519c..b9c8c7a4f5e 100644 --- a/rust/lance/src/lib.rs +++ b/rust/lance/src/lib.rs @@ -72,7 +72,7 @@ use arrow_schema::DataType; use dataset::builder::DatasetBuilder; pub use lance_core::datatypes; -pub use lance_core::{Error, Result}; +pub use lance_core::{Error, Result, cache}; use std::sync::LazyLock; pub mod arrow; diff --git a/rust/lance/src/session.rs b/rust/lance/src/session.rs index c67345fba32..b032cbaa15e 100644 --- a/rust/lance/src/session.rs +++ b/rust/lance/src/session.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::sync::Arc; use deepsize::DeepSizeOf; -use lance_core::cache::LanceCache; +use lance_core::cache::{CacheBackend, LanceCache}; use lance_core::{Error, Result}; use lance_index::IndexType; use lance_io::object_store::ObjectStoreRegistry; @@ -17,7 +17,7 @@ use crate::session::index_caches::GlobalIndexCache; use self::index_extension::IndexExtension; pub(crate) mod caches; -pub(crate) mod index_caches; +pub mod index_caches; pub(crate) mod index_extension; /// A user session holds the runtime state for a [`crate::Dataset`] @@ -77,11 +77,7 @@ impl std::fmt::Debug for Session { ) .field( "file_metadata_cache", - &format!( - "LanceCache(items={}, size_bytes={})", - self.metadata_cache.0.approx_size(), - self.metadata_cache.0.approx_size_bytes(), - ), + &format!("LanceCache(items={})", self.metadata_cache.0.approx_size(),), ) .field( "index_extensions", @@ -114,6 +110,23 @@ impl Session { } } + /// Create a session with a custom index cache backend. + /// + /// The provided backend will be used for caching index data. The metadata + /// cache will use the default Moka-based backend with the given capacity. + pub fn with_index_cache_backend( + index_cache_backend: Arc, + metadata_cache_size: usize, + store_registry: Arc, + ) -> Self { + Self { + index_cache: GlobalIndexCache(LanceCache::with_backend(index_cache_backend)), + metadata_cache: GlobalMetadataCache(LanceCache::with_capacity(metadata_cache_size)), + index_extensions: HashMap::new(), + store_registry, + } + } + /// Register a new index extension. /// /// A name can only be registered once per type of index extension. @@ -182,6 +195,11 @@ impl Session { self.store_registry.clone() } + /// Get a reference to the raw metadata cache (for use in index reconstruction). + pub fn file_metadata_cache(&self) -> &LanceCache { + &self.metadata_cache.0 + } + /// Fetch statistics for the metadata cache pub async fn metadata_cache_stats(&self) -> lance_core::cache::CacheStats { self.metadata_cache.0.stats().await @@ -206,7 +224,21 @@ impl Default for Session { #[cfg(test)] mod tests { use super::*; + use lance_core::cache::UnsizedCacheKey; use lance_index::vector::VectorIndex; + use std::borrow::Cow; + + struct TestUnsizedKey(&'static str); + impl UnsizedCacheKey for TestUnsizedKey { + type ValueType = dyn VectorIndex; + fn key(&self) -> Cow<'_, str> { + Cow::Borrowed(self.0) + } + + fn type_name() -> &'static str { + "TestUnsized" + } + } #[tokio::test] async fn test_disable_index_cache() { @@ -214,7 +246,7 @@ mod tests { assert!( no_cache .index_cache - .get_unsized::("abc") + .get_unsized_with_key(&TestUnsizedKey("abc")) .await .is_none() ); diff --git a/rust/lance/src/session/caches.rs b/rust/lance/src/session/caches.rs index 67c684c98de..55f78a5068f 100644 --- a/rust/lance/src/session/caches.rs +++ b/rust/lance/src/session/caches.rs @@ -75,7 +75,6 @@ pub struct ManifestKey<'a> { impl CacheKey for ManifestKey<'_> { type ValueType = Manifest; - fn key(&self) -> Cow<'_, str> { if let Some(e_tag) = self.e_tag { Cow::Owned(format!("manifest/{}/{}", self.version, e_tag)) @@ -83,6 +82,9 @@ impl CacheKey for ManifestKey<'_> { Cow::Owned(format!("manifest/{}", self.version)) } } + fn type_name() -> &'static str { + "Manifest" + } } #[derive(Debug)] @@ -92,10 +94,12 @@ pub struct TransactionKey { impl CacheKey for TransactionKey { type ValueType = Transaction; - fn key(&self) -> Cow<'_, str> { Cow::Owned(format!("txn/{}", self.version)) } + fn type_name() -> &'static str { + "Transaction" + } } #[derive(Debug)] @@ -106,7 +110,6 @@ pub struct DeletionFileKey<'a> { impl CacheKey for DeletionFileKey<'_> { type ValueType = DeletionVector; - fn key(&self) -> Cow<'_, str> { Cow::Owned(format!( "deletion/{}/{}/{}/{}", @@ -116,6 +119,9 @@ impl CacheKey for DeletionFileKey<'_> { self.deletion_file.file_type.suffix() )) } + fn type_name() -> &'static str { + "DeletionVector" + } } #[derive(Debug)] @@ -125,10 +131,12 @@ pub struct RowAddrMaskKey { impl CacheKey for RowAddrMaskKey { type ValueType = RowAddrMask; - fn key(&self) -> Cow<'_, str> { Cow::Owned(format!("row_addr_mask/{}", self.version)) } + fn type_name() -> &'static str { + "RowAddrMask" + } } #[derive(Debug)] @@ -138,10 +146,12 @@ pub struct RowIdIndexKey { impl CacheKey for RowIdIndexKey { type ValueType = RowIdIndex; - fn key(&self) -> Cow<'_, str> { Cow::Owned(format!("row_id_index/{}", self.version)) } + fn type_name() -> &'static str { + "RowIdIndex" + } } #[derive(Debug)] @@ -151,10 +161,12 @@ pub struct RowIdSequenceKey { impl CacheKey for RowIdSequenceKey { type ValueType = RowIdSequence; - fn key(&self) -> Cow<'_, str> { Cow::Owned(format!("row_id_sequence/{}", self.fragment_id)) } + fn type_name() -> &'static str { + "RowIdSequence" + } } impl DSMetadataCache { diff --git a/rust/lance/src/session/index_caches.rs b/rust/lance/src/session/index_caches.rs index d9578d43112..43443b5dd34 100644 --- a/rust/lance/src/session/index_caches.rs +++ b/rust/lance/src/session/index_caches.rs @@ -88,6 +88,10 @@ impl CacheKey for FragReuseIndexKey<'_> { fn key(&self) -> Cow<'_, str> { Cow::Owned(format!("frag_reuse/{}", self.uuid)) } + + fn type_name() -> &'static str { + "FragReuseIndex" + } } #[derive(Debug)] @@ -101,6 +105,10 @@ impl CacheKey for IndexMetadataKey { fn key(&self) -> Cow<'_, str> { Cow::Owned(self.version.to_string()) } + + fn type_name() -> &'static str { + "Vec" + } } pub struct ProstAny(pub Arc); @@ -128,4 +136,8 @@ impl CacheKey for ScalarIndexDetailsKey<'_> { fn key(&self) -> Cow<'_, str> { Cow::Owned(format!("type/{}", self.uuid)) } + + fn type_name() -> &'static str { + "ScalarIndexDetails" + } } From 277db864d2f5ff7defca4c9096b021486e99ba39 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Mon, 30 Mar 2026 15:29:59 +0800 Subject: [PATCH 098/206] chore: cleanup dead functions and dead_code annotations (#6314) This PR removes dead functions and narrows a few test-only helpers so the workspace no longer depends on stale `dead_code` suppressions in live paths. It intentionally avoids field-level cleanup and keeps the scope limited to function-oriented removals and warning fixes after re-review. --- rust/lance-arrow/src/deepcopy.rs | 2 +- rust/lance-core/src/datatypes/schema.rs | 1 - rust/lance-core/src/utils/bit.rs | 2 +- rust/lance-core/src/utils/deletion.rs | 1 - rust/lance-datafusion/src/logical_expr.rs | 2 +- rust/lance-encoding/benches/decoder.rs | 2 +- .../src/encodings/logical/struct.rs | 1 - .../src/encodings/physical/binary.rs | 2 +- .../src/encodings/physical/value.rs | 2 +- rust/lance-encoding/src/previous/encoder.rs | 2 +- .../src/previous/encodings/logical/blob.rs | 2 +- .../previous/encodings/physical/dictionary.rs | 2 +- .../encodings/physical/packed_struct.rs | 2 +- rust/lance-file/src/reader.rs | 2 +- rust/lance-index/benches/find_partitions.rs | 2 - rust/lance-index/benches/sq.rs | 1 - rust/lance-index/src/frag_reuse.rs | 2 +- rust/lance-index/src/scalar/bitmap.rs | 2 +- .../src/scalar/bloomfilter/sbbf.rs | 5 -- rust/lance-index/src/scalar/lance_format.rs | 2 +- rust/lance-index/src/vector/pq/distance.rs | 6 +- rust/lance-index/src/vector/quantizer.rs | 1 - rust/lance-index/src/vector/utils.rs | 50 +++----------- rust/lance-io/src/utils/tracking_store.rs | 6 +- rust/lance-linalg/src/distance/cosine.rs | 1 - rust/lance-namespace-impls/src/credentials.rs | 14 +++- rust/lance-namespace-impls/src/dir.rs | 1 - .../lance-namespace-impls/src/dir/manifest.rs | 3 - rust/lance-namespace-impls/src/rest.rs | 40 ----------- rust/lance/src/datafusion/dataframe.rs | 2 +- .../mem_wal/memtable/scanner/exec/fts.rs | 67 ------------------- rust/lance/src/dataset/udtf.rs | 2 +- rust/lance/src/dataset/write.rs | 10 ++- rust/lance/src/index/vector/ivf/v2.rs | 2 - rust/lance/src/io/commit.rs | 2 +- rust/lance/src/io/exec/fts.rs | 2 +- rust/lance/src/utils/future.rs | 11 --- rust/lance/src/utils/test.rs | 1 - 38 files changed, 48 insertions(+), 212 deletions(-) diff --git a/rust/lance-arrow/src/deepcopy.rs b/rust/lance-arrow/src/deepcopy.rs index a82a64aa872..b747b24d466 100644 --- a/rust/lance-arrow/src/deepcopy.rs +++ b/rust/lance-arrow/src/deepcopy.rs @@ -94,7 +94,7 @@ pub fn deep_copy_batch_sliced(batch: &RecordBatch) -> crate::Result } #[cfg(test)] -pub mod tests { +mod tests { use std::sync::Arc; use arrow_array::{Array, Int32Array, RecordBatch, StringArray}; diff --git a/rust/lance-core/src/datatypes/schema.rs b/rust/lance-core/src/datatypes/schema.rs index ab75149b8da..9f73b153982 100644 --- a/rust/lance-core/src/datatypes/schema.rs +++ b/rust/lance-core/src/datatypes/schema.rs @@ -85,7 +85,6 @@ struct SchemaFieldIterPreOrder<'a> { } impl<'a> SchemaFieldIterPreOrder<'a> { - #[allow(dead_code)] fn new(schema: &'a Schema) -> Self { let mut field_stack = Vec::with_capacity(schema.fields.len() * 2); for field in schema.fields.iter().rev() { diff --git a/rust/lance-core/src/utils/bit.rs b/rust/lance-core/src/utils/bit.rs index ba4b882691d..d0c9eaf5a83 100644 --- a/rust/lance-core/src/utils/bit.rs +++ b/rust/lance-core/src/utils/bit.rs @@ -110,7 +110,7 @@ pub fn log_2_ceil(val: u32) -> u32 { } #[cfg(test)] -pub mod tests { +mod tests { use crate::utils::bit::{is_pwr_two, log_2_ceil, pad_bytes, pad_bytes_to, pad_bytes_u64}; #[test] diff --git a/rust/lance-core/src/utils/deletion.rs b/rust/lance-core/src/utils/deletion.rs index 0164d11fce6..5ddfc3348e5 100644 --- a/rust/lance-core/src/utils/deletion.rs +++ b/rust/lance-core/src/utils/deletion.rs @@ -32,7 +32,6 @@ impl DeepSizeOf for DeletionVector { } impl DeletionVector { - #[allow(dead_code)] // Used in tests pub fn len(&self) -> usize { match self { Self::NoDeletions => 0, diff --git a/rust/lance-datafusion/src/logical_expr.rs b/rust/lance-datafusion/src/logical_expr.rs index 02775f1bbbf..3fde6cf7c82 100644 --- a/rust/lance-datafusion/src/logical_expr.rs +++ b/rust/lance-datafusion/src/logical_expr.rs @@ -295,7 +295,7 @@ pub fn field_path_to_expr(field_path: &str) -> Result { } #[cfg(test)] -pub mod tests { +mod tests { use std::sync::Arc; use super::*; diff --git a/rust/lance-encoding/benches/decoder.rs b/rust/lance-encoding/benches/decoder.rs index 939087d8a62..0a444eaa9da 100644 --- a/rust/lance-encoding/benches/decoder.rs +++ b/rust/lance-encoding/benches/decoder.rs @@ -300,7 +300,7 @@ fn bench_decode_packed_struct(c: &mut Criterion) { }); } -#[allow(dead_code)] +#[cfg(target_os = "linux")] fn bench_decode_str_with_fixed_size_binary_encoding(c: &mut Criterion) { let rt = tokio::runtime::Runtime::new().unwrap(); let mut group = c.benchmark_group("decode_primitive"); diff --git a/rust/lance-encoding/src/encodings/logical/struct.rs b/rust/lance-encoding/src/encodings/logical/struct.rs index 2682090b317..9199d1dcb19 100644 --- a/rust/lance-encoding/src/encodings/logical/struct.rs +++ b/rust/lance-encoding/src/encodings/logical/struct.rs @@ -493,7 +493,6 @@ pub struct StructFieldEncoder { } impl StructFieldEncoder { - #[allow(dead_code)] pub fn new(children: Vec>, column_index: u32) -> Self { Self { children, diff --git a/rust/lance-encoding/src/encodings/physical/binary.rs b/rust/lance-encoding/src/encodings/physical/binary.rs index fba56420bae..7f52dd816c8 100644 --- a/rust/lance-encoding/src/encodings/physical/binary.rs +++ b/rust/lance-encoding/src/encodings/physical/binary.rs @@ -525,7 +525,7 @@ impl BlockDecompressor for BinaryBlockDecompressor { } #[cfg(test)] -pub mod tests { +mod tests { use arrow_array::{ ArrayRef, StringArray, builder::{LargeStringBuilder, StringBuilder}, diff --git a/rust/lance-encoding/src/encodings/physical/value.rs b/rust/lance-encoding/src/encodings/physical/value.rs index 48f1b01886c..ced3886a13d 100644 --- a/rust/lance-encoding/src/encodings/physical/value.rs +++ b/rust/lance-encoding/src/encodings/physical/value.rs @@ -749,7 +749,7 @@ impl PerValueCompressor for ValueEncoder { // public tests module because we share the PRIMITIVE_TYPES constant with fixed_size_list #[cfg(test)] -pub(crate) mod tests { +mod tests { use std::{ collections::HashMap, sync::{Arc, LazyLock}, diff --git a/rust/lance-encoding/src/previous/encoder.rs b/rust/lance-encoding/src/previous/encoder.rs index 9188c8ca4be..9c314ae97e9 100644 --- a/rust/lance-encoding/src/previous/encoder.rs +++ b/rust/lance-encoding/src/previous/encoder.rs @@ -639,7 +639,7 @@ impl ArrayEncodingStrategy for CoreArrayEncodingStrategy { } #[cfg(test)] -pub mod tests { +mod tests { use crate::constants::{COMPRESSION_LEVEL_META_KEY, COMPRESSION_META_KEY}; use crate::previous::encoder::{ ArrayEncodingStrategy, CoreArrayEncodingStrategy, check_dict_encoding, diff --git a/rust/lance-encoding/src/previous/encodings/logical/blob.rs b/rust/lance-encoding/src/previous/encodings/logical/blob.rs index 460b6e2bbea..ce4bdb5ae21 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/blob.rs +++ b/rust/lance-encoding/src/previous/encodings/logical/blob.rs @@ -386,7 +386,7 @@ impl FieldEncoder for BlobFieldEncoder { } #[cfg(test)] -pub mod tests { +mod tests { use std::{ collections::HashMap, sync::{Arc, LazyLock}, diff --git a/rust/lance-encoding/src/previous/encodings/physical/dictionary.rs b/rust/lance-encoding/src/previous/encodings/physical/dictionary.rs index 759ea5a4429..77ba0300237 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/dictionary.rs +++ b/rust/lance-encoding/src/previous/encodings/physical/dictionary.rs @@ -403,7 +403,7 @@ impl ArrayEncoder for DictionaryEncoder { } #[cfg(test)] -pub mod tests { +mod tests { use arrow_array::{ ArrayRef, DictionaryArray, StringArray, UInt8Array, diff --git a/rust/lance-encoding/src/previous/encodings/physical/packed_struct.rs b/rust/lance-encoding/src/previous/encodings/physical/packed_struct.rs index 9b151afebd7..0f3a5fc3832 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/packed_struct.rs +++ b/rust/lance-encoding/src/previous/encodings/physical/packed_struct.rs @@ -256,7 +256,7 @@ impl ArrayEncoder for PackedStructEncoder { } #[cfg(test)] -pub mod tests { +mod tests { use arrow_array::{ArrayRef, Int32Array, StructArray, UInt8Array, UInt64Array}; use arrow_schema::{DataType, Field, Fields}; diff --git a/rust/lance-file/src/reader.rs b/rust/lance-file/src/reader.rs index 29c1aa3ccc0..36a4dad06ff 100644 --- a/rust/lance-file/src/reader.rs +++ b/rust/lance-file/src/reader.rs @@ -1532,7 +1532,7 @@ impl EncodedBatchReaderExt for EncodedBatch { } #[cfg(test)] -pub mod tests { +mod tests { use std::{collections::BTreeMap, pin::Pin, sync::Arc}; use arrow_array::{ diff --git a/rust/lance-index/benches/find_partitions.rs b/rust/lance-index/benches/find_partitions.rs index eb36f59f3f1..bade4e3bfee 100644 --- a/rust/lance-index/benches/find_partitions.rs +++ b/rust/lance-index/benches/find_partitions.rs @@ -1,8 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -mod sq; - use arrow_array::Float32Array; use arrow_array::{FixedSizeListArray, types::Float32Type}; use lance_arrow::FixedSizeListArrayExt; diff --git a/rust/lance-index/benches/sq.rs b/rust/lance-index/benches/sq.rs index 2e0828e2117..84b5a304723 100644 --- a/rust/lance-index/benches/sq.rs +++ b/rust/lance-index/benches/sq.rs @@ -65,7 +65,6 @@ fn create_sq_batch(row_id_range: Range, dim: usize) -> RecordBatch { batch.drop_column("vector").unwrap() } -#[allow(dead_code)] pub fn bench_storage(c: &mut Criterion) { let mut rng = rand::rng(); diff --git a/rust/lance-index/src/frag_reuse.rs b/rust/lance-index/src/frag_reuse.rs index e122540f653..490d21a7132 100644 --- a/rust/lance-index/src/frag_reuse.rs +++ b/rust/lance-index/src/frag_reuse.rs @@ -390,7 +390,7 @@ impl Index for FragReuseIndex { } #[cfg(test)] -pub mod tests { +mod tests { use super::*; diff --git a/rust/lance-index/src/scalar/bitmap.rs b/rust/lance-index/src/scalar/bitmap.rs index ea68d4891a6..c5a71ed6305 100644 --- a/rust/lance-index/src/scalar/bitmap.rs +++ b/rust/lance-index/src/scalar/bitmap.rs @@ -888,7 +888,7 @@ impl ScalarIndexPlugin for BitmapIndexPlugin { } #[cfg(test)] -pub mod tests { +mod tests { use super::*; use crate::metrics::NoOpMetricsCollector; use crate::scalar::lance_format::LanceIndexStore; diff --git a/rust/lance-index/src/scalar/bloomfilter/sbbf.rs b/rust/lance-index/src/scalar/bloomfilter/sbbf.rs index 50574768050..cbb4eb76b12 100644 --- a/rust/lance-index/src/scalar/bloomfilter/sbbf.rs +++ b/rust/lance-index/src/scalar/bloomfilter/sbbf.rs @@ -316,7 +316,6 @@ impl Sbbf { } /// Write the bitset in serialized form to the writer - #[allow(dead_code)] pub fn write_bitset(&self, mut writer: W) -> Result<()> { for block in &self.blocks { writer @@ -336,19 +335,16 @@ impl Sbbf { } /// Get the number of blocks in this filter - #[allow(dead_code)] pub fn num_blocks(&self) -> usize { self.blocks.len() } /// Get the size in bytes of this filter - #[allow(dead_code)] pub fn size_bytes(&self) -> usize { self.blocks.len() * 32 } /// Return the total in memory size of this bloom filter in bytes - #[allow(dead_code)] pub fn estimated_memory_size(&self) -> usize { self.blocks.capacity() * std::mem::size_of::() } @@ -456,7 +452,6 @@ impl SbbfBuilder { } /// Set the number of bytes directly - #[allow(dead_code)] pub fn log2_num_bytes(mut self, log2_num_bytes: u8) -> Self { self.log2_num_bytes = Some(log2_num_bytes); self diff --git a/rust/lance-index/src/scalar/lance_format.rs b/rust/lance-index/src/scalar/lance_format.rs index 9839ea5400e..76f904a1a64 100644 --- a/rust/lance-index/src/scalar/lance_format.rs +++ b/rust/lance-index/src/scalar/lance_format.rs @@ -354,7 +354,7 @@ impl IndexStore for LanceIndexStore { } #[cfg(test)] -pub mod tests { +mod tests { use std::{collections::HashMap, ops::Bound}; diff --git a/rust/lance-index/src/vector/pq/distance.rs b/rust/lance-index/src/vector/pq/distance.rs index 0a3f2ac7b8c..b341ba98af7 100644 --- a/rust/lance-index/src/vector/pq/distance.rs +++ b/rust/lance-index/src/vector/pq/distance.rs @@ -4,7 +4,7 @@ use core::panic; use std::cmp::{max, min}; -use super::{num_centroids, utils::get_sub_vector_centroids}; +use super::utils::get_sub_vector_centroids; use lance_core::assume_eq; use lance_linalg::distance::{Dot, L2, dot_distance_batch, l2::L2Prepared, l2_distance_batch}; use lance_linalg::simd::u8::u8x16; @@ -306,14 +306,14 @@ fn quantize_distance_table(distance_table: &[f32], qmax: f32) -> (f32, Vec) /// - C: the tile size of code-book to run at once. /// - V: the tile size of PQ code to run at once. /// -#[allow(dead_code)] +#[cfg(test)] fn compute_l2_distance_without_transposing( distance_table: &[f32], num_bits: u32, num_sub_vectors: usize, code: &[u8], ) -> Vec { - let num_centroids = num_centroids(num_bits); + let num_centroids = super::num_centroids(num_bits); let iter = code.chunks_exact(num_sub_vectors * V); let distances = iter.clone().flat_map(|c| { let mut sums = [0.0_f32; V]; diff --git a/rust/lance-index/src/vector/quantizer.rs b/rust/lance-index/src/vector/quantizer.rs index 887269b19c9..65e83ecce13 100644 --- a/rust/lance-index/src/vector/quantizer.rs +++ b/rust/lance-index/src/vector/quantizer.rs @@ -313,7 +313,6 @@ impl Clone for IvfQuantizationStorage { } } -#[allow(dead_code)] impl IvfQuantizationStorage { /// Open a Loader. /// diff --git a/rust/lance-index/src/vector/utils.rs b/rust/lance-index/src/vector/utils.rs index 0f2cae636f4..af2e5e4e819 100644 --- a/rust/lance-index/src/vector/utils.rs +++ b/rust/lance-index/src/vector/utils.rs @@ -1,12 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use arrow::{ - array::AsArray, - compute::cast, - datatypes::{Float16Type, Float32Type, Float64Type}, -}; -use arrow_array::{Array, ArrayRef, BooleanArray, FixedSizeListArray}; +use arrow::compute::cast; +use arrow_array::types::{Float16Type, Float32Type, Float64Type}; +use arrow_array::{Array, ArrayRef, BooleanArray, FixedSizeListArray, cast::AsArray}; use arrow_schema::{DataType, Field}; use lance_arrow::FixedSizeListArrayExt; use lance_core::{Error, Result}; @@ -108,37 +105,6 @@ impl SimpleIndex { } } -#[inline] -#[allow(dead_code)] -pub(crate) fn prefetch_arrow_array(array: &dyn Array) -> Result<()> { - match array.data_type() { - DataType::FixedSizeList(_, _) => { - let array = array.as_fixed_size_list(); - return prefetch_arrow_array(array.values()); - } - DataType::Float16 => { - let array = array.as_primitive::(); - do_prefetch(array.values().as_ptr_range()) - } - DataType::Float32 => { - let array = array.as_primitive::(); - do_prefetch(array.values().as_ptr_range()) - } - DataType::Float64 => { - let array = array.as_primitive::(); - do_prefetch(array.values().as_ptr_range()) - } - _ => { - return Err(Error::invalid_input(format!( - "Unsupported data type for prefetch: {}", - array.data_type() - ))); - } - } - - Ok(()) -} - #[inline] pub(crate) fn do_prefetch(ptrs: Range<*const T>) { // TODO use rust intrinsics instead of x86 intrinsics @@ -207,7 +173,7 @@ impl TryFrom<&FixedSizeListArray> for pb::Tensor { fn try_from(array: &FixedSizeListArray) -> Result { let mut tensor = Self::default(); tensor.data_type = pb::tensor::DataType::try_from(array.value_type())? as i32; - tensor.shape = vec![array.len() as u32, array.value_length() as u32]; + tensor.shape = vec![Array::len(array) as u32, array.value_length() as u32]; let flat_array = array.values(); tensor.data = flat_array.into_data().buffers()[0].to_vec(); Ok(tensor) @@ -265,17 +231,17 @@ pub fn is_finite(fsl: &FixedSizeListArray) -> BooleanArray { Some(v) => match v.data_type() { DataType::Float16 => { let v = v.as_primitive::(); - v.null_count() == 0 && v.values().iter().all(|v| v.is_finite()) + Array::null_count(v) == 0 && v.values().iter().all(|v| v.is_finite()) } DataType::Float32 => { let v = v.as_primitive::(); - v.null_count() == 0 && v.values().iter().all(|v| v.is_finite()) + Array::null_count(v) == 0 && v.values().iter().all(|v| v.is_finite()) } DataType::Float64 => { let v = v.as_primitive::(); - v.null_count() == 0 && v.values().iter().all(|v| v.is_finite()) + Array::null_count(v) == 0 && v.values().iter().all(|v| v.is_finite()) } - _ => v.null_count() == 0, + _ => Array::null_count(&v) == 0, }, None => false, }) diff --git a/rust/lance-io/src/utils/tracking_store.rs b/rust/lance-io/src/utils/tracking_store.rs index dd8474b5683..706aba34615 100644 --- a/rust/lance-io/src/utils/tracking_store.rs +++ b/rust/lance-io/src/utils/tracking_store.rs @@ -190,9 +190,8 @@ macro_rules! assert_io_lt { }; } -// These fields are "dead code" because we just use them right now to display -// in test failure messages through Debug. (The lint ignores Debug impls.) -#[allow(dead_code)] +// These request records only exist for test-only diagnostics. +#[cfg(feature = "test-util")] #[derive(Clone)] pub struct IoRequestRecord { pub method: &'static str, @@ -200,6 +199,7 @@ pub struct IoRequestRecord { pub range: Option>, } +#[cfg(feature = "test-util")] impl std::fmt::Debug for IoRequestRecord { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { // For example: "put /path/to/file range: 0-100" diff --git a/rust/lance-linalg/src/distance/cosine.rs b/rust/lance-linalg/src/distance/cosine.rs index 89a86208793..a010b80baed 100644 --- a/rust/lance-linalg/src/distance/cosine.rs +++ b/rust/lance-linalg/src/distance/cosine.rs @@ -228,7 +228,6 @@ impl Cosine for f32 { impl Cosine for f64 {} /// Fallback non-SIMD implementation -#[allow(dead_code)] // Does not fallback on aarch64. #[inline] fn cosine_scalar(x: &[T], x_norm: f32, y: &[T]) -> f32 { let y_sq = dot(y, y); diff --git a/rust/lance-namespace-impls/src/credentials.rs b/rust/lance-namespace-impls/src/credentials.rs index 15881bfa620..a66489ac441 100644 --- a/rust/lance-namespace-impls/src/credentials.rs +++ b/rust/lance-namespace-impls/src/credentials.rs @@ -440,7 +440,12 @@ pub async fn create_credential_vendor_for_location( } /// Parse permission from properties, defaulting to Read -#[allow(dead_code)] +#[cfg(any( + test, + feature = "credential-vendor-aws", + feature = "credential-vendor-azure", + feature = "credential-vendor-gcp" +))] fn parse_permission(properties: &HashMap) -> VendedPermission { properties .get(PERMISSION) @@ -449,7 +454,12 @@ fn parse_permission(properties: &HashMap) -> VendedPermission { } /// Parse duration from properties using a vendor-specific key, defaulting to DEFAULT_CREDENTIAL_DURATION_MILLIS -#[allow(dead_code)] +#[cfg(any( + test, + feature = "credential-vendor-aws", + feature = "credential-vendor-azure", + feature = "credential-vendor-gcp" +))] fn parse_duration_millis(properties: &HashMap, key: &str) -> u64 { properties .get(key) diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index da07c12dfbd..c0ee3f9a713 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -651,7 +651,6 @@ impl DirectoryNamespaceBuilder { pub struct DirectoryNamespace { root: String, storage_options: Option>, - #[allow(dead_code)] session: Option>, object_store: Arc, base_path: Path, diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index 2a9dced2e1c..ab850a7e731 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -254,11 +254,8 @@ impl DerefMut for DatasetWriteGuard<'_> { pub struct ManifestNamespace { root: String, storage_options: Option>, - #[allow(dead_code)] session: Option>, - #[allow(dead_code)] object_store: Arc, - #[allow(dead_code)] base_path: Path, manifest_dataset: DatasetConsistencyWrapper, /// Whether directory listing is enabled in dual mode diff --git a/rust/lance-namespace-impls/src/rest.rs b/rust/lance-namespace-impls/src/rest.rs index be850fd885d..cd8a79cabed 100644 --- a/rust/lance-namespace-impls/src/rest.rs +++ b/rust/lance-namespace-impls/src/rest.rs @@ -747,46 +747,6 @@ impl RestNamespace { } } - /// Execute a POST request with JSON body and get binary response. - #[allow(dead_code)] - async fn post_json_binary( - &self, - path: &str, - query: &[(&str, &str)], - body: &T, - operation: &str, - object_id: &str, - ) -> Result { - let url = format!("{}{}", self.rest_client.base_path(), path); - let req_builder = self.rest_client.client().post(&url).query(query).json(body); - - let resp = self - .rest_client - .execute(req_builder, operation, object_id) - .await - .map_err(|e| { - Error::from(NamespaceError::Internal { - message: format!("Failed to execute request: {}", e), - }) - })?; - - let status = resp.status(); - if status.is_success() { - resp.bytes().await.map_err(|e| { - Error::from(NamespaceError::Internal { - message: format!("Failed to read response bytes: {}", e), - }) - }) - } else { - let content = resp.text().await.map_err(|e| { - Error::from(NamespaceError::Internal { - message: format!("Failed to read response body: {}", e), - }) - })?; - Err(Self::parse_error_response(status, &content, operation)) - } - } - /// Get the base endpoint URL for this namespace pub fn endpoint(&self) -> &str { self.rest_client.base_path() diff --git a/rust/lance/src/datafusion/dataframe.rs b/rust/lance/src/datafusion/dataframe.rs index 0c0291d2d1b..00db9920bf9 100644 --- a/rust/lance/src/datafusion/dataframe.rs +++ b/rust/lance/src/datafusion/dataframe.rs @@ -258,7 +258,7 @@ impl SessionContextExt for SessionContext { } #[cfg(test)] -pub mod tests { +mod tests { use std::sync::Arc; use arrow::{ diff --git a/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/fts.rs b/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/fts.rs index 6a848da0e7b..4451b1a1bc9 100644 --- a/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/fts.rs +++ b/rust/lance/src/dataset/mem_wal/memtable/scanner/exec/fts.rs @@ -231,73 +231,6 @@ impl FtsIndexExec { .collect() } - /// Materialize rows from batch store with score column (for unsorted results). - #[allow(dead_code)] - fn materialize_rows(&self, results: &[(u64, f32)]) -> DataFusionResult> { - if results.is_empty() { - return Ok(vec![]); - } - - // Group rows by batch using binary search on pre-computed ranges - // Track (row_in_batch, score, original_row_position) - let mut batches_data: std::collections::HashMap> = - std::collections::HashMap::new(); - - for &(pos, score) in results { - if let Some(batch) = self.find_batch(pos as usize) { - batches_data.entry(batch.batch_id).or_default().push(( - pos as usize - batch.start, - score, - pos, - )); - } - } - - let mut all_batches = Vec::new(); - - for (batch_id, rows_with_score) in batches_data { - if let Some(stored) = self.batch_store.get(batch_id) { - let rows: Vec = rows_with_score.iter().map(|&(r, _, _)| r as u32).collect(); - let scores: Vec = rows_with_score.iter().map(|&(_, s, _)| s).collect(); - let row_positions: Vec = - rows_with_score.iter().map(|&(_, _, pos)| pos).collect(); - - let indices = UInt32Array::from(rows); - - let mut columns: Vec> = stored - .data - .columns() - .iter() - .map(|col| arrow_select::take::take(col.as_ref(), &indices, None).unwrap()) - .collect(); - - // Add score column - columns.push(Arc::new(Float32Array::from(scores))); - - // Apply projection if needed (excluding score column which is always included) - let mut final_columns = if let Some(ref proj_indices) = self.projection { - let mut projected: Vec<_> = - proj_indices.iter().map(|&i| columns[i].clone()).collect(); - // Always include score as last column - projected.push(columns.last().unwrap().clone()); - projected - } else { - columns - }; - - // Add _rowid column if requested - if self.with_row_id { - final_columns.push(Arc::new(UInt64Array::from(row_positions))); - } - - let batch = RecordBatch::try_new(self.output_schema.clone(), final_columns)?; - all_batches.push(batch); - } - } - - Ok(all_batches) - } - /// Materialize rows from batch store preserving input order (for sorted results). /// /// This method processes results one at a time to preserve the score-sorted order, diff --git a/rust/lance/src/dataset/udtf.rs b/rust/lance/src/dataset/udtf.rs index b39586c2777..75c0388bc24 100644 --- a/rust/lance/src/dataset/udtf.rs +++ b/rust/lance/src/dataset/udtf.rs @@ -256,7 +256,7 @@ impl FtsQueryUDTFBuilder { } #[cfg(test)] -pub mod tests { +mod tests { use crate::Dataset; use crate::dataset::udtf::FtsQueryUDTFBuilder; use crate::index::DatasetIndexExt; diff --git a/rust/lance/src/dataset/write.rs b/rust/lance/src/dataset/write.rs index 2ed137bd23c..0d946bb30de 100644 --- a/rust/lance/src/dataset/write.rs +++ b/rust/lance/src/dataset/write.rs @@ -1149,13 +1149,11 @@ async fn new_source_iter( struct SpillStreamIter { receiver: SpillReceiver, - #[allow(dead_code)] // Exists to keep the SpillSender alive - sender_handle: tokio::task::JoinHandle, + _sender_handle: tokio::task::JoinHandle, // This temp dir is used to store the spilled data. It is kept alive by // this struct. When this struct is dropped, the Drop implementation of // tempfile::TempDir will delete the temp dir. - #[allow(dead_code)] // Exists to keep the temp dir alive - tmp_dir: TempDir, + _tmp_dir: TempDir, } impl SpillStreamIter { @@ -1199,8 +1197,8 @@ impl SpillStreamIter { Ok(Self { receiver, - tmp_dir, - sender_handle, + _tmp_dir: tmp_dir, + _sender_handle: sender_handle, }) } } diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 5cf8190136c..5a6ff915409 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -1296,7 +1296,6 @@ mod tests { .unwrap(); } - #[allow(dead_code)] async fn ground_truth( dataset: &Dataset, column: &str, @@ -1322,7 +1321,6 @@ mod tests { .collect() } - #[allow(dead_code)] fn multivec_ground_truth( vectors: &ListArray, query: &dyn Array, diff --git a/rust/lance/src/io/commit.rs b/rust/lance/src/io/commit.rs index 2e61054d28f..14e92216b3c 100644 --- a/rust/lance/src/io/commit.rs +++ b/rust/lance/src/io/commit.rs @@ -73,7 +73,7 @@ pub mod namespace_manifest; mod s3_test; /// Read the transaction data from a transaction file. -#[allow(dead_code)] +#[cfg(test)] pub(crate) async fn read_transaction_file( object_store: &ObjectStore, base_path: &Path, diff --git a/rust/lance/src/io/exec/fts.rs b/rust/lance/src/io/exec/fts.rs index b40ad145802..1e7938d15f4 100644 --- a/rust/lance/src/io/exec/fts.rs +++ b/rust/lance/src/io/exec/fts.rs @@ -1395,7 +1395,7 @@ impl ExecutionPlan for BooleanQueryExec { } #[cfg(test)] -pub mod tests { +mod tests { use std::sync::{Arc, Mutex}; use crate::index::DatasetIndexExt; diff --git a/rust/lance/src/utils/future.rs b/rust/lance/src/utils/future.rs index e43f2055111..595d5c2a297 100644 --- a/rust/lance/src/utils/future.rs +++ b/rust/lance/src/utils/future.rs @@ -16,17 +16,6 @@ use tracing::Instrument; pub struct SharedPrerequisite(Arc>>); impl SharedPrerequisite { - /// Asynchronously get a cloned copy of the output - /// - /// If the child task failed then a PrerequisiteFailed error is raised. - #[allow(dead_code)] - pub async fn get_fut(&self) -> crate::Result { - self.0 - .get() - .await - .map_err(|err| crate::Error::prerequisite_failed(err)) - } - /// Synchronously get a cloned copy of the cached output /// /// Must be called after a call to `wait_ready` diff --git a/rust/lance/src/utils/test.rs b/rust/lance/src/utils/test.rs index f3b037aa02e..ec38e05cd35 100644 --- a/rust/lance/src/utils/test.rs +++ b/rust/lance/src/utils/test.rs @@ -52,7 +52,6 @@ impl TestDatasetGenerator { /// Set the seed for the random number generator. /// /// If not set, a random seed will be generated on each call to [`Self::make_hostile`]. - #[allow(dead_code)] pub fn seed(mut self, seed: u64) -> Self { self.seed = Some(seed); self From 38454c82bda540358ea61d53614bab14b177d143 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Mon, 30 Mar 2026 18:45:21 +0800 Subject: [PATCH 099/206] docs(python): prefer uv for local environment setup (#6335) This updates the Python contributor docs to prefer `uv` for local environment initialization and day-to-day command execution, while keeping the documented `Makefile` targets unchanged. I intentionally did not migrate CI in this PR. We have already tried broader `uv` rollouts in #4221 and are still carrying a larger CI refactor in #5210; the current Python workflows still have materially different dependency shapes across lint, wheel-install test, benchmark, and optional Torch / TensorFlow / Ray paths, so I don't see a single environment definition we can switch to safely without a dedicated CI pass. This keeps the local guidance moving in the right direction without pretending the CI environment has already been unified. No tests were run because this is a docs-only change. --- python/AGENTS.md | 3 +++ python/DEVELOPMENT.md | 53 +++++++++++++++++++++++++------------------ 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/python/AGENTS.md b/python/AGENTS.md index 8aaaa53bedc..5cb45aa69f9 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -4,6 +4,9 @@ Also see [root AGENTS.md](../AGENTS.md) for cross-language standards. ## Commands +* Environment: prefer `uv` for local Python environment setup; start with `uv sync --extra tests --extra dev`, and add other extras such as `benchmarks`, `torch`, or `geo` only when needed. +* Command execution: after initializing the environment with `uv`, either activate the environment or use `uv run ...`; keep `Makefile` targets and CI commands environment-manager agnostic unless CI has been migrated explicitly. +* Build time expectations: `uv sync` and `uv run maturin develop` build the local `pylance` Rust extension as part of the environment workflow. This can be slow, especially on the first run or after Rust dependency changes; treat that as expected and do not switch to a different environment manager just because the build takes time. * Build: `maturin develop` (required after Rust changes) * Test: `make test` * Run single test: `pytest python/tests/.py::` diff --git a/python/DEVELOPMENT.md b/python/DEVELOPMENT.md index 9effb0fb456..12c56549608 100644 --- a/python/DEVELOPMENT.md +++ b/python/DEVELOPMENT.md @@ -1,5 +1,15 @@ # Development +For local development, prefer [uv](https://docs.astral.sh/uv/) to create and manage the Python environment: + +```shell +uv sync --extra tests --extra dev +``` + +Add extras such as `benchmarks`, `torch`, or `geo` only when you need them. After the environment is initialized, either activate it or use `uv run ...` for commands. + +`uv sync` is not just downloading Python packages here. It also builds the local `pylance` Rust extension as part of the editable environment, so the first run, cache misses, or Rust dependency changes can make it noticeably slow. This is expected; let the build finish instead of interrupting it and switching to a different environment setup. + ## Building the project This project is built with [maturin](https://github.com/PyO3/maturin). @@ -7,31 +17,27 @@ This project is built with [maturin](https://github.com/PyO3/maturin). It can be built in development mode with: ```shell -maturin develop +uv run maturin develop ``` This builds the Rust native module in place. You will need to re-run this whenever you change the Rust code. But changing the Python code doesn't require re-building. -## Running tests - -To run the tests, first install the test packages: +As with `uv sync`, this may take a while because it is compiling Rust code for the local `pylance` extension. Slow builds are expected here, especially after dependency or toolchain changes. -```shell -pip install '.[tests]' -``` +## Running tests -then: +To run the tests: ```shell -make test +uv run make test ``` To check the documentation examples, use ```shell -make doctest +uv run make doctest ``` ## Formatting and linting @@ -39,7 +45,7 @@ make doctest To run formatters, run: ```shell -make format +uv run make format ``` (To run for just Python or just Rust, use `make format-python` or `cargo fmt`.) @@ -47,7 +53,7 @@ make format To run format checker and linters, run: ```shell -make lint +uv run make lint ``` (To run for just Python or just Rust, use `make lint-python` or `make lint-rust`.) @@ -59,7 +65,7 @@ then you can use the pre-commit tool. The project includes a pre-commit config file already. First, install the pre-commit tool: ```shell -pip install pre-commit +uv tool install pre-commit ``` Then install the hooks: @@ -98,7 +104,8 @@ benchmarks added there should run in less than 5 seconds. Before running benchmarks, you should build pylance in release mode: ```shell -maturin develop --profile release-with-debug --extras benchmarks --features datagen +uv sync --extra tests --extra dev --extra benchmarks +uv run maturin develop --profile release-with-debug --extras benchmarks --features datagen ``` (You can also use `--release` or `--profile release`, but `--profile release-with-debug` @@ -107,7 +114,7 @@ will provide debug symbols for profiling.) Then you can run the benchmarks with ```shell -pytest python/benchmarks -m "not slow" +uv run pytest python/benchmarks -m "not slow" ``` Note: the first time you run the benchmarks, they may take a while, since they @@ -118,7 +125,7 @@ Some benchmarks are especially slow, so they are skipped `-m "not slow"`. To run the slow benchmarks, use: ```shell -pytest python/benchmarks +uv run pytest python/benchmarks ``` ### Run a particular benchmark @@ -127,7 +134,7 @@ To filter benchmarks by name, use the usual pytest `-k` flag (this can be a substring match, so you don't need to type the full name): ```shell -pytest python/benchmarks -k test_ivf_pq_index_search +uv run pytest python/benchmarks -k test_ivf_pq_index_search ``` ### Profile a benchmark @@ -136,7 +143,7 @@ If you have [cargo-flamegraph](https://github.com/flamegraph-rs/flamegraph) installed, you can create a flamegraph of a benchmark by running: ```shell -flamegraph -F 100 --no-inline -- $(which python) \ +flamegraph -F 100 --no-inline -- $(uv run which python) \ -m pytest python/benchmarks \ --benchmark-min-time=2 \ -k test_ivf_pq_index_search @@ -165,12 +172,14 @@ the benchmarks again with `--benchmark-compare`. ```shell CURRENT_BRANCH=$(git branch --show-current) git checkout main -maturin develop --profile release-with-debug --features datagen -pytest --benchmark-save=baseline python/benchmarks -m "not slow" +uv sync --extra tests --extra dev --extra benchmarks +uv run maturin develop --profile release-with-debug --features datagen +uv run pytest --benchmark-save=baseline python/benchmarks -m "not slow" COMPARE_ID=$(ls .benchmarks/*/ | tail -1 | cut -c1-4) git checkout $CURRENT_BRANCH -maturin develop --profile release-with-debug --features datagen -pytest --benchmark-compare=$COMPARE_ID python/benchmarks -m "not slow" +uv sync --extra tests --extra dev --extra benchmarks +uv run maturin develop --profile release-with-debug --features datagen +uv run pytest --benchmark-compare=$COMPARE_ID python/benchmarks -m "not slow" ``` ## Tracing From 26bf8f3426fe2c8a62201af97fe0d7e3443001b8 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Mon, 30 Mar 2026 07:52:53 -0700 Subject: [PATCH 100/206] feat: add write progress callback to InsertBuilder (#6318) ## Summary - Adds `InsertBuilder::progress(callback)` builder method that fires `WriteStats` after each batch is handed to the writer - Adds `WriteParams::write_progress: Option` for lower-level callers using `do_write_fragments` directly - `WriteStats` reports `bytes_written`, `rows_written`, and `files_written` (all cumulative) ## Usage ```rust InsertBuilder::new(dataset) .with_params(&write_params) .progress(|stats| { // stats.bytes_written - cumulative bytes handed to the writer // stats.rows_written - cumulative rows written // stats.files_written - completed fragment files so far }) .execute_uncommitted_stream(input_stream) .await?; ``` The callback fires after each batch during the write and once more after the final file is closed (with exact totals). `bytes_written` uses `writer.tell()` for in-progress files and the final `DataFile.file_size_bytes` on completion. Closes #6247 Co-authored-by: Claude Sonnet 4.6 --- rust/lance/src/dataset.rs | 2 +- rust/lance/src/dataset/progress.rs | 43 +++++++++++++++ rust/lance/src/dataset/write.rs | 36 ++++++++++++- rust/lance/src/dataset/write/insert.rs | 75 +++++++++++++++++++++++++- 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 817954da710..aec6010b33a 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -130,7 +130,7 @@ pub use write::update::{UpdateBuilder, UpdateJob}; #[allow(deprecated)] pub use write::{ AutoCleanupParams, CommitBuilder, DeleteBuilder, DeleteResult, InsertBuilder, WriteDestination, - WriteMode, WriteParams, write_fragments, + WriteMode, WriteParams, WriteProgressFn, WriteStats, write_fragments, }; pub(crate) const INDICES_DIR: &str = "_indices"; diff --git a/rust/lance/src/dataset/progress.rs b/rust/lance/src/dataset/progress.rs index 1e489d0b768..e970adc47ee 100644 --- a/rust/lance/src/dataset/progress.rs +++ b/rust/lance/src/dataset/progress.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::sync::Arc; + use async_trait::async_trait; use lance_table::format::Fragment; @@ -26,6 +28,47 @@ pub trait WriteFragmentProgress: std::fmt::Debug + Sync + Send { async fn complete(&self, fragment: &Fragment) -> Result<()>; } +/// Statistics reported to the write progress callback set via +/// [`InsertBuilder::progress`](crate::dataset::InsertBuilder::progress) or +/// [`WriteParams::write_progress`](crate::dataset::WriteParams::write_progress). +#[derive(Debug, Clone, Default)] +pub struct WriteStats { + /// Cumulative bytes handed to the writer so far. + /// + /// For local storage this closely tracks bytes flushed to disk. For cloud + /// object stores (S3, GCS, Azure) this reflects bytes handed to the + /// multipart-upload buffer; actual network I/O may lag slightly. + pub bytes_written: u64, + /// Cumulative rows written so far. + pub rows_written: u64, + /// Number of files (fragments) whose writes have completed so far. + pub files_written: u32, +} + +/// An opaque wrapper around a write-progress closure. +/// +/// Stored inside [`WriteParams::write_progress`](crate::dataset::WriteParams::write_progress). +/// Construct via [`InsertBuilder::progress`](crate::dataset::InsertBuilder::progress) or +/// directly with [`WriteProgressFn::new`]. +#[derive(Clone)] +pub struct WriteProgressFn(Arc); + +impl WriteProgressFn { + pub fn new(f: impl Fn(WriteStats) + Send + Sync + 'static) -> Self { + Self(Arc::new(f)) + } + + pub(crate) fn call(&self, stats: WriteStats) { + (self.0)(stats); + } +} + +impl std::fmt::Debug for WriteProgressFn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WriteProgressFn").finish_non_exhaustive() + } +} + /// By default, Progress tracker is Noop. #[derive(Debug, Clone, Default)] pub struct NoopFragmentWriteProgress {} diff --git a/rust/lance/src/dataset/write.rs b/rust/lance/src/dataset/write.rs index 0d946bb30de..6b226969b08 100644 --- a/rust/lance/src/dataset/write.rs +++ b/rust/lance/src/dataset/write.rs @@ -52,6 +52,7 @@ pub mod merge_insert; mod retry; pub mod update; +pub use super::progress::{WriteProgressFn, WriteStats}; pub use commit::CommitBuilder; pub use delete::{DeleteBuilder, DeleteResult}; pub use insert::InsertBuilder; @@ -177,6 +178,13 @@ pub struct WriteParams { pub progress: Arc, + /// Optional callback invoked after each batch is written. + /// + /// Receives cumulative [`WriteStats`] so callers can render a progress bar + /// or compute throughput. The callback must be cheap and non-blocking; + /// spawn a task if you need async work. + pub write_progress: Option, + /// If present, dataset will use this to update the latest version /// /// If not set, the default will be based on the object store. Generally this will @@ -263,6 +271,7 @@ impl Default for WriteParams { mode: WriteMode::Create, store_params: None, progress: Arc::new(NoopFragmentWriteProgress::new()), + write_progress: None, commit_handler: None, data_storage_version: None, enable_stable_row_ids: false, @@ -430,6 +439,9 @@ pub async fn do_write_fragments( let mut writer: Option> = None; let mut num_rows_in_current_file = 0; let mut fragments = Vec::new(); + let mut bytes_completed: u64 = 0; + let mut rows_completed: u64 = 0; + let mut files_written: u32 = 0; while let Some(batch_chunk) = buffered_reader.next().await { let batch_chunk = batch_chunk?; @@ -441,16 +453,28 @@ pub async fn do_write_fragments( } writer.as_mut().unwrap().write(&batch_chunk).await?; - for batch in batch_chunk { + for batch in &batch_chunk { num_rows_in_current_file += batch.num_rows() as u32; } + if let Some(cb) = ¶ms.write_progress { + let current_bytes = writer.as_mut().unwrap().tell().await?; + cb.call(WriteStats { + bytes_written: bytes_completed + current_bytes, + rows_written: rows_completed + num_rows_in_current_file as u64, + files_written, + }); + } + if num_rows_in_current_file >= params.max_rows_per_file as u32 || writer.as_mut().unwrap().tell().await? >= params.max_bytes_per_file as u64 { let (num_rows, data_file) = writer.take().unwrap().finish().await?; info!(target: TRACE_FILE_AUDIT, mode=AUDIT_MODE_CREATE, r#type=AUDIT_TYPE_DATA, path = &data_file.path); debug_assert_eq!(num_rows, num_rows_in_current_file); + bytes_completed += data_file.file_size_bytes.get().map_or(0, |s| s.get()); + rows_completed += num_rows as u64; + files_written += 1; params.progress.complete(fragments.last().unwrap()).await?; let last_fragment = fragments.last_mut().unwrap(); last_fragment.physical_rows = Some(num_rows as usize); @@ -463,6 +487,16 @@ pub async fn do_write_fragments( if let Some(mut writer) = writer.take() { let (num_rows, data_file) = writer.finish().await?; info!(target: TRACE_FILE_AUDIT, mode=AUDIT_MODE_CREATE, r#type=AUDIT_TYPE_DATA, path = &data_file.path); + bytes_completed += data_file.file_size_bytes.get().map_or(0, |s| s.get()); + rows_completed += num_rows as u64; + files_written += 1; + if let Some(cb) = ¶ms.write_progress { + cb.call(WriteStats { + bytes_written: bytes_completed, + rows_written: rows_completed, + files_written, + }); + } let last_fragment = fragments.last_mut().unwrap(); last_fragment.physical_rows = Some(num_rows as usize); last_fragment.files.push(data_file); diff --git a/rust/lance/src/dataset/write/insert.rs b/rust/lance/src/dataset/write/insert.rs index 3c9d01fe49b..20209ed7f30 100644 --- a/rust/lance/src/dataset/write/insert.rs +++ b/rust/lance/src/dataset/write/insert.rs @@ -31,6 +31,8 @@ use super::WriteMode; use super::WriteParams; use super::commit::CommitBuilder; use super::resolve_commit_handler; +use crate::dataset::progress::{WriteProgressFn, WriteStats}; + /// Insert or create a new dataset. /// /// There are different variants of `execute()` methods. Those with the `_stream` @@ -46,6 +48,7 @@ pub struct InsertBuilder<'a> { dest: WriteDestination<'a>, // TODO: make these parameters a part of the builder, and add specific methods. params: Option<&'a WriteParams>, + write_progress: Option, } impl<'a> InsertBuilder<'a> { @@ -53,6 +56,7 @@ impl<'a> InsertBuilder<'a> { Self { dest: dest.into(), params: None, + write_progress: None, } } @@ -61,6 +65,18 @@ impl<'a> InsertBuilder<'a> { self } + /// Register a callback that is invoked after each batch of rows is written. + /// + /// The callback receives cumulative [`WriteStats`] and can be used to drive + /// a progress bar or compute throughput. It must be cheap and non-blocking; + /// spawn a task if you need async work. + /// + /// This overrides any `write_progress` set in [`WriteParams`]. + pub fn progress(mut self, callback: impl Fn(WriteStats) + Send + Sync + 'static) -> Self { + self.write_progress = Some(WriteProgressFn::new(callback)); + self + } + /// Execute the insert operation with the given data. /// /// This writes the data fragments and commits them into the dataset. @@ -331,7 +347,10 @@ impl<'a> InsertBuilder<'a> { } async fn resolve_context(&self) -> Result> { - let params = self.params.cloned().unwrap_or_default(); + let mut params = self.params.cloned().unwrap_or_default(); + if let Some(cb) = self.write_progress.clone() { + params.write_progress = Some(cb); + } let (object_store, base_path, commit_handler) = match &self.dest { WriteDestination::Dataset(dataset) => ( dataset.object_store.clone(), @@ -635,4 +654,58 @@ mod test { } } } + + #[tokio::test] + async fn test_write_progress_callback() { + use std::sync::Mutex; + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + // Three batches of 100 rows each. + let batches: Vec<_> = (0..3) + .map(|_| { + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![0i32; 100]))], + ) + .unwrap() + }) + .collect(); + + let stats_log: Arc>> = + Arc::new(Mutex::new(Vec::new())); + let log_clone = stats_log.clone(); + + InsertBuilder::new("memory://test_write_progress") + .progress(move |stats| { + log_clone.lock().unwrap().push(stats); + }) + .execute_stream(RecordBatchIterator::new( + batches.into_iter().map(Ok), + schema, + )) + .await + .unwrap(); + + let log = stats_log.lock().unwrap(); + assert!( + !log.is_empty(), + "progress callback must be called at least once" + ); + // bytes_written and rows_written must be monotonically non-decreasing. + for window in log.windows(2) { + assert!( + window[1].bytes_written >= window[0].bytes_written, + "bytes_written must not decrease: {:?} -> {:?}", + window[0].bytes_written, + window[1].bytes_written, + ); + assert!( + window[1].rows_written >= window[0].rows_written, + "rows_written must not decrease", + ); + } + let last = log.last().unwrap(); + assert!(last.bytes_written > 0, "final bytes_written must be > 0"); + assert_eq!(last.rows_written, 300, "all 300 rows must be reported"); + assert_eq!(last.files_written, 1, "a single file should be written"); + } } From 30eceac5f2d11f89b27deddd1458fa9fb6a16991 Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Mon, 30 Mar 2026 22:52:56 +0800 Subject: [PATCH 101/206] feat: support hamming distance in HNSW (#6336) ## Summary - add UInt8/Hamming support to the HNSW builder and IVF_HNSW_FLAT index paths - enable the PartitionTransformer HNSW speedup path for binary centroids under hamming distance - add regression coverage for direct HNSW binary roundtrips and binary SimpleIndex usage ## Testing - cargo test -p lance-index test_builder_write_load_binary_hamming -- --nocapture - cargo test -p lance-index test_simple_index_nearest_centroid_binary -- --nocapture - cargo test -p lance-index test_simple_index_rejects_uint8_non_hamming -- --nocapture - cargo test -p lance test_create_ivf_hnsw_flat -- --nocapture --- rust/lance-index/benches/kmeans.rs | 11 +- rust/lance-index/src/vector/hnsw/builder.rs | 93 ++++++++++-- rust/lance-index/src/vector/utils.rs | 112 +++++++++++---- rust/lance/src/index.rs | 39 +++-- rust/lance/src/index/vector.rs | 152 ++++++++++++++------ rust/lance/src/index/vector/ivf.rs | 71 ++++++--- rust/lance/src/index/vector/ivf/v2.rs | 1 + 7 files changed, 362 insertions(+), 117 deletions(-) diff --git a/rust/lance-index/benches/kmeans.rs b/rust/lance-index/benches/kmeans.rs index e250f59e21c..ad04adb27e8 100644 --- a/rust/lance-index/benches/kmeans.rs +++ b/rust/lance-index/benches/kmeans.rs @@ -7,7 +7,6 @@ use arrow_array::FixedSizeListArray; use criterion::{Criterion, criterion_group, criterion_main}; use lance_arrow::FixedSizeListArrayExt; -use lance_index::vector::flat::storage::FlatFloatStorage; use lance_index::vector::utils::SimpleIndex; #[cfg(target_os = "linux")] use pprof::criterion::{Output, PProfProfiler}; @@ -66,9 +65,13 @@ fn bench_train(c: &mut Criterion) { }); if k * dimension as usize >= 1_000_000 { - let index = - SimpleIndex::try_new(FlatFloatStorage::new(centroids.clone(), DistanceType::L2)) - .unwrap(); + let index = SimpleIndex::may_train_index( + centroids.values().clone(), + dimension as usize, + DistanceType::L2, + ) + .unwrap() + .unwrap(); group.bench_function("with_index", |b| { b.iter(|| { KMeansAlgoFloat::::compute_membership_and_loss( diff --git a/rust/lance-index/src/vector/hnsw/builder.rs b/rust/lance-index/src/vector/hnsw/builder.rs index af4283c69f9..6de41fbc162 100644 --- a/rust/lance-index/src/vector/hnsw/builder.rs +++ b/rust/lance-index/src/vector/hnsw/builder.rs @@ -5,7 +5,7 @@ use arrow::array::{AsArray, ListBuilder, UInt32Builder}; use arrow::compute::concat_batches; -use arrow::datatypes::{Float32Type, UInt32Type}; +use arrow::datatypes::{DataType, Float32Type, UInt32Type}; use arrow_array::{ArrayRef, Float32Array, RecordBatch, UInt64Array}; use crossbeam_queue::ArrayQueue; use deepsize::DeepSizeOf; @@ -31,7 +31,7 @@ use super::super::graph::beam_search; use super::{HNSW_TYPE, HnswMetadata, VECTOR_ID_COL, VECTOR_ID_FIELD, select_neighbors_heuristic}; use crate::metrics::MetricsCollector; use crate::prefilter::PreFilter; -use crate::vector::flat::storage::FlatFloatStorage; +use crate::vector::flat::storage::{FlatBinStorage, FlatFloatStorage}; use crate::vector::graph::builder::GraphBuilderNode; use crate::vector::graph::{ BorrowingGraph, DISTS_FIELD, Graph, NEIGHBORS_COL, NEIGHBORS_FIELD, OrderedFloat, OrderedNode, @@ -101,11 +101,25 @@ impl HnswBuildParams { /// - `data`: A FixedSizeList to build the HNSW. /// - `distance_type`: The distance type to use. pub async fn build(self, data: ArrayRef, distance_type: DistanceType) -> Result { - let vec_store = Arc::new(FlatFloatStorage::new( - data.as_fixed_size_list().clone(), - distance_type, - )); - HNSW::index_vectors(vec_store.as_ref(), self) + let vectors = data.as_fixed_size_list().clone(); + match (vectors.value_type(), distance_type) { + (DataType::UInt8, DistanceType::Hamming) => { + let vec_store = Arc::new(FlatBinStorage::new(vectors, distance_type)); + HNSW::index_vectors(vec_store.as_ref(), self) + } + (DataType::UInt8, _) => Err(Error::invalid_input(format!( + "HNSW only supports hamming distance for UInt8 vectors, got {}", + distance_type + ))), + (_, DistanceType::Hamming) => Err(Error::invalid_input(format!( + "HNSW hamming distance only supports UInt8 vectors, got {}", + vectors.value_type() + ))), + _ => { + let vec_store = Arc::new(FlatFloatStorage::new(vectors, distance_type)); + HNSW::index_vectors(vec_store.as_ref(), self) + } + } } } @@ -934,7 +948,7 @@ impl IvfSubIndex for HNSW { mod tests { use std::sync::Arc; - use arrow_array::FixedSizeListArray; + use arrow_array::{FixedSizeListArray, UInt8Array}; use arrow_schema::Schema; use lance_arrow::FixedSizeListArrayExt; use lance_file::previous::{ @@ -953,7 +967,7 @@ mod tests { use crate::scalar::IndexWriter; use crate::vector::v3::subindex::IvfSubIndex; use crate::vector::{ - flat::storage::FlatFloatStorage, + flat::storage::{FlatBinStorage, FlatFloatStorage}, graph::{DISTS_FIELD, NEIGHBORS_FIELD}, hnsw::{ HNSW, VECTOR_ID_FIELD, @@ -1022,4 +1036,65 @@ mod tests { .unwrap(); assert_eq!(builder_results, loaded_results); } + + #[tokio::test] + async fn test_builder_write_load_binary_hamming() { + const DIM: usize = 8; + const TOTAL: usize = 256; + const NUM_EDGES: usize = 20; + let data = UInt8Array::from_iter_values((0..TOTAL * DIM).map(|v| (v % 16) as u8)); + let fsl = FixedSizeListArray::try_new_from_values(data, DIM as i32).unwrap(); + let store = Arc::new(FlatBinStorage::new(fsl.clone(), DistanceType::Hamming)); + let builder = HnswBuildParams::default() + .num_edges(NUM_EDGES) + .ef_construction(50) + .build(Arc::new(fsl.clone()), DistanceType::Hamming) + .await + .unwrap(); + + let object_store = ObjectStore::memory(); + let path = Path::from("test_builder_write_load_binary_hamming"); + let writer = object_store.create(&path).await.unwrap(); + let schema = Schema::new(vec![ + VECTOR_ID_FIELD.clone(), + NEIGHBORS_FIELD.clone(), + DISTS_FIELD.clone(), + ]); + let schema = lance_core::datatypes::Schema::try_from(&schema).unwrap(); + let mut writer = PreviousFileWriter::::with_object_writer( + writer, + schema, + &PreviousFileWriterOptions::default(), + ) + .unwrap(); + let batch = builder.to_batch().unwrap(); + let metadata = batch.schema_ref().metadata().clone(); + writer.write_record_batch(batch).await.unwrap(); + writer.finish_with_metadata(&metadata).await.unwrap(); + + let reader = PreviousFileReader::try_new_self_described(&object_store, &path, None) + .await + .unwrap(); + let batch = reader + .read_range(0..reader.len(), reader.schema()) + .await + .unwrap(); + let loaded_hnsw = HNSW::load(batch).unwrap(); + + let query = fsl.value(0); + let k = 10; + let params = HnswQueryParams { + ef: 50, + lower_bound: None, + upper_bound: None, + dist_q_c: 0.0, + }; + let builder_results = builder + .search_basic(query.clone(), k, ¶ms, None, store.as_ref()) + .unwrap(); + let loaded_results = loaded_hnsw + .search_basic(query, k, ¶ms, None, store.as_ref()) + .unwrap(); + assert_eq!(builder_results, loaded_results); + } } diff --git a/rust/lance-index/src/vector/utils.rs b/rust/lance-index/src/vector/utils.rs index af2e5e4e819..299444976bb 100644 --- a/rust/lance-index/src/vector/utils.rs +++ b/rust/lance-index/src/vector/utils.rs @@ -15,6 +15,7 @@ use std::{ops::Range, sync::Arc}; use super::pb; use crate::pb::Tensor; +use crate::vector::flat::storage::FlatBinStorage; use crate::vector::flat::storage::FlatFloatStorage; use crate::vector::hnsw::HNSW; use crate::vector::hnsw::builder::{HnswBuildParams, HnswQueryParams}; @@ -42,22 +43,34 @@ static USE_HNSW_SPEEDUP_INDEXING: LazyLock = LazyLock::new(|| #[derive(Debug)] pub struct SimpleIndex { - store: FlatFloatStorage, + store: SimpleStore, index: HNSW, } +#[derive(Debug)] +enum SimpleStore { + Float(FlatFloatStorage), + Binary(FlatBinStorage), +} + impl SimpleIndex { - pub fn try_new(store: FlatFloatStorage) -> Result { - let hnsw = HNSW::index_vectors( - &store, - HnswBuildParams::default().ef_construction(15).num_edges(12), - )?; + fn try_new(store: SimpleStore) -> Result { + let hnsw = match &store { + SimpleStore::Float(store) => HNSW::index_vectors( + store, + HnswBuildParams::default().ef_construction(15).num_edges(12), + )?, + SimpleStore::Binary(store) => HNSW::index_vectors( + store, + HnswBuildParams::default().ef_construction(15).num_edges(12), + )?, + }; Ok(Self { store, index: hnsw }) } // train HNSW over the centroids to speed up finding the nearest clusters, // only train if all conditions are met: - // - the centroids are float32s or uint8s + // - the centroids are float16/float32 or uint8 with hamming distance // - `num_centroids * dimension >= 1_000_000` // we benchmarked that it's 2x faster in the case of 1024 centroids and 1024 dimensions, // so set the threshold to 1_000_000. @@ -76,31 +89,44 @@ impl SimpleIndex { _ => {} } - let f32_centroids = match centroids.data_type() { - DataType::Float16 | DataType::Float32 => { - cast(¢roids, &DataType::Float32).map_err(|e| Error::index(e.to_string()))? + let store = match (centroids.data_type(), distance_type) { + (DataType::Float16 | DataType::Float32, _) => { + let f32_centroids = cast(¢roids, &DataType::Float32) + .map_err(|e| Error::index(e.to_string()))?; + let fsl = FixedSizeListArray::try_new_from_values(f32_centroids, dimension as i32)?; + SimpleStore::Float(FlatFloatStorage::new(fsl, distance_type)) + } + (DataType::UInt8, DistanceType::Hamming) => { + let fsl = FixedSizeListArray::try_new_from_values(centroids, dimension as i32)?; + SimpleStore::Binary(FlatBinStorage::new(fsl, distance_type)) } _ => return Ok(None), }; - let fsl = FixedSizeListArray::try_new_from_values(f32_centroids, dimension as i32)?; - let store = FlatFloatStorage::new(fsl, distance_type); Self::try_new(store).map(Some) } pub(crate) fn search(&self, query: ArrayRef) -> Result<(u32, f32)> { - let query = cast(&query, &DataType::Float32).map_err(|e| Error::index(e.to_string()))?; - let res = self.index.search_basic( - query, - 1, - &HnswQueryParams { - ef: 15, - lower_bound: None, - upper_bound: None, - dist_q_c: 0.0, - }, - None, - &self.store, - )?; + let params = HnswQueryParams { + ef: 15, + lower_bound: None, + upper_bound: None, + dist_q_c: 0.0, + }; + let res = match &self.store { + SimpleStore::Float(store) => { + let query = + cast(&query, &DataType::Float32).map_err(|e| Error::index(e.to_string()))?; + self.index.search_basic(query, 1, ¶ms, None, store)? + } + SimpleStore::Binary(store) => { + let query = if query.data_type() == &DataType::UInt8 { + query + } else { + cast(&query, &DataType::UInt8).map_err(|e| Error::index(e.to_string()))? + }; + self.index.search_basic(query, 1, ¶ms, None, store)? + } + }; Ok((res[0].id, res[0].dist.0)) } } @@ -253,7 +279,7 @@ pub fn is_finite(fsl: &FixedSizeListArray) -> BooleanArray { mod tests { use super::*; - use arrow_array::{Float16Array, Float32Array, Float64Array}; + use arrow_array::{Float16Array, Float32Array, Float64Array, UInt8Array}; use half::f16; use lance_arrow::FixedSizeListArrayExt; use num_traits::identities::Zero; @@ -264,7 +290,18 @@ mod tests { fn build_index(centroids: ArrayRef, dim: usize) -> SimpleIndex { let f32_centroids = cast(¢roids, &DataType::Float32).unwrap(); let fsl = FixedSizeListArray::try_new_from_values(f32_centroids, dim as i32).unwrap(); - let store = FlatFloatStorage::new(fsl, DistanceType::L2); + let store = SimpleStore::Float(FlatFloatStorage::new(fsl, DistanceType::L2)); + SimpleIndex::try_new(store).unwrap() + } + + fn build_binary_index(centroids: ArrayRef, dim: usize) -> SimpleIndex { + let u8_centroids = if centroids.data_type() == &DataType::UInt8 { + centroids + } else { + cast(¢roids, &DataType::UInt8).unwrap() + }; + let fsl = FixedSizeListArray::try_new_from_values(u8_centroids, dim as i32).unwrap(); + let store = SimpleStore::Binary(FlatBinStorage::new(fsl, DistanceType::Hamming)); SimpleIndex::try_new(store).unwrap() } @@ -282,6 +319,20 @@ mod tests { assert_eq!(id, 42); } + #[test] + fn test_simple_index_nearest_centroid_binary() { + let centroids: ArrayRef = Arc::new(UInt8Array::from( + (0..100) + .flat_map(|i| std::iter::repeat_n(i as u8, 16)) + .collect::>(), + )); + let index = build_binary_index(centroids, 16); + let query: ArrayRef = Arc::new(UInt8Array::from(vec![42u8; 16])); + let (id, dist) = index.search(query).unwrap(); + assert_eq!(id, 42); + assert_eq!(dist, 0.0); + } + #[test] fn test_simple_index_rejects_f64() { let centroids: ArrayRef = Arc::new(Float64Array::from(vec![0.0; 1600])); @@ -289,6 +340,13 @@ mod tests { assert!(result.is_none()); } + #[test] + fn test_simple_index_rejects_uint8_non_hamming() { + let centroids: ArrayRef = Arc::new(UInt8Array::from(vec![0u8; 1600])); + let result = SimpleIndex::may_train_index(centroids, 16, DistanceType::L2).unwrap(); + assert!(result.is_none()); + } + #[test] fn test_fsl_to_tensor() { let fsl = diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index 320d8a7501f..85497bc6180 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -1647,17 +1647,34 @@ impl DatasetIndexInternalExt for Dataset { let uri = index_dir.child(uuid).child("index.pb"); let file_metadata_cache = self.session.metadata_cache.file_metadata_cache(&uri); - let ivf = IVFIndex::::try_new( - self.object_store.clone(), - index_dir, - uuid.to_owned(), - frag_reuse_index, - &file_metadata_cache, - index_cache, - file_sizes, - ) - .await?; - Ok(Arc::new(ivf) as Arc) + match element_type { + DataType::UInt8 => { + let ivf = IVFIndex::::try_new( + self.object_store.clone(), + index_dir, + uuid.to_owned(), + frag_reuse_index, + &file_metadata_cache, + index_cache, + file_sizes, + ) + .await?; + Ok(Arc::new(ivf) as Arc) + } + _ => { + let ivf = IVFIndex::::try_new( + self.object_store.clone(), + index_dir, + uuid.to_owned(), + frag_reuse_index, + &file_metadata_cache, + index_cache, + file_sizes, + ) + .await?; + Ok(Arc::new(ivf) as Arc) + } + } } "IVF_HNSW_SQ" => { diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index a60d4941a07..c5c9038403e 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -594,21 +594,42 @@ pub(crate) async fn build_distributed_vector_index( ))); }; - IvfIndexBuilder::::new( - filtered_dataset, - column.to_owned(), - index_dir.clone(), - params.metric_type, - shuffler, - Some(ivf_params), - Some(()), - hnsw_params.clone(), - frag_reuse_index, - )? - .with_fragment_filter(fragment_filter) - .with_progress(progress.clone()) - .build() - .await?; + match element_type { + DataType::UInt8 => { + IvfIndexBuilder::::new( + filtered_dataset, + column.to_owned(), + index_dir.clone(), + params.metric_type, + shuffler, + Some(ivf_params), + Some(()), + hnsw_params.clone(), + frag_reuse_index, + )? + .with_fragment_filter(fragment_filter) + .with_progress(progress.clone()) + .build() + .await?; + } + _ => { + IvfIndexBuilder::::new( + filtered_dataset, + column.to_owned(), + index_dir.clone(), + params.metric_type, + shuffler, + Some(ivf_params), + Some(()), + hnsw_params.clone(), + frag_reuse_index, + )? + .with_fragment_filter(fragment_filter) + .with_progress(progress.clone()) + .build() + .await?; + } + } } IndexType::IvfHnswPq => { @@ -862,20 +883,40 @@ pub(crate) async fn build_vector_index( stages ))); }; - IvfIndexBuilder::::new( - dataset.clone(), - column.to_owned(), - dataset.indices_dir().child(uuid), - params.metric_type, - shuffler, - Some(ivf_params), - Some(()), - hnsw_params.clone(), - frag_reuse_index, - )? - .with_progress(progress.clone()) - .build() - .await?; + match element_type { + DataType::UInt8 => { + IvfIndexBuilder::::new( + dataset.clone(), + column.to_owned(), + dataset.indices_dir().child(uuid), + params.metric_type, + shuffler, + Some(ivf_params), + Some(()), + hnsw_params.clone(), + frag_reuse_index, + )? + .with_progress(progress.clone()) + .build() + .await?; + } + _ => { + IvfIndexBuilder::::new( + dataset.clone(), + column.to_owned(), + dataset.indices_dir().child(uuid), + params.metric_type, + shuffler, + Some(ivf_params), + Some(()), + hnsw_params.clone(), + frag_reuse_index, + )? + .with_progress(progress.clone()) + .build() + .await?; + } + } } IndexType::IvfHnswPq => { let StageParams::Hnsw(hnsw_params) = &stages[1] else { @@ -1123,23 +1164,42 @@ pub(crate) async fn build_vector_index_incremental( }; match quantization_type { - QuantizationType::Flat => { - IvfIndexBuilder::::new_incremental( - dataset.clone(), - column.to_owned(), - index_dir, - params.metric_type, - shuffler, - hnsw_params.clone(), - frag_reuse_index, - OptimizeOptions::append(), - )? - .with_ivf(ivf_model) - .with_quantizer(quantizer.try_into()?) - .with_progress(progress.clone()) - .build() - .await?; - } + QuantizationType::Flat => match element_type { + DataType::UInt8 => { + IvfIndexBuilder::::new_incremental( + dataset.clone(), + column.to_owned(), + index_dir, + params.metric_type, + shuffler, + hnsw_params.clone(), + frag_reuse_index, + OptimizeOptions::append(), + )? + .with_ivf(ivf_model) + .with_quantizer(quantizer.try_into()?) + .with_progress(progress.clone()) + .build() + .await?; + } + _ => { + IvfIndexBuilder::::new_incremental( + dataset.clone(), + column.to_owned(), + index_dir, + params.metric_type, + shuffler, + hnsw_params.clone(), + frag_reuse_index, + OptimizeOptions::append(), + )? + .with_ivf(ivf_model) + .with_quantizer(quantizer.try_into()?) + .with_progress(progress.clone()) + .build() + .await?; + } + }, QuantizationType::Product => { IvfIndexBuilder::::new_incremental( dataset.clone(), diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index f741b9ae4a4..b67e6ea8e81 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -499,23 +499,43 @@ pub(crate) async fn optimize_vector_indices_v2( } // IVF_HNSW_FLAT (SubIndexType::Hnsw, QuantizationType::Flat) => { - IvfIndexBuilder::::new_incremental( - dataset.clone(), - vector_column.to_owned(), - index_dir, - distance_type, - shuffler, - HnswBuildParams::default(), - frag_reuse_index, - options.clone(), - )? - .with_ivf(ivf_model.clone()) - .with_quantizer(quantizer.try_into()?) - .with_existing_indices(existing_indices.clone()) - .shuffle_data(unindexed) - .await? - .build() - .await? + if element_type == DataType::UInt8 { + IvfIndexBuilder::::new_incremental( + dataset.clone(), + vector_column.to_owned(), + index_dir, + distance_type, + shuffler, + HnswBuildParams::default(), + frag_reuse_index, + options.clone(), + )? + .with_ivf(ivf_model.clone()) + .with_quantizer(quantizer.try_into()?) + .with_existing_indices(existing_indices.clone()) + .shuffle_data(unindexed) + .await? + .build() + .await? + } else { + IvfIndexBuilder::::new_incremental( + dataset.clone(), + vector_column.to_owned(), + index_dir, + distance_type, + shuffler, + HnswBuildParams::default(), + frag_reuse_index, + options.clone(), + )? + .with_ivf(ivf_model.clone()) + .with_quantizer(quantizer.try_into()?) + .with_existing_indices(existing_indices.clone()) + .shuffle_data(unindexed) + .await? + .build() + .await? + } } // IVF_HNSW_SQ (SubIndexType::Hnsw, QuantizationType::Scalar) => { @@ -1558,11 +1578,22 @@ pub(crate) async fn remap_index_file_v3( .remap(mapping) .await } - (SubIndexType::Hnsw, QuantizationType::Flat) => { - IvfIndexBuilder::::new_remapper(dataset, column, index_dir, index)? + (SubIndexType::Hnsw, QuantizationType::Flat) => match element_type { + DataType::UInt8 => { + IvfIndexBuilder::::new_remapper( + dataset, column, index_dir, index, + )? .remap(mapping) .await - } + } + _ => { + IvfIndexBuilder::::new_remapper( + dataset, column, index_dir, index, + )? + .remap(mapping) + .await + } + }, (SubIndexType::Hnsw, QuantizationType::Product) => { IvfIndexBuilder::::new_remapper( dataset, column, index_dir, index, diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 5a6ff915409..11b19cd9f1d 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -2660,6 +2660,7 @@ mod tests { #[case(4, DistanceType::L2, 0.9)] #[case(4, DistanceType::Cosine, 0.9)] #[case(4, DistanceType::Dot, 0.85)] + #[case(4, DistanceType::Hamming, 0.9)] #[tokio::test] async fn test_create_ivf_hnsw_flat( #[case] nlist: usize, From 34e311c7632f62d8e4ff3a6e8bd124f84f0b70dc Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Mon, 30 Mar 2026 19:12:01 +0000 Subject: [PATCH 102/206] chore: release beta version 5.0.0-beta.2 --- .bumpversion.toml | 2 +- Cargo.lock | 89 +++++++++++++++++++++------------------ Cargo.toml | 40 +++++++++--------- java/lance-jni/Cargo.lock | 81 +++++++++++++++++++---------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 81 +++++++++++++++++++---------------- python/Cargo.toml | 2 +- 8 files changed, 163 insertions(+), 136 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 750cc27b46e..1dbb046b1ef 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "5.0.0-beta.1" +current_version = "5.0.0-beta.2" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index c419cbc1a7f..cd3d59104ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,7 +37,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -1355,16 +1355,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -1788,6 +1788,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.3.0" @@ -3336,7 +3345,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "lance-datagen", @@ -4349,9 +4358,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -4524,7 +4533,7 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float 5.2.0", + "ordered-float 5.3.0", "rand 0.9.2", "serde", "serde_json", @@ -4557,7 +4566,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "all_asserts", "approx", @@ -4653,7 +4662,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -4689,7 +4698,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrayref", "paste", @@ -4698,7 +4707,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -4738,7 +4747,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4772,7 +4781,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4792,7 +4801,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4839,7 +4848,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "all_asserts", "arrow", @@ -4865,7 +4874,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4908,7 +4917,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "datafusion", "geo-traits", @@ -4922,7 +4931,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "approx", "arrow", @@ -5003,7 +5012,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -5051,7 +5060,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "approx", "arrow-array", @@ -5072,7 +5081,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "async-trait", @@ -5087,7 +5096,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -5105,7 +5114,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-ipc", @@ -5164,7 +5173,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -5210,7 +5219,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "proc-macro2", "quote", @@ -5219,7 +5228,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "arrow-schema", @@ -5230,7 +5239,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "clap", "lance-core", @@ -6269,9 +6278,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0218004a4aae742209bee9c3cef05672f6b2708be36a50add8eb613b1f2a4008" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", ] @@ -7522,9 +7531,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -7888,7 +7897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -7899,7 +7908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -9754,9 +9763,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -9971,18 +9980,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 5df59d793c0..dd4e0646a4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ resolver = "3" [workspace.package] -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -52,24 +52,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=5.0.0-beta.1", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=5.0.0-beta.1", path = "./rust/lance-arrow" } -lance-core = { version = "=5.0.0-beta.1", path = "./rust/lance-core" } -lance-datafusion = { version = "=5.0.0-beta.1", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=5.0.0-beta.1", path = "./rust/lance-datagen" } -lance-encoding = { version = "=5.0.0-beta.1", path = "./rust/lance-encoding" } -lance-file = { version = "=5.0.0-beta.1", path = "./rust/lance-file" } -lance-geo = { version = "=5.0.0-beta.1", path = "./rust/lance-geo" } -lance-index = { version = "=5.0.0-beta.1", path = "./rust/lance-index" } -lance-io = { version = "=5.0.0-beta.1", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=5.0.0-beta.1", path = "./rust/lance-linalg" } -lance-namespace = { version = "=5.0.0-beta.1", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=5.0.0-beta.1", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=5.0.0-beta.1", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=5.0.0-beta.2", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=5.0.0-beta.2", path = "./rust/lance-arrow" } +lance-core = { version = "=5.0.0-beta.2", path = "./rust/lance-core" } +lance-datafusion = { version = "=5.0.0-beta.2", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=5.0.0-beta.2", path = "./rust/lance-datagen" } +lance-encoding = { version = "=5.0.0-beta.2", path = "./rust/lance-encoding" } +lance-file = { version = "=5.0.0-beta.2", path = "./rust/lance-file" } +lance-geo = { version = "=5.0.0-beta.2", path = "./rust/lance-geo" } +lance-index = { version = "=5.0.0-beta.2", path = "./rust/lance-index" } +lance-io = { version = "=5.0.0-beta.2", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=5.0.0-beta.2", path = "./rust/lance-linalg" } +lance-namespace = { version = "=5.0.0-beta.2", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=5.0.0-beta.2", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=5.0.0-beta.2", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=5.0.0-beta.1", path = "./rust/lance-table" } -lance-test-macros = { version = "=5.0.0-beta.1", path = "./rust/lance-test-macros" } -lance-testing = { version = "=5.0.0-beta.1", path = "./rust/lance-testing" } +lance-table = { version = "=5.0.0-beta.2", path = "./rust/lance-table" } +lance-test-macros = { version = "=5.0.0-beta.2", path = "./rust/lance-test-macros" } +lance-testing = { version = "=5.0.0-beta.2", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -95,7 +95,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=5.0.0-beta.1", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=5.0.0-beta.2", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -136,7 +136,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=5.0.0-beta.1", path = "./rust/compression/fsst" } +fsst = { version = "=5.0.0-beta.2", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 44ce34125fd..2f98efa51e2 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -964,16 +964,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -1271,6 +1271,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32c" version = "0.6.8" @@ -2389,7 +2398,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3186,9 +3195,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -3353,7 +3362,7 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float 5.2.0", + "ordered-float 5.3.0", "rand 0.9.2", "serde", "serde_json", @@ -3377,7 +3386,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3443,7 +3452,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3463,7 +3472,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrayref", "paste", @@ -3472,7 +3481,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3509,7 +3518,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -3542,7 +3551,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -3560,7 +3569,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -3597,7 +3606,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -3629,7 +3638,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "datafusion", "geo-traits", @@ -3643,7 +3652,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3711,7 +3720,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3752,7 +3761,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-schema", @@ -3786,7 +3795,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3802,7 +3811,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "async-trait", @@ -3815,7 +3824,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-ipc", @@ -3860,7 +3869,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4503,9 +4512,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0218004a4aae742209bee9c3cef05672f6b2708be36a50add8eb613b1f2a4008" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", ] @@ -5383,9 +5392,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -5725,7 +5734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5736,7 +5745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -7231,9 +7240,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -7403,18 +7412,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index bfd6ecaa13d..5a1e7f3f655 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 047fb50082c..e31d77745b0 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 5.0.0-beta.1 + 5.0.0-beta.2 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index 5b90e60c644..4507a617872 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -70,7 +70,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -1114,16 +1114,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -1460,6 +1460,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32c" version = "0.6.8" @@ -2818,7 +2827,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3666,9 +3675,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -3812,7 +3821,7 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float 5.2.0", + "ordered-float 5.3.0", "rand 0.9.2", "serde", "serde_json", @@ -3845,7 +3854,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3912,7 +3921,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3932,7 +3941,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrayref", "paste", @@ -3941,7 +3950,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3978,7 +3987,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4011,7 +4020,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4029,7 +4038,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4066,7 +4075,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4098,7 +4107,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "datafusion", "geo-traits", @@ -4112,7 +4121,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -4183,7 +4192,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -4224,7 +4233,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -4240,7 +4249,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "async-trait", @@ -4253,7 +4262,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-ipc", @@ -4298,7 +4307,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -5165,9 +5174,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0218004a4aae742209bee9c3cef05672f6b2708be36a50add8eb613b1f2a4008" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", ] @@ -5662,7 +5671,7 @@ dependencies = [ [[package]] name = "pylance" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -6289,9 +6298,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -6631,7 +6640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -6642,7 +6651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -8194,9 +8203,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -8382,18 +8391,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/python/Cargo.toml b/python/Cargo.toml index 91e804565df..a3542f7360f 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "5.0.0-beta.1" +version = "5.0.0-beta.2" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From ee71812c94daaf94ee62eaeab6182a2c97f1c6ae Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Mon, 30 Mar 2026 14:40:24 -0700 Subject: [PATCH 103/206] feat: make MAX_MINIBLOCK_VALUES configurable via env var (#6340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Converts `MAX_MINIBLOCK_VALUES` from a compile-time constant to a `LazyLock` that reads from the `LANCE_MINIBLOCK_MAX_VALUES` environment variable (default `4096`) - Updates all 6 usage sites across the encoding crate to dereference the `LazyLock` - Adds documentation in `docs/src/format/file/encoding.md` explaining the tuning knob and when it's useful Closes #6140 ## Test plan - [x] All existing miniblock tests pass (10 tests) - [x] All existing RLE tests pass (22 tests) - [x] `cargo clippy -p lance-encoding --tests -- -D warnings` clean - [ ] ~~Verify with a custom `LANCE_MINIBLOCK_MAX_VALUES` value that smaller mini-blocks are produced~~ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 42 +++++++++++ Cargo.toml | 1 + docs/src/format/file/encoding.md | 19 +++++ rust/lance-encoding/Cargo.toml | 1 + .../src/encodings/logical/primitive.rs | 2 +- .../encodings/logical/primitive/miniblock.rs | 69 ++++++++++++++++++- .../src/encodings/physical/binary.rs | 2 +- .../src/encodings/physical/rle.rs | 6 +- .../src/encodings/physical/value.rs | 2 +- 9 files changed, 135 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd3d59104ea..49ac3b56a23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4837,6 +4837,7 @@ dependencies = [ "rand 0.9.2", "rand_xoshiro", "rstest", + "serial_test", "snafu", "strum 0.26.3", "test-log", @@ -7671,6 +7672,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.29" @@ -7727,6 +7737,12 @@ dependencies = [ "sha2", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "security-framework" version = "3.7.0" @@ -7890,6 +7906,32 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/Cargo.toml b/Cargo.toml index dd4e0646a4c..c922eff6b8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -177,6 +177,7 @@ rustc_version = "0.4" serde = { version = "^1" } serde_json = { version = "1" } semver = "1.0" +serial_test = "3" slatedb = "0.3" snafu = "0.9" strum = "0.26" diff --git a/docs/src/format/file/encoding.md b/docs/src/format/file/encoding.md index 97b6c007b7d..0ff1f479b1c 100644 --- a/docs/src/format/file/encoding.md +++ b/docs/src/format/file/encoding.md @@ -670,3 +670,22 @@ in row-major format rather than the default columnar format. This reduces the nu random access but prevents reading individual fields independently. This is always opt-in and should only be used when all struct fields are typically accessed together. + +#### Mini-Block Size Tuning + +Each mini-block contains at most 4096 values by default. Because an entire mini-block must be fetched to +read any value within it, workloads that read only a small contiguous slice of each mini-block may experience +read amplification. + +The default is appropriate for the vast majority of deployments. Local disks and typical cloud object storage +(where the client and bucket are in the same region) have more than enough bandwidth that the overhead from +the default mini-block size is negligible. You should only consider changing this setting if you have +confirmed — through profiling — that mini-block read amplification is saturating your available bandwidth +(for example, accessing a remote object store over a constrained network link). + +The maximum number of values per mini-block can be lowered via an environment variable: + +- `LANCE_MINIBLOCK_MAX_VALUES` (default `4096`): upper bound on the number of values in a single mini-block chunk. + +Reducing this value produces smaller mini-blocks, which reduces the amount of data fetched per read at the +cost of more mini-blocks and slightly more metadata overhead. diff --git a/rust/lance-encoding/Cargo.toml b/rust/lance-encoding/Cargo.toml index c8f23f9b914..a12acd756e9 100644 --- a/rust/lance-encoding/Cargo.toml +++ b/rust/lance-encoding/Cargo.toml @@ -53,6 +53,7 @@ test-log.workspace = true criterion = { workspace = true } rand_xoshiro = { workspace = true } proptest.workspace = true +serial_test.workspace = true [build-dependencies] prost-build.workspace = true diff --git a/rust/lance-encoding/src/encodings/logical/primitive.rs b/rust/lance-encoding/src/encodings/logical/primitive.rs index f4f8b6e8627..f3ad7ca7e87 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive.rs @@ -3796,7 +3796,7 @@ impl PrimitiveStructuralEncoder { // A chunk has at most MAX_MINIBLOCK_VALUES data values. The levels-to-values // ratio tells us how many levels a chunk of that size would need. let levels_per_chunk = - (num_levels as f64 / num_values as f64) * miniblock::MAX_MINIBLOCK_VALUES as f64; + (num_levels as f64 / num_values as f64) * *miniblock::MAX_MINIBLOCK_VALUES as f64; levels_per_chunk > max_levels_per_chunk as f64 } diff --git a/rust/lance-encoding/src/encodings/logical/primitive/miniblock.rs b/rust/lance-encoding/src/encodings/logical/primitive/miniblock.rs index 6da985e9ec0..e3a4a4f4895 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive/miniblock.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive/miniblock.rs @@ -17,7 +17,19 @@ use crate::{buffer::LanceBuffer, data::DataBlock, format::pb21::CompressiveEncod use lance_core::Result; pub const MAX_MINIBLOCK_BYTES: u64 = 8 * 1024 - 6; -pub const MAX_MINIBLOCK_VALUES: u64 = 4096; + +const DEFAULT_MAX_MINIBLOCK_VALUES: u64 = 4096; + +fn parse_max_miniblock_values() -> u64 { + let val = std::env::var("LANCE_MINIBLOCK_MAX_VALUES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_MAX_MINIBLOCK_VALUES); + val.clamp(1, DEFAULT_MAX_MINIBLOCK_VALUES) +} + +pub static MAX_MINIBLOCK_VALUES: std::sync::LazyLock = + std::sync::LazyLock::new(parse_max_miniblock_values); /// Page data that has been compressed into a series of chunks put into /// a single buffer. @@ -36,10 +48,15 @@ pub struct MiniBlockCompressed { /// Mini-block chunks are designed to be small (just a few disk sectors) /// and contain a power-of-two number of values (except for the last chunk) /// -/// To enforce this we limit a chunk to 4Ki values and slightly less than +/// By default we limit a chunk to 4Ki values and slightly less than /// 8KiB of compressed data. This means that even in the extreme case /// where we have 4 bytes of rep/def then we will have at most 24KiB of /// data (values, repetition, and definition) per mini-block. +/// +/// The maximum number of values per chunk can be configured via the +/// `LANCE_MINIBLOCK_MAX_VALUES` environment variable. This is only +/// useful in extremely bandwidth-limited environments; the default is +/// appropriate for local disks and same-region cloud object storage. #[derive(Debug)] pub struct MiniBlockChunk { // The size in bytes of each buffer in the chunk. @@ -54,7 +71,7 @@ pub struct MiniBlockChunk { // For example, 1 would mean there are 2 values in the chunk and 12 would mean there // are 4Ki values in the chunk. // - // This must be <= 12 (i.e. <= 4096 values) + // This must be <= log2(MAX_MINIBLOCK_VALUES) (i.e. <= 12 at the default of 4096) pub log_num_values: u8, } @@ -87,3 +104,49 @@ pub trait MiniBlockCompressor: std::fmt::Debug + Send + Sync { /// used at decode time to read the data. fn compress(&self, page: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)>; } + +#[cfg(test)] +mod tests { + use serial_test::serial; + + use super::*; + + #[test] + #[serial] + fn test_parse_default() { + unsafe { std::env::remove_var("LANCE_MINIBLOCK_MAX_VALUES") }; + assert_eq!(parse_max_miniblock_values(), 4096); + } + + #[test] + #[serial] + fn test_parse_custom_value() { + unsafe { std::env::set_var("LANCE_MINIBLOCK_MAX_VALUES", "256") }; + assert_eq!(parse_max_miniblock_values(), 256); + unsafe { std::env::remove_var("LANCE_MINIBLOCK_MAX_VALUES") }; + } + + #[test] + #[serial] + fn test_parse_clamps_zero_to_one() { + unsafe { std::env::set_var("LANCE_MINIBLOCK_MAX_VALUES", "0") }; + assert_eq!(parse_max_miniblock_values(), 1); + unsafe { std::env::remove_var("LANCE_MINIBLOCK_MAX_VALUES") }; + } + + #[test] + #[serial] + fn test_parse_clamps_above_max() { + unsafe { std::env::set_var("LANCE_MINIBLOCK_MAX_VALUES", "99999") }; + assert_eq!(parse_max_miniblock_values(), DEFAULT_MAX_MINIBLOCK_VALUES); + unsafe { std::env::remove_var("LANCE_MINIBLOCK_MAX_VALUES") }; + } + + #[test] + #[serial] + fn test_parse_invalid_falls_back_to_default() { + unsafe { std::env::set_var("LANCE_MINIBLOCK_MAX_VALUES", "not_a_number") }; + assert_eq!(parse_max_miniblock_values(), DEFAULT_MAX_MINIBLOCK_VALUES); + unsafe { std::env::remove_var("LANCE_MINIBLOCK_MAX_VALUES") }; + } +} diff --git a/rust/lance-encoding/src/encodings/physical/binary.rs b/rust/lance-encoding/src/encodings/physical/binary.rs index 7f52dd816c8..d02cf2da693 100644 --- a/rust/lance-encoding/src/encodings/physical/binary.rs +++ b/rust/lance-encoding/src/encodings/physical/binary.rs @@ -186,7 +186,7 @@ fn search_next_offset_idx( let new_size = existing_bytes + N::from_usize((new_num_values + 1) * N::get_byte_width()).unwrap(); if new_size.to_i64().unwrap() <= minichunk_size { - if new_num_values * 2 > MAX_MINIBLOCK_VALUES as usize { + if new_num_values * 2 > *MAX_MINIBLOCK_VALUES as usize { // hit the max number of values limit break; } diff --git a/rust/lance-encoding/src/encodings/physical/rle.rs b/rust/lance-encoding/src/encodings/physical/rle.rs index 31eab63ed5c..88e27bf954f 100644 --- a/rust/lance-encoding/src/encodings/physical/rle.rs +++ b/rust/lance-encoding/src/encodings/physical/rle.rs @@ -205,7 +205,7 @@ impl RleEncoder { let type_size = std::mem::size_of::(); let chunk_start = offset * type_size; - let max_by_count = MAX_MINIBLOCK_VALUES as usize; + let max_by_count = *MAX_MINIBLOCK_VALUES as usize; let max_values = values_remaining.min(max_by_count); let chunk_end = chunk_start + max_values * type_size; @@ -239,7 +239,7 @@ impl RleEncoder { 2 => 7, // 128 _ => 6, // 64 }; - let max_checkpoint_log2 = (values_remaining.min(MAX_MINIBLOCK_VALUES as usize)) + let max_checkpoint_log2 = (values_remaining.min(*MAX_MINIBLOCK_VALUES as usize)) .next_power_of_two() .ilog2(); let mut checkpoint_log2 = min_checkpoint_log2; @@ -718,7 +718,7 @@ mod tests { assert!(chunk.log_num_values > 0); let chunk_values = 1u64 << chunk.log_num_values; assert!(chunk_values.is_power_of_two()); - assert!(chunk_values <= MAX_MINIBLOCK_VALUES); + assert!(chunk_values <= *MAX_MINIBLOCK_VALUES); } else { assert_eq!(chunk.log_num_values, 0); } diff --git a/rust/lance-encoding/src/encodings/physical/value.rs b/rust/lance-encoding/src/encodings/physical/value.rs index ced3886a13d..c49bbd3efbd 100644 --- a/rust/lance-encoding/src/encodings/physical/value.rs +++ b/rust/lance-encoding/src/encodings/physical/value.rs @@ -38,7 +38,7 @@ impl ValueEncoder { // If the type is so wide that we can't even fit 2 values we shouldn't be here assert!(size_bytes < MAX_MINIBLOCK_BYTES); - while 2 * size_bytes < MAX_MINIBLOCK_BYTES && 2 * num_vals <= MAX_MINIBLOCK_VALUES { + while 2 * size_bytes < MAX_MINIBLOCK_BYTES && 2 * num_vals <= *MAX_MINIBLOCK_VALUES { log_num_vals += 1; size_bytes *= 2; num_vals *= 2; From 0c754aa07aa58506d10a674180f005f68190cfb6 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Mon, 30 Mar 2026 15:32:18 -0700 Subject: [PATCH 104/206] fix: use StorageOptions::new() in cloud providers to pick up env vars (#6316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Cloud providers (AWS, Azure, GCP) constructed `StorageOptions` using the raw tuple struct constructor, bypassing `StorageOptions::new()` which reads `OBJECT_STORE_CLIENT_MAX_RETRIES` and `OBJECT_STORE_CLIENT_RETRY_TIMEOUT` from environment variables - This meant those env vars were silently ignored — the default (10) was always used regardless of what was set in the environment ## Test plan - [ ] Set `OBJECT_STORE_CLIENT_MAX_RETRIES=1` and verify the object store client respects it - [ ] Verify `OBJECT_STORE_CLIENT_RETRY_TIMEOUT` is also picked up 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 --- rust/lance-io/src/object_store/providers/aws.rs | 2 +- rust/lance-io/src/object_store/providers/azure.rs | 2 +- rust/lance-io/src/object_store/providers/gcp.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/lance-io/src/object_store/providers/aws.rs b/rust/lance-io/src/object_store/providers/aws.rs index a06fcaef140..f7ba1f6ad66 100644 --- a/rust/lance-io/src/object_store/providers/aws.rs +++ b/rust/lance-io/src/object_store/providers/aws.rs @@ -132,7 +132,7 @@ impl ObjectStoreProvider for AwsStoreProvider { ) -> Result { let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE); let mut storage_options = - StorageOptions(params.storage_options().cloned().unwrap_or_default()); + StorageOptions::new(params.storage_options().cloned().unwrap_or_default()); storage_options.with_env_s3(); let download_retry_count = storage_options.download_retry_count(); diff --git a/rust/lance-io/src/object_store/providers/azure.rs b/rust/lance-io/src/object_store/providers/azure.rs index 647d324bd3e..ae7fc1d19ff 100644 --- a/rust/lance-io/src/object_store/providers/azure.rs +++ b/rust/lance-io/src/object_store/providers/azure.rs @@ -147,7 +147,7 @@ impl ObjectStoreProvider for AzureBlobStoreProvider { let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE); let mut storage_options = - StorageOptions(params.storage_options().cloned().unwrap_or_default()); + StorageOptions::new(params.storage_options().cloned().unwrap_or_default()); storage_options.with_env_azure(); let download_retry_count = storage_options.download_retry_count(); diff --git a/rust/lance-io/src/object_store/providers/gcp.rs b/rust/lance-io/src/object_store/providers/gcp.rs index f234a8e31ee..e0f42179b19 100644 --- a/rust/lance-io/src/object_store/providers/gcp.rs +++ b/rust/lance-io/src/object_store/providers/gcp.rs @@ -92,7 +92,7 @@ impl ObjectStoreProvider for GcsStoreProvider { async fn new_store(&self, base_path: Url, params: &ObjectStoreParams) -> Result { let block_size = params.block_size.unwrap_or(DEFAULT_CLOUD_BLOCK_SIZE); let mut storage_options = - StorageOptions(params.storage_options().cloned().unwrap_or_default()); + StorageOptions::new(params.storage_options().cloned().unwrap_or_default()); storage_options.with_env_gcs(); let download_retry_count = storage_options.download_retry_count(); From 2e102831a3ebf31ed1d11c69c661ced3d019f2a2 Mon Sep 17 00:00:00 2001 From: YSBF Date: Tue, 31 Mar 2026 08:04:23 +0800 Subject: [PATCH 105/206] =?UTF-8?q?perf:=20remove=20O(n=C2=B2)=20performan?= =?UTF-8?q?ce=20regression=20in=20take()=20with=20duplicate=20indices=20(#?= =?UTF-8?q?6351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: take() with sorted-but-duplicate indices (e.g., ML sliding window sampling) degrades from O(n) to O(n²). Benchmarked: 2.4M indices with 90% duplicates takes 57 seconds instead of 120ms. Root cause (two bugs): 1. check_row_addrs() uses strict `>` to detect sorted order. Duplicate adjacent values (addr == last_offset) are misclassified as "unsorted", routing to the slow fallback path. 2. The unsorted fallback remaps via `.position()` linear scan — O(N*M) where N=original indices, M=deduplicated rows. For 2.4M × 249K = 308 billion comparisons ≈ 57 seconds. Fix: 1. Change `>` to `>=` in check_row_addrs so sorted-with-duplicates correctly takes the fast "sorted" branch. 2. Replace `.position()` linear scan with HashMap lookup (O(1) per element) as defense-in-depth for truly unsorted input. Benchmark (1024 sliding windows, 2410 rows each, stride 241): Before: 57,000ms After: 120ms (475× speedup) The sorted fast path already handles duplicates correctly via fragment-level take_as_batch() which has its own dedup logic. Co-authored-by: YSBF --- rust/lance/src/dataset/take.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/rust/lance/src/dataset/take.rs b/rust/lance/src/dataset/take.rs index 68121410f01..7beaa9ad206 100644 --- a/rust/lance/src/dataset/take.rs +++ b/rust/lance/src/dataset/take.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::{collections::BTreeMap, ops::Range, pin::Pin, sync::Arc}; +use std::{collections::BTreeMap, collections::HashMap, ops::Range, pin::Pin, sync::Arc}; use crate::dataset::fragment::FragReadConfig; use crate::dataset::rowids::get_row_id_index; @@ -333,14 +333,14 @@ async fn do_take_rows( .as_primitive::() .values(); + let addr_to_pos: HashMap = returned_row_addr + .iter() + .enumerate() + .map(|(i, addr)| (*addr, i as u64)) + .collect(); let remapping_index: UInt64Array = row_addrs .iter() - .filter_map(|o| { - returned_row_addr - .iter() - .position(|id| id == o) - .map(|pos| pos as u64) - }) + .filter_map(|o| addr_to_pos.get(o).copied()) .collect(); // remapping_index may be greater than the number of rows in one_batch @@ -449,7 +449,7 @@ fn check_row_addrs(row_addrs: &[u64]) -> RowAddressStats { let first_fragment_id = row_addrs[0] >> 32; for addr in row_addrs.iter().skip(1) { - sorted &= *addr > last_offset; + sorted &= *addr >= last_offset; contiguous &= *addr == last_offset + 1; // Contiguous also requires the fragment ids are all the same contiguous &= (*addr >> 32) == first_fragment_id; From da46e7e624bc225868286b630894bd884dbaa80d Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Tue, 31 Mar 2026 15:56:44 +0800 Subject: [PATCH 106/206] refactor: remove row-id ordering from vector index merge (#6332) This removes the `ROW_ID` sort from distributed vector segment finalization. The merge path now preserves incoming batch order instead of canonicalizing partition contents by `ROW_ID`, which avoids the `FixedSizeList` `take` overflow that was hitting very large IVF_PQ partitions. A focused Rust regression test now covers large `FixedSizeList` partition batches and verifies we preserve input order without triggering the Arrow overflow. --- python/python/tests/test_vector_index.py | 102 ----------- rust/lance/src/index/vector/builder.rs | 213 ++++++++++++----------- 2 files changed, 110 insertions(+), 205 deletions(-) diff --git a/python/python/tests/test_vector_index.py b/python/python/tests/test_vector_index.py index 0c1d5ab9ed4..b20ffc8cf7a 100644 --- a/python/python/tests/test_vector_index.py +++ b/python/python/tests/test_vector_index.py @@ -2765,108 +2765,6 @@ def test_index_segment_builder_builds_vector_segments(tmp_path): assert 0 < len(results) <= 5 -def test_distributed_ivf_pq_order_invariance(tmp_path: Path): - """Ensure distributed IVF_PQ build is invariant to shard build order.""" - ds = _make_sample_dataset_base(tmp_path, "dist_ds", 2000, 128) - - # Global IVF+PQ training once; artifacts are reused across shard orders. - builder = IndicesBuilder(ds, "vector") - pre = builder.prepare_global_ivf_pq( - num_partitions=4, - num_subvectors=16, - distance_type="l2", - sample_rate=7, - ) - - # Copy the dataset twice so index manifests do not clash and we can vary - # the shard build order independently on identical data. - ds_order_12 = lance.write_dataset( - ds.to_table(), tmp_path / "pq_order_node1_node2", max_rows_per_file=500 - ) - ds_order_21 = lance.write_dataset( - ds.to_table(), tmp_path / "pq_order_node2_node1", max_rows_per_file=500 - ) - - # For each copy, derive two shard groups from its own fragments. - frags_12 = ds_order_12.get_fragments() - if len(frags_12) < 2: - pytest.skip("Need at least 2 fragments for distributed indexing (order_12)") - mid_12 = len(frags_12) // 2 - node1_12 = [f.fragment_id for f in frags_12[:mid_12]] - node2_12 = [f.fragment_id for f in frags_12[mid_12:]] - if not node1_12 or not node2_12: - pytest.skip("Failed to split fragments into two non-empty groups (order_12)") - - frags_21 = ds_order_21.get_fragments() - if len(frags_21) < 2: - pytest.skip("Need at least 2 fragments for distributed indexing (order_21)") - mid_21 = len(frags_21) // 2 - node1_21 = [f.fragment_id for f in frags_21[:mid_21]] - node2_21 = [f.fragment_id for f in frags_21[mid_21:]] - if not node1_21 or not node2_21: - pytest.skip("Failed to split fragments into two non-empty groups (order_21)") - - def build_distributed_ivf_pq(ds_copy, shard_order): - try: - segments = _build_segments( - ds_copy, - "vector", - "IVF_PQ", - shard_order, - index_name="vector_idx", - num_partitions=4, - num_sub_vectors=16, - ivf_centroids=pre["ivf_centroids"], - pq_codebook=pre["pq_codebook"], - ) - segments = ( - ds_copy.create_index_segment_builder() - .with_segments(segments) - .build_all() - ) - return _commit_segments_helper(ds_copy, segments, column="vector") - except ValueError as e: - raise e - - ds_12 = build_distributed_ivf_pq(ds_order_12, [node1_12, node2_12]) - ds_21 = build_distributed_ivf_pq(ds_order_21, [node2_21, node1_21]) - - # Sample queries once from the original dataset and reuse for both index builds - # to check order invariance under distributed PQ training and merging. - k = 10 - sample_tbl = ds.sample(10, columns=["vector"]) - queries = [ - np.asarray(v, dtype=np.float32) for v in sample_tbl["vector"].to_pylist() - ] - - def collect_ids_and_distances(ds_with_index): - ids_per_query = [] - dists_per_query = [] - for q in queries: - tbl = ds_with_index.to_table( - columns=["id", "_distance"], - nearest={ - "column": "vector", - "q": q, - "k": k, - "nprobes": 16, - "refine_factor": 100, - }, - ) - ids_per_query.append([int(x) for x in tbl["id"].to_pylist()]) - dists_per_query.append(tbl["_distance"].to_numpy()) - return ids_per_query, dists_per_query - - ids_12, dists_12 = collect_ids_and_distances(ds_12) - ids_21, dists_21 = collect_ids_and_distances(ds_21) - - # TopK ids must match exactly and distances must be numerically stable across - # different shard build orders (allow tiny floating error). - assert ids_12 == ids_21 - for a, b in zip(dists_12, dists_21): - assert np.allclose(a, b, atol=1e-6) - - def test_fts_filter_vector_search(tmp_path): # Create dataset with vector and text columns ids = list(range(1, 301)) diff --git a/rust/lance/src/index/vector/builder.rs b/rust/lance/src/index/vector/builder.rs index 017d50319f8..24298cbba18 100644 --- a/rust/lance/src/index/vector/builder.rs +++ b/rust/lance/src/index/vector/builder.rs @@ -34,7 +34,7 @@ use lance_index::frag_reuse::FragReuseIndex; use lance_index::metrics::NoOpMetricsCollector; use lance_index::optimize::OptimizeOptions; use lance_index::progress::{IndexBuildProgress, NoopIndexBuildProgress}; -use lance_index::vector::bq::storage::{RABIT_CODE_COLUMN, pack_codes, unpack_codes}; +use lance_index::vector::bq::storage::{RABIT_CODE_COLUMN, unpack_codes}; use lance_index::vector::kmeans::KMeansParams; use lance_index::vector::pq::storage::transpose; use lance_index::vector::quantizer::{ @@ -88,26 +88,6 @@ use super::{ utils::{self, get_vector_type}, }; -/// Stably sort a RecordBatch by the ROW_ID column in ascending order. -/// -/// If the batch has no ROW_ID column or has fewer than 2 rows, it is -/// returned unchanged. When sorting, the relative order of rows with the -/// same ROW_ID is preserved. -fn stable_sort_batch_by_row_id(batch: &RecordBatch) -> Result { - if let Some(row_id_col) = batch.column_by_name(ROW_ID) { - let row_ids = row_id_col.as_primitive::(); - if row_ids.len() > 1 { - let mut order: Vec = (0..row_ids.len()).collect(); - // Vec::sort_by is stable, so equal ROW_IDs keep their - // original relative order. - order.sort_by(|&i, &j| row_ids.value(i).cmp(&row_ids.value(j))); - let indices = UInt32Array::from_iter_values(order.into_iter().map(|i| i as u32)); - return Ok(batch.take(&indices)?); - } - } - Ok(batch.clone()) -} - // the number of partitions to evaluate for reassigning const REASSIGN_RANGE: usize = 64; @@ -996,14 +976,6 @@ impl IvfIndexBuilder _ => {} } - // Normalize each batch for this partition to be stably sorted by ROW_ID. - for batch in part_batches.iter_mut() { - if batch.num_rows() == 0 { - continue; - } - *batch = stable_sort_batch_by_row_id(batch)?; - } - batches.extend(part_batches); } @@ -1027,9 +999,7 @@ impl IvfIndexBuilder .get(LOSS_METADATA_KEY) .map(|s| s.parse::().unwrap_or(0.0)) .unwrap_or(0.0); - let batch = batch.drop_column(PART_ID_COLUMN)?; - let batch = stable_sort_batch_by_row_id(&batch)?; - batches.push(batch); + batches.push(batch.drop_column(PART_ID_COLUMN)?); } } @@ -1098,79 +1068,43 @@ impl IvfIndexBuilder if storage.len() == 0 { storage_ivf.add_partition(0); } else { - let batches = storage.to_batches()?.collect::>(); - let mut batch = - arrow::compute::concat_batches(&batches[0].schema(), batches.iter())?; - - if is_pq && batch.column_by_name(PQ_CODE_COLUMN).is_some() { - // The PQ storage keeps codes in a transposed layout (bytes grouped - // across all rows). Convert them back to per-row layout so that a - // stable ROW_ID sort moves PQ_CODE_COLUMN together with ROW_ID. - let codes_fsl = batch - .column_by_name(PQ_CODE_COLUMN) - .unwrap() - .as_fixed_size_list(); - let num_rows = batch.num_rows(); - let bytes_per_code = codes_fsl.value_length() as usize; - let codes = codes_fsl.values().as_primitive::(); - let original_codes = transpose(codes, bytes_per_code, num_rows); - let original_fsl = Arc::new(FixedSizeListArray::try_new_from_values( - original_codes, - bytes_per_code as i32, - )?); - batch = batch.replace_column_by_name(PQ_CODE_COLUMN, original_fsl)?; - } - - if is_rq && batch.column_by_name(RABIT_CODE_COLUMN).is_some() { - // RQ storage batches reaching merge_partitions always come - // from RabitQuantizationStorage, which canonicalizes codes - // into packed layout in try_from_batch/remap. Materialize - // row-major bytes so row-wise sort operates on per-row codes. - let codes_fsl = batch - .column_by_name(RABIT_CODE_COLUMN) - .unwrap() - .as_fixed_size_list(); - let unpacked = Arc::new(unpack_codes(codes_fsl)); - batch = batch.replace_column_by_name(RABIT_CODE_COLUMN, unpacked)?; - } + for mut batch in storage.to_batches()? { + if is_pq + && !self.transpose_codes + && batch.num_rows() > 0 + && batch.column_by_name(PQ_CODE_COLUMN).is_some() + { + let codes_fsl = batch + .column_by_name(PQ_CODE_COLUMN) + .unwrap() + .as_fixed_size_list(); + let num_rows = batch.num_rows(); + let bytes_per_code = codes_fsl.value_length() as usize; + let codes = codes_fsl.values().as_primitive::(); + let original_codes = transpose(codes, bytes_per_code, num_rows); + let original_fsl = Arc::new(FixedSizeListArray::try_new_from_values( + original_codes, + bytes_per_code as i32, + )?); + batch = batch.replace_column_by_name(PQ_CODE_COLUMN, original_fsl)?; + } - // Enforce a stable ROW_ID ordering for all auxiliary batches so that the - // PQ code column moves together with ROW_ID. - batch = stable_sort_batch_by_row_id(&batch)?; - - // For PQ storages, optionally convert codes back to transposed layout - // in the unified auxiliary file. This keeps final PQ storage column-major - // when `transpose_pq_codes` is enabled. - if is_pq && self.transpose_codes && batch.column_by_name(PQ_CODE_COLUMN).is_some() { - let codes_fsl = batch - .column_by_name(PQ_CODE_COLUMN) - .unwrap() - .as_fixed_size_list(); - let num_rows = batch.num_rows(); - let bytes_per_code = codes_fsl.value_length() as usize; - let codes = codes_fsl.values().as_primitive::(); - let transposed_codes = transpose(codes, num_rows, bytes_per_code); - let transposed_fsl = Arc::new(FixedSizeListArray::try_new_from_values( - transposed_codes, - bytes_per_code as i32, - )?); - batch = batch.replace_column_by_name(PQ_CODE_COLUMN, transposed_fsl)?; - } + if is_rq + && !self.transpose_codes + && batch.num_rows() > 0 + && batch.column_by_name(RABIT_CODE_COLUMN).is_some() + { + let codes_fsl = batch + .column_by_name(RABIT_CODE_COLUMN) + .unwrap() + .as_fixed_size_list(); + let unpacked = Arc::new(unpack_codes(codes_fsl)); + batch = batch.replace_column_by_name(RABIT_CODE_COLUMN, unpacked)?; + } - if is_rq - && self.transpose_codes - && batch.column_by_name(RABIT_CODE_COLUMN).is_some() - { - let codes_fsl = batch - .column_by_name(RABIT_CODE_COLUMN) - .unwrap() - .as_fixed_size_list(); - let packed = Arc::new(pack_codes(codes_fsl)); - batch = batch.replace_column_by_name(RABIT_CODE_COLUMN, packed)?; + storage_writer.write_batch(&batch).await?; + storage_ivf.add_partition(batch.num_rows() as u32); } - - storage_writer.write_batch(&batch).await?; - storage_ivf.add_partition(batch.num_rows() as u32); } let index_batch = index.to_batch()?; @@ -2155,9 +2089,44 @@ pub(crate) fn index_type_string(sub_index: SubIndexType, quantizer: Quantization #[cfg(test)] mod tests { use super::*; - use arrow_array::Float32Array; + use arrow_array::{Array, Float32Array, NullArray}; use lance_index::vector::flat::index::{FlatIndex, FlatQuantizer}; + struct SingleBatchReader { + batch: RecordBatch, + partition_id: usize, + } + + #[async_trait::async_trait] + impl ShuffleReader for SingleBatchReader { + async fn read_partition( + &self, + partition_id: usize, + ) -> Result>> { + if partition_id != self.partition_id || self.batch.num_rows() == 0 { + return Ok(None); + } + + let schema = self.batch.schema(); + let stream = stream::iter(vec![Ok(self.batch.clone())]); + Ok(Some(Box::new(RecordBatchStreamAdapter::new( + schema, stream, + )))) + } + + fn partition_size(&self, partition_id: usize) -> Result { + Ok(if partition_id == self.partition_id { + self.batch.num_rows() + } else { + 0 + }) + } + + fn total_loss(&self) -> Option { + None + } + } + #[test] fn select_reassign_candidates_skips_deleted_partition() { let dim = 4; @@ -2231,4 +2200,42 @@ mod tests { other => panic!("unexpected op: {:?}", other), } } + + #[tokio::test] + async fn take_partition_batches_preserves_partition_order_for_large_fixed_size_list() { + let value_length = 1_073_741_824i32; + let num_rows = 5usize; + let row_ids = UInt64Array::from(vec![4_u64, 3, 2, 1, 0]); + let part_ids = UInt32Array::from(vec![0_u32; num_rows]); + let values = Arc::new(NullArray::new(num_rows * value_length as usize)); + let item_field = Arc::new(Field::new("item", DataType::Null, true)); + let codes = FixedSizeListArray::try_new(item_field, value_length, values, None).unwrap(); + let batch = RecordBatch::try_new( + Arc::new(arrow_schema::Schema::new(vec![ + ROW_ID_FIELD.clone(), + PART_ID_FIELD.clone(), + Field::new(PQ_CODE_COLUMN, codes.data_type().clone(), true), + ])), + vec![Arc::new(row_ids), Arc::new(part_ids), Arc::new(codes)], + ) + .unwrap(); + let reader = SingleBatchReader { + batch, + partition_id: 0, + }; + + let (batches, loss) = IvfIndexBuilder::::take_partition_batches( + 0, + &[], + Some(&reader), + ) + .await + .unwrap(); + + assert_eq!(loss, 0.0); + assert_eq!(batches.len(), 1); + assert!(batches[0].column_by_name(PART_ID_COLUMN).is_none()); + let row_ids = batches[0][ROW_ID].as_primitive::(); + assert_eq!(row_ids.values(), &[4, 3, 2, 1, 0]); + } } From b7f6baa5111ad4d45667fe07c1a79c0fc1093bb8 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Tue, 31 Mar 2026 16:03:57 +0800 Subject: [PATCH 107/206] fix: avoid full scan for nullable vector fragment sampling (#6341) This fixes an OOM in vector index training when `fragment_ids` are provided for a nullable FixedSizeList vector column. Instead of materializing all selected training rows and then sampling, the nullable fragment-filtered path now samples in chunks and filters nulls batch by batch, keeping memory bounded by the requested sample size. --- rust/lance/src/dataset.rs | 3 +- rust/lance/src/dataset/take.rs | 2 +- rust/lance/src/index/vector/utils.rs | 171 ++++++++++++++++++----- rust/lance/tests/resource_test/mod.rs | 1 + rust/lance/tests/resource_test/vector.rs | 81 +++++++++++ 5 files changed, 223 insertions(+), 35 deletions(-) create mode 100644 rust/lance/tests/resource_test/vector.rs diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index aec6010b33a..6b244bcacef 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -60,7 +60,6 @@ use std::fmt::Debug; use std::ops::Range; use std::pin::Pin; use std::sync::Arc; -use take::row_offsets_to_row_addresses; use tracing::{info, instrument}; pub(crate) mod blob; @@ -88,6 +87,8 @@ pub mod updater; mod utils; pub mod write; +pub(crate) use take::row_offsets_to_row_addresses; + use self::builder::DatasetBuilder; use self::cleanup::RemovalStats; use self::fragment::FileFragment; diff --git a/rust/lance/src/dataset/take.rs b/rust/lance/src/dataset/take.rs index 7beaa9ad206..95c71b41833 100644 --- a/rust/lance/src/dataset/take.rs +++ b/rust/lance/src/dataset/take.rs @@ -43,7 +43,7 @@ use super::{Dataset, fragment::FileFragment, scanner::DatasetRecordBatchStream}; /// to the address (1, 10), assuming the second fragment starts with 10 undeleted rows. /// /// If any offsets are beyond the end of the dataset, they will be mapped to a tombstone row address. -pub(super) async fn row_offsets_to_row_addresses( +pub async fn row_offsets_to_row_addresses( fragments: &[FileFragment], row_indices: &[u64], ) -> Result> { diff --git a/rust/lance/src/index/vector/utils.rs b/rust/lance/src/index/vector/utils.rs index f00a81b764d..19156ac8eed 100644 --- a/rust/lance/src/index/vector/utils.rs +++ b/rust/lance/src/index/vector/utils.rs @@ -1,14 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::collections::HashSet; +use std::pin::Pin; use std::sync::Arc; use arrow::array::ArrayData; use arrow::datatypes::DataType; use arrow_array::new_empty_array; -use arrow_array::{Array, ArrayRef, FixedSizeListArray, RecordBatch, UInt32Array, cast::AsArray}; +use arrow_array::{Array, ArrayRef, FixedSizeListArray, RecordBatch, cast::AsArray}; use arrow_buffer::{Buffer, MutableBuffer}; -use futures::StreamExt; +use futures::{Stream, StreamExt, stream}; use lance_arrow::DataTypeExt; use lance_core::datatypes::Schema; use lance_linalg::distance::DistanceType; @@ -18,7 +20,7 @@ use rand::seq::{IteratorRandom, SliceRandom}; use rand::{Rng, SeedableRng}; use tokio::sync::Mutex; -use crate::dataset::Dataset; +use crate::dataset::{Dataset, ProjectionRequest, TakeBuilder, row_offsets_to_row_addresses}; use crate::{Error, Result}; /// Helper function to extract a column from a RecordBatch, supporting nested field paths. @@ -427,34 +429,34 @@ async fn sample_training_data( is_nullable: bool, fragment_ids: Option<&[u32]>, ) -> Result { - if fragment_ids.is_some() { + let byte_width = vector_field + .data_type() + .byte_width_opt() + .unwrap_or(4 * 1024); + + if let Some(fragment_ids) = fragment_ids { if !is_nullable { let projection = dataset.schema().project(&[column])?; let batch = dataset - .sample(sample_size_hint, &projection, fragment_ids) + .sample(sample_size_hint, &projection, Some(fragment_ids)) .await?; return vector_column_to_fsl(&batch, column); } - - let batch = scan_all_training_data(dataset, column, is_nullable, fragment_ids).await?; - let training_data = vector_column_to_fsl(&batch, column)?; - if training_data.len() <= sample_size_hint { - return Ok(training_data); - } - let indices = UInt32Array::from_iter_values( - generate_random_indices(training_data.len(), sample_size_hint) - .into_iter() - .map(|index| index as u32), - ); - let sampled = arrow_select::take::take(&training_data, &indices, None)?; - return Ok(sampled.as_fixed_size_list().clone()); + let scan = sample_training_data_scan_from_fragments( + dataset, + column, + sample_size_hint, + num_rows, + fragment_ids, + )?; + return match vector_field.data_type() { + DataType::FixedSizeList(_, _) => { + sample_nullable_fsl(column, sample_size_hint, byte_width, vector_field, scan).await + } + _ => sample_nullable_fallback(column, sample_size_hint, is_nullable, scan).await, + }; } - let byte_width = vector_field - .data_type() - .byte_width_opt() - .unwrap_or(4 * 1024); - match vector_field.data_type() { DataType::FixedSizeList(_, _) if !is_nullable && fragment_ids.is_none() => { sample_fsl_uniform( @@ -497,6 +499,105 @@ fn sample_training_data_scan( )) } +/// Build a batch stream over fragment-limited random samples. +/// +/// This is the only extra sampling helper we keep for ENT-1099. The existing +/// range-based scan only works for dataset-wide offsets, while fragment-limited +/// sampling must first map random offsets within the selected fragments to row +/// addresses and then `take` those rows. Both nullable FSL and multivector +/// paths reuse this stream to avoid duplicating fragment sampling logic. +fn sample_training_data_scan_from_fragments( + dataset: &Dataset, + column: &str, + sample_size_hint: usize, + num_rows: usize, + fragment_ids: &[u32], +) -> Result> + Send>>> { + if fragment_ids.is_empty() { + return Err(Error::invalid_input( + "Training fragment filter must not be empty".to_string(), + )); + } + + let mut ordered_ids = fragment_ids.to_vec(); + ordered_ids.sort_unstable(); + ordered_ids.dedup(); + let selected_fragments = dataset + .get_frags_from_ordered_ids(&ordered_ids) + .into_iter() + .zip(ordered_ids.iter()) + .map(|(fragment, fragment_id)| { + fragment.ok_or_else(|| { + Error::invalid_input(format!( + "Unknown fragment id {fragment_id} in training fragment filter" + )) + }) + }) + .collect::>>()?; + let dataset = Arc::new(dataset.clone()); + let projection = Arc::new( + ProjectionRequest::from(dataset.schema().project(&[column])?) + .into_projection_plan(dataset.clone())?, + ); + + let stream = stream::try_unfold( + ( + dataset, + projection, + selected_fragments, + HashSet::::with_capacity(sample_size_hint.min(num_rows)), + SmallRng::from_os_rng(), + ), + move |(dataset, projection, selected_fragments, mut seen_offsets, mut rng)| async move { + if seen_offsets.len() >= num_rows { + return Ok(None); + } + + let remaining = num_rows.saturating_sub(seen_offsets.len()); + let target = sample_size_hint.saturating_mul(2).min(remaining); + let mut sampled_offsets = if remaining <= target.saturating_mul(4) { + let mut unseen_indices = (0..num_rows as u64) + .filter(|index| !seen_offsets.contains(index)) + .collect::>(); + unseen_indices.shuffle(&mut rng); + unseen_indices.truncate(target); + seen_offsets.extend(unseen_indices.iter().copied()); + unseen_indices + } else { + let mut sampled_offsets = Vec::with_capacity(target); + while sampled_offsets.len() < target { + let index = rng.random_range(0..num_rows as u64); + if seen_offsets.insert(index) { + sampled_offsets.push(index); + } + } + sampled_offsets + }; + if sampled_offsets.is_empty() { + return Ok(None); + } + sampled_offsets.sort_unstable(); + + let mut row_addrs = + row_offsets_to_row_addresses(&selected_fragments, &sampled_offsets).await?; + row_addrs.sort_unstable(); + + let batch = TakeBuilder::try_new_from_addresses( + dataset.clone(), + row_addrs, + projection.clone(), + )? + .execute() + .await?; + Ok(Some(( + batch, + (dataset, projection, selected_fragments, seen_offsets, rng), + ))) + }, + ); + Ok(Box::pin(stream)) +} + fn resolve_scan_fragments( dataset: &Dataset, fragment_ids: &[u32], @@ -570,13 +671,16 @@ fn fsl_values_to_array( /// accumulate non-null vector bytes directly into a flat buffer, dropping /// each source batch immediately. This keeps peak memory proportional to the /// output sample rather than the input scan. -async fn sample_nullable_fsl( +async fn sample_nullable_fsl( column: &str, sample_size_hint: usize, byte_width: usize, vector_field: &lance_core::datatypes::Field, - mut scan: crate::dataset::scanner::DatasetRecordBatchStream, -) -> Result { + mut scan: S, +) -> Result +where + S: Stream> + Unpin, +{ let mut values_buf = MutableBuffer::with_capacity(sample_size_hint * byte_width); let mut num_non_null: usize = 0; @@ -602,7 +706,6 @@ async fn sample_nullable_fsl( fsl_values_to_array(vector_field, values_buf, num_rows_out) } - /// True uniform random sampling for non-nullable FixedSizeList columns. /// /// Generates truly random row indices, sorts them, and fetches via @@ -679,12 +782,15 @@ fn accumulate_fsl_values( /// Fallback sampling for non-FixedSizeList columns (e.g. multivector List /// columns). Collects batches and concatenates them. When `is_nullable` is /// true, filters null rows from each batch. -async fn sample_nullable_fallback( +async fn sample_nullable_fallback( column: &str, sample_size_hint: usize, is_nullable: bool, - mut scan: crate::dataset::scanner::DatasetRecordBatchStream, -) -> Result { + mut scan: S, +) -> Result +where + S: Stream> + Unpin, +{ let mut schema = None; let mut filtered = Vec::new(); let mut num_non_null: usize = 0; @@ -834,13 +940,12 @@ fn random_ranges( mod tests { use super::*; - use arrow_array::{Float32Array, types::Float32Type}; + use crate::dataset::InsertBuilder; + use arrow_array::{ArrayRef, Float32Array, types::Float32Type}; use arrow_schema::{DataType, Field}; use lance_arrow::FixedSizeListArrayExt; use lance_datagen::{ArrayGeneratorExt, Dimension, RowCount, array, gen_batch}; - use crate::dataset::InsertBuilder; - #[rstest::rstest] #[test] fn test_random_ranges( diff --git a/rust/lance/tests/resource_test/mod.rs b/rust/lance/tests/resource_test/mod.rs index 80ec1ab9d20..15951e92fbd 100644 --- a/rust/lance/tests/resource_test/mod.rs +++ b/rust/lance/tests/resource_test/mod.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors mod utils; +mod vector; mod write; diff --git a/rust/lance/tests/resource_test/vector.rs b/rust/lance/tests/resource_test/vector.rs new file mode 100644 index 00000000000..dcd299284ae --- /dev/null +++ b/rust/lance/tests/resource_test/vector.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +use super::utils::AllocTracker; +use all_asserts::assert_le; +use arrow_array::{Array, ArrayRef, RecordBatch, RecordBatchIterator, types::Float32Type}; +use arrow_schema::{DataType, Field, Schema}; +use lance::Dataset; +use lance::dataset::WriteParams; +use lance::index::vector::utils::maybe_sample_training_data; +use lance_arrow::FixedSizeListArrayExt; + +#[tokio::test] +async fn test_nullable_fragment_sampling_memory_stays_bounded() { + let dim = 1024; + let num_fragments = 4; + let rows_per_fragment = 8_192; + let sample_size = 32; + let schema = Arc::new(Schema::new(vec![Field::new( + "vec", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), dim), + true, + )])); + + let batches = (0..num_fragments) + .map(|seed| { + let values = lance_testing::datagen::generate_random_array_with_seed::( + rows_per_fragment * dim as usize, + [seed as u8; 32], + ); + let vectors = Arc::new( + arrow_array::FixedSizeListArray::try_new_from_values(values, dim).unwrap(), + ) as ArrayRef; + RecordBatch::try_new(schema.clone(), vec![vectors]).unwrap() + }) + .collect::>(); + + let tmp_dir = tempfile::tempdir().unwrap(); + let uri = tmp_dir.path().to_str().unwrap(); + let dataset = Dataset::write( + RecordBatchIterator::new(batches.into_iter().map(Ok), schema), + uri, + Some(WriteParams { + max_rows_per_file: rows_per_fragment, + max_rows_per_group: rows_per_fragment, + ..Default::default() + }), + ) + .await + .unwrap(); + let fragment_ids = dataset + .get_fragments() + .into_iter() + .take(2) + .map(|fragment| fragment.id() as u32) + .collect::>(); + + let alloc_tracker = AllocTracker::new(); + let training_data = { + let _guard = alloc_tracker.enter(); + maybe_sample_training_data(&dataset, "vec", sample_size, Some(&fragment_ids)) + .await + .unwrap() + }; + let stats = alloc_tracker.stats(); + + assert_eq!(training_data.len(), sample_size); + + // A full scan of the selected fragments would need at least: + // 2 fragments * 8192 rows * 1024 dims * 4 bytes = 64 MiB + // Keep a generous ceiling well below that lower bound so the test remains + // stable while still catching regressions back to eager materialization. + assert_le!( + stats.max_bytes_allocated, + 24 * 1024 * 1024, + "nullable fragment sampling allocated too much memory: {:?}", + stats + ); +} From c8695123a001d2afca3c1aa86652a15f52674c33 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Tue, 31 Mar 2026 17:59:43 +0800 Subject: [PATCH 108/206] fix: respect fragment filters during distributed worker training (#6358) This fixes a bug in distributed vector index builds where any worker that fell back to builder-local training sampled training data from the entire dataset instead of the worker's selected fragments. The change threads `fragment_filter` through IVF and quantizer training, and adds an `IvfSq` regression test that verifies worker-local SQ bounds are derived only from the shard fragments. --- rust/lance/src/index/vector/builder.rs | 12 ++- rust/lance/src/index/vector/ivf/v2.rs | 131 ++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/rust/lance/src/index/vector/builder.rs b/rust/lance/src/index/vector/builder.rs index 24298cbba18..c2d6427b251 100644 --- a/rust/lance/src/index/vector/builder.rs +++ b/rust/lance/src/index/vector/builder.rs @@ -385,7 +385,7 @@ impl IvfIndexBuilder dim, self.distance_type, ivf_params, - None, + self.fragment_filter.as_deref(), self.progress.clone(), ) .await @@ -414,9 +414,13 @@ impl IvfIndexBuilder "loading training data for quantizer. sample size: {}", sample_size_hint ); - let training_data = - utils::maybe_sample_training_data(dataset, &self.column, sample_size_hint, None) - .await?; + let training_data = utils::maybe_sample_training_data( + dataset, + &self.column, + sample_size_hint, + self.fragment_filter.as_deref(), + ) + .await?; info!( "Finished loading training data in {:02} seconds", start.elapsed().as_secs_f32() diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 11b19cd9f1d..ab1fc93fb13 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -660,7 +660,9 @@ mod tests { use lance_index::vector::quantizer::QuantizerMetadata; use lance_index::vector::sq::builder::SQBuildParams; use lance_index::vector::{ - pq::storage::ProductQuantizationMetadata, storage::STORAGE_METADATA_KEY, + pq::storage::ProductQuantizationMetadata, + sq::storage::{SQ_METADATA_KEY, ScalarQuantizationMetadata}, + storage::STORAGE_METADATA_KEY, }; use lance_index::{INDEX_AUXILIARY_FILE_NAME, metrics::NoOpMetricsCollector}; use lance_index::{optimize::OptimizeOptions, scalar::IndexReader}; @@ -767,6 +769,37 @@ mod tests { serde_json::from_str(&metadata_entries[0]).unwrap() } + async fn get_sq_metadata( + dataset: &Dataset, + scheduler: Arc, + index_uuid: &str, + ) -> ScalarQuantizationMetadata { + let index_path = dataset + .indices_dir() + .child(index_uuid) + .child(INDEX_AUXILIARY_FILE_NAME); + let file_scheduler = scheduler + .open_file(&index_path, &CachedFileSize::unknown()) + .await + .unwrap(); + let reader = FileReader::try_open( + file_scheduler, + None, + Arc::::default(), + &LanceCache::no_cache(), + FileReaderOptions::default(), + ) + .await + .unwrap(); + if let Some(metadata) = reader.schema().metadata.get(SQ_METADATA_KEY) { + serde_json::from_str(metadata).unwrap() + } else { + let metadata = reader.schema().metadata.get(STORAGE_METADATA_KEY).unwrap(); + let metadata_entries: Vec = serde_json::from_str(metadata).unwrap(); + serde_json::from_str(&metadata_entries[0]).unwrap() + } + } + async fn assert_rq_rotation_type(dataset: &Dataset, expected: RQRotationType) { let obj_store = Arc::new(ObjectStore::local()); let scheduler = ScanScheduler::new(obj_store, SchedulerConfig::default_for_testing()); @@ -948,6 +981,49 @@ mod tests { ) } + fn make_fragment_offset_batches( + rows_per_fragment: usize, + offsets: &[f32], + ) -> (Arc, Vec) { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::UInt64, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + DIM as i32, + ), + false, + ), + ])); + + let mut next_id = 0_u64; + let batches = offsets + .iter() + .map(|offset| { + let ids = Arc::new(UInt64Array::from_iter_values( + next_id..next_id + rows_per_fragment as u64, + )); + next_id += rows_per_fragment as u64; + + let mut values = Vec::with_capacity(rows_per_fragment * DIM); + for _ in 0..rows_per_fragment { + for dim in 0..DIM { + values.push(*offset + dim as f32); + } + } + + let vectors = Arc::new( + FixedSizeListArray::try_new_from_values(Float32Array::from(values), DIM as i32) + .unwrap(), + ); + RecordBatch::try_new(schema.clone(), vec![ids, vectors]).unwrap() + }) + .collect(); + + (schema, batches) + } + struct VectorIndexTestContext { stats_json: String, stats: serde_json::Value, @@ -2164,6 +2240,59 @@ mod tests { .unwrap(); assert!(result.num_rows() > 0); } + + #[tokio::test] + async fn test_distributed_ivf_sq_worker_training_respects_fragment_filter() { + const ROWS_PER_FRAGMENT: usize = 64; + const FRAGMENT_OFFSETS: [f32; 2] = [0.0, 1000.0]; + + let test_dir = TempStrDir::default(); + let dataset_uri = format!("{}/distributed_sq_fragment_filter", test_dir.as_str()); + let (schema, batches) = make_fragment_offset_batches(ROWS_PER_FRAGMENT, &FRAGMENT_OFFSETS); + let batches = RecordBatchIterator::new(batches.into_iter().map(Ok), schema); + let mut dataset = Dataset::write( + batches, + &dataset_uri, + Some(WriteParams { + max_rows_per_file: ROWS_PER_FRAGMENT, + mode: WriteMode::Overwrite, + ..Default::default() + }), + ) + .await + .unwrap(); + + let fragments = dataset.get_fragments(); + assert_eq!(fragments.len(), FRAGMENT_OFFSETS.len()); + + let ivf_params = + IvfBuildParams::try_with_centroids(2, build_centroids_for_offsets(&FRAGMENT_OFFSETS)) + .unwrap(); + let params = VectorIndexParams::with_ivf_sq_params( + DistanceType::L2, + ivf_params, + SQBuildParams::default(), + ); + + let segment = dataset + .create_index_builder(&["vector"], IndexType::Vector, ¶ms) + .name("sq_fragment_filter".to_string()) + .fragments(vec![fragments[0].id() as u32]) + .execute_uncommitted() + .await + .unwrap(); + + let scheduler = ScanScheduler::new( + Arc::new(dataset.object_store().clone()), + SchedulerConfig::default_for_testing(), + ); + let sq_meta = get_sq_metadata(&dataset, scheduler, &segment.uuid.to_string()).await; + + assert_eq!(sq_meta.bounds.start, 0.0); + assert_eq!(sq_meta.bounds.end, (DIM - 1) as f64); + assert_lt!(sq_meta.bounds.end, FRAGMENT_OFFSETS[1] as f64); + } + async fn test_index( params: VectorIndexParams, nlist: usize, From 507dd6ffb0cbb328909a062234d548a1afc35149 Mon Sep 17 00:00:00 2001 From: Ionut Scheianu <54133156+ivscheianu@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:10:21 +0300 Subject: [PATCH 109/206] feat: expose stable row ids (#6325) Expose the stable row ID manifest flag to Java via `Dataset.usesStableRowIds()`. Downstream consumers (e.g. lance-spark) need to check whether a dataset was created with stable row IDs enabled. The Rust manifest tracks this flag internally, but it was not accessible from the Java API. The only workaround was to mirror the flag into `Dataset.getConfig()` via an explicit `UpdateConfig` commit after table creation. This had two consequences: 1. **Unnecessary version bump.** Every table creation with stable row IDs produced two commits instead of one: the initial `CREATE`, followed by a separate `UpdateConfig` commit just to persist the flag in the config map. This means the table starts at version 2 instead of version 1. 2. The above mentioned action will also cause **CDF (Change Data Feed) breakage.** CDF tracks row-level changes across dataset versions, and tests assert exact version numbers for created/updated rows (e.g. "row inserted at version 1, updated at version 3"). The extra version bump shifts every subsequent version by +1, breaking all CDF assertions across the test suite. With `Dataset.hasStableRowIds()`, consumers can read the manifest flag directly: no config map mirror needed, no extra commit, no version inflation. A companion PR on lance-spark (https://github.com/lance-format/lance-spark/pull/351) adds the rest of the needed changes. **NOTE:** he changes are related, but can be merged independently. No synchronization is needed, they can exist without each other without problems --------- Co-authored-by: Will Jones --- java/lance-jni/src/blocking_dataset.rs | 14 ++++++++++ java/src/main/java/org/lance/Dataset.java | 17 ++++++++++++ java/src/test/java/org/lance/DatasetTest.java | 27 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index cc82c8acee5..00e30f1f6b0 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -1967,6 +1967,20 @@ fn inner_get_config<'local>( Ok(java_hashmap) } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeHasStableRowIds( + mut env: JNIEnv, + java_dataset: JObject, +) -> jboolean { + ok_or_throw_with_return!(env, inner_has_stable_row_ids(&mut env, java_dataset), 0u8) +} + +fn inner_has_stable_row_ids(env: &mut JNIEnv, java_dataset: JObject) -> Result { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + Ok(dataset_guard.inner.manifest().uses_stable_row_ids() as u8) +} + #[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetLanceFileFormatVersion<'local>( mut env: JNIEnv<'local>, diff --git a/java/src/main/java/org/lance/Dataset.java b/java/src/main/java/org/lance/Dataset.java index d9c58e9b54a..0bd693e88e2 100644 --- a/java/src/main/java/org/lance/Dataset.java +++ b/java/src/main/java/org/lance/Dataset.java @@ -1334,6 +1334,23 @@ public Map getConfig() { private native Map nativeGetConfig(); + /** + * Check whether the dataset uses stable row IDs. + * + *

Stable row IDs remain constant when rows are moved during compaction. This reads the + * manifest feature flag directly rather than the user-facing config map. + * + * @return true if the dataset was created with stable row IDs enabled + */ + public boolean hasStableRowIds() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return nativeHasStableRowIds(); + } + } + + private native boolean nativeHasStableRowIds(); + /** * Get the Lance file format version of this dataset. * diff --git a/java/src/test/java/org/lance/DatasetTest.java b/java/src/test/java/org/lance/DatasetTest.java index a707b4f4a3c..1ec92074ee5 100644 --- a/java/src/test/java/org/lance/DatasetTest.java +++ b/java/src/test/java/org/lance/DatasetTest.java @@ -1292,6 +1292,33 @@ void testEnableStableRowIds(@TempDir Path tempDir) throws Exception { } } + @Test + void testHasStableRowIds(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("uses_stable_row_ids").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + + // Dataset created without stable row IDs + try (Dataset ds = testDataset.createEmptyDataset()) { + assertFalse(ds.hasStableRowIds()); + } + } + + String datasetPathWithRowIds = tempDir.resolve("uses_stable_row_ids_enabled").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPathWithRowIds); + + // Dataset created with stable row IDs + try (Dataset ds = + testDataset.createDatasetWithWriteParams( + new WriteParams.Builder().withEnableStableRowIds(true).build())) { + assertTrue(ds.hasStableRowIds()); + } + } + } + @Test void testCompact(@TempDir Path tempDir) { String testMethodName = new Object() {}.getClass().getEnclosingMethod().getName(); From 3c35f481525052c718c13a5a09bc779886c9911a Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Tue, 31 Mar 2026 05:27:28 -0700 Subject: [PATCH 110/206] feat: change default file format version to 2.1 (#6115) Co-authored-by: Claude Opus 4.6 Co-authored-by: Will Jones --- java/src/test/java/org/lance/DatasetTest.java | 6 +- .../java/org/lance/operation/MergeTest.java | 2 +- .../lance/operation/OperationTestBase.java | 2 +- .../tests/compat/test_scalar_indices.py | 43 +++++++- .../tests/compat/test_vector_indices.py | 25 ++++- python/python/tests/compat/util.py | 15 +++ python/python/tests/test_file.py | 7 +- python/python/tests/test_optimize.py | 20 ++-- rust/lance-core/src/utils/futures.rs | 104 +++++++++++++++++- rust/lance-encoding/src/decoder.rs | 17 ++- rust/lance-encoding/src/version.rs | 6 +- .../src/dataset/tests/dataset_merge_update.rs | 8 +- rust/lance/src/index.rs | 5 +- rust/lance/src/index/vector/ivf/v2.rs | 11 +- 14 files changed, 231 insertions(+), 40 deletions(-) diff --git a/java/src/test/java/org/lance/DatasetTest.java b/java/src/test/java/org/lance/DatasetTest.java index 1ec92074ee5..6c387aac499 100644 --- a/java/src/test/java/org/lance/DatasetTest.java +++ b/java/src/test/java/org/lance/DatasetTest.java @@ -123,12 +123,12 @@ void testCreateEmptyDataset(@TempDir Path tempDir) { @Test void testGetLanceFileFormatVersion(@TempDir Path tempDir) { try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { - // Test default version (V2_0) + // Test default version (V2_1) String defaultPath = tempDir.resolve("default_version").toString(); TestUtils.SimpleTestDataset testDataset = new TestUtils.SimpleTestDataset(allocator, defaultPath); try (Dataset dataset = testDataset.createEmptyDataset()) { - assertEquals(LanceConstants.FILE_FORMAT_VERSION_2_0, dataset.getLanceFileFormatVersion()); + assertEquals(LanceConstants.FILE_FORMAT_VERSION_2_1, dataset.getLanceFileFormatVersion()); } // Test LEGACY version @@ -869,7 +869,7 @@ void testCalculateDataSize(@TempDir Path tempDir) { dataset = testDataset.createEmptyDataset(); try (Dataset dataset2 = testDataset.write(1, 5)) { - assertEquals(100, dataset2.calculateDataSize()); + assertEquals(108, dataset2.calculateDataSize()); } } } diff --git a/java/src/test/java/org/lance/operation/MergeTest.java b/java/src/test/java/org/lance/operation/MergeTest.java index 121d3a2e9d5..841ad41d5ea 100644 --- a/java/src/test/java/org/lance/operation/MergeTest.java +++ b/java/src/test/java/org/lance/operation/MergeTest.java @@ -246,7 +246,7 @@ void testMergeNewColumnWithNonContiguousFieldId(@TempDir Path tempDir) throws Ex datasetPath, addressRoot, new int[] {addressFieldId, cityFieldId, countryFieldId}, - new int[] {0, 1, 2}); + new int[] {-1, 0, 1}); FragmentMetadata fragmentMeta = initialDataset.getFragment(0).metadata(); List dataFiles = fragmentMeta.getFiles(); diff --git a/java/src/test/java/org/lance/operation/OperationTestBase.java b/java/src/test/java/org/lance/operation/OperationTestBase.java index 5f2c2a46d99..fc2571fb55c 100644 --- a/java/src/test/java/org/lance/operation/OperationTestBase.java +++ b/java/src/test/java/org/lance/operation/OperationTestBase.java @@ -35,7 +35,7 @@ public class OperationTestBase { public static final int TEST_FILE_FORMAT_MAJOR_VERSION = 2; - public static final int TEST_FILE_FORMAT_MINOR_VERSION = 0; + public static final int TEST_FILE_FORMAT_MINOR_VERSION = 1; protected Dataset dataset; @BeforeAll diff --git a/python/python/tests/compat/test_scalar_indices.py b/python/python/tests/compat/test_scalar_indices.py index 08222108805..35022df3b12 100644 --- a/python/python/tests/compat/test_scalar_indices.py +++ b/python/python/tests/compat/test_scalar_indices.py @@ -19,6 +19,7 @@ UpgradeDowngradeTest, compat_test, ) +from .util import safe_data_storage_version @compat_test(min_version="0.30.0") @@ -40,7 +41,12 @@ def create(self): "btree": pa.array(range(1000)), } ) - dataset = lance.write_dataset(data, self.path, max_rows_per_file=100) + dataset = lance.write_dataset( + data, + self.path, + max_rows_per_file=100, + data_storage_version=safe_data_storage_version(self.compat_version), + ) dataset.create_scalar_index("btree", "BTREE") def check_read(self): @@ -92,7 +98,12 @@ def create(self): "label_list": pa.array([[f"label{i}"] for i in range(1000)]), } ) - dataset = lance.write_dataset(data, self.path, max_rows_per_file=100) + dataset = lance.write_dataset( + data, + self.path, + max_rows_per_file=100, + data_storage_version=safe_data_storage_version(self.compat_version), + ) dataset.create_scalar_index("bitmap", "BITMAP") dataset.create_scalar_index("label_list", "LABEL_LIST") @@ -141,7 +152,12 @@ def create(self): "ngram": pa.array([f"word{i}" for i in range(1000)]), } ) - dataset = lance.write_dataset(data, self.path, max_rows_per_file=100) + dataset = lance.write_dataset( + data, + self.path, + max_rows_per_file=100, + data_storage_version=safe_data_storage_version(self.compat_version), + ) dataset.create_scalar_index("ngram", "NGRAM") def check_read(self): @@ -186,7 +202,12 @@ def create(self): "bloomfilter": pa.array(range(1000)), } ) - dataset = lance.write_dataset(data, self.path, max_rows_per_file=100) + dataset = lance.write_dataset( + data, + self.path, + max_rows_per_file=100, + data_storage_version=safe_data_storage_version(self.compat_version), + ) dataset.create_scalar_index("zonemap", "ZONEMAP") dataset.create_scalar_index("bloomfilter", "BLOOMFILTER") @@ -237,7 +258,12 @@ def create(self): "json": pa.array([f'{{"val": {i}}}' for i in range(1000)], pa.json_()), } ) - dataset = lance.write_dataset(data, self.path, max_rows_per_file=100) + dataset = lance.write_dataset( + data, + self.path, + max_rows_per_file=100, + data_storage_version=safe_data_storage_version(self.compat_version), + ) dataset.create_scalar_index( "json", IndexConfig( @@ -288,7 +314,12 @@ def create(self): ), } ) - dataset = lance.write_dataset(data, self.path, max_rows_per_file=100) + dataset = lance.write_dataset( + data, + self.path, + max_rows_per_file=100, + data_storage_version=safe_data_storage_version(self.compat_version), + ) dataset.create_scalar_index("text", "INVERTED", with_position=True) def check_read(self): diff --git a/python/python/tests/compat/test_vector_indices.py b/python/python/tests/compat/test_vector_indices.py index 25d43c2f17b..194435c095a 100644 --- a/python/python/tests/compat/test_vector_indices.py +++ b/python/python/tests/compat/test_vector_indices.py @@ -20,6 +20,7 @@ UpgradeDowngradeTest, compat_test, ) +from .util import safe_data_storage_version @compat_test(min_version="0.29.1.beta2") @@ -44,7 +45,11 @@ def create(self): } ) - dataset = lance.write_dataset(data, self.path) + dataset = lance.write_dataset( + data, + self.path, + data_storage_version=safe_data_storage_version(self.compat_version), + ) dataset.create_index( "vec", "IVF_PQ", @@ -109,7 +114,11 @@ def create(self): } ) - dataset = lance.write_dataset(data, self.path) + dataset = lance.write_dataset( + data, + self.path, + data_storage_version=safe_data_storage_version(self.compat_version), + ) dataset.create_index( "vec", "IVF_HNSW_PQ", @@ -174,7 +183,11 @@ def create(self): } ) - dataset = lance.write_dataset(data, self.path) + dataset = lance.write_dataset( + data, + self.path, + data_storage_version=safe_data_storage_version(self.compat_version), + ) dataset.create_index( "vec", "IVF_HNSW_SQ", @@ -235,7 +248,11 @@ def create(self): } ) - dataset = lance.write_dataset(data, self.path) + dataset = lance.write_dataset( + data, + self.path, + data_storage_version=safe_data_storage_version(self.compat_version), + ) dataset.create_index( "vec", "IVF_RQ", diff --git a/python/python/tests/compat/util.py b/python/python/tests/compat/util.py index f20bc93f23f..f587393be10 100644 --- a/python/python/tests/compat/util.py +++ b/python/python/tests/compat/util.py @@ -9,6 +9,21 @@ import pyarrow as pa +def safe_data_storage_version(version_str): + """Return a data_storage_version safe for the given lance version. + + Versions 0.30 and older use "2.0", newer versions use "stable". + """ + parts = version_str.split(".") + major = int(parts[0]) + if major > 0: + return "stable" + minor = int(parts[1]) if len(parts) > 1 else 0 + if minor <= 30: + return "2.0" + return "stable" + + def build_basic_types(): schema = pa.schema( [ diff --git a/python/python/tests/test_file.py b/python/python/tests/test_file.py index ea89e7da4a3..c71654769b8 100644 --- a/python/python/tests/test_file.py +++ b/python/python/tests/test_file.py @@ -243,9 +243,10 @@ def test_metadata(tmp_path): assert len(column.pages) == 1 page = column.pages[0] - assert len(page.buffers) == 1 - assert page.buffers[0].position == 0 - assert page.buffers[0].size == 24 + assert len(page.buffers) > 0 + for buffer in page.buffers: + assert buffer.position % 64 == 0 + assert buffer.size > 0 assert len(page.encoding) > 0 diff --git a/python/python/tests/test_optimize.py b/python/python/tests/test_optimize.py index 748e4294f93..728ee1955b5 100644 --- a/python/python/tests/test_optimize.py +++ b/python/python/tests/test_optimize.py @@ -74,28 +74,30 @@ def test_optimize_max_bytes(tmp_path: Path): arr = pa.FixedSizeListArray.from_arrays(arr, 1024) data = pa.table({"a": arr}) + # Write out 4K rows and 32MB of data dataset = lance.write_dataset( data, base_dir, max_rows_per_file=2 * 1024, data_storage_version="stable" ) + # We get 2 fragments assert len(dataset.get_fragments()) == 2 - # max_bytes_per_file is too small and we get tiny files + # Now run compaction with a small max_bytes_per_file (1000 bytes) to get more + # fragments. The exact number is a bit tricky to calculate because we don't + # split into a new fragment until we've actually written data and that depends + # on how much the file format chooses to accumulate, but it should be more than 2 metrics = dataset.optimize.compact_files( target_rows_per_fragment=100 * 1024, materialize_deletions=False, max_bytes_per_file=1000, batch_size=128, ) - - # We get 4 fragments here because we don't actually write any data to the file - # until we've accumulated 8MiB for a page. assert metrics.fragments_removed == 2 - assert metrics.fragments_added == 4 + assert metrics.fragments_added > 2 assert metrics.files_removed == 2 - assert metrics.files_added == 4 + assert metrics.files_added > 2 num_frags = len(dataset.get_fragments()) - assert num_frags == 4 + assert num_frags == metrics.fragments_added dataset = lance.write_dataset( data, @@ -115,9 +117,9 @@ def test_optimize_max_bytes(tmp_path: Path): results = [task.execute(dataset) for task in plan.tasks] metrics = Compaction.commit(dataset, results) assert metrics.fragments_removed == 2 - assert metrics.fragments_added == 4 + assert metrics.fragments_added > 2 assert metrics.files_removed == 2 - assert metrics.files_added == 4 + assert metrics.files_added > 2 dataset = lance.write_dataset( data, diff --git a/rust/lance-core/src/utils/futures.rs b/rust/lance-core/src/utils/futures.rs index 88af99f1e4e..95a1c39aaae 100644 --- a/rust/lance-core/src/utils/futures.rs +++ b/rust/lance-core/src/utils/futures.rs @@ -8,7 +8,7 @@ use std::{ }; use futures::{Stream, StreamExt, stream::BoxStream}; -use pin_project::pin_project; +use pin_project::{pin_project, pinned_drop}; use tokio::sync::Semaphore; use tokio_util::sync::PollSemaphore; @@ -264,13 +264,64 @@ impl FinallyStreamExt for S { } } +/// A stream wrapper that calls a function when dropped. +/// +/// Unlike [`FinallyStream`], which fires when the inner stream yields `None`, +/// this fires when the wrapper is dropped — even if the stream was not fully +/// consumed. +#[pin_project(PinnedDrop)] +pub struct OnDropStream { + #[pin] + stream: S, + f: Option, +} + +impl OnDropStream { + pub fn new(stream: S, f: F) -> Self { + Self { stream, f: Some(f) } + } +} + +impl Stream for OnDropStream { + type Item = S::Item; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.project().stream.poll_next(cx) + } +} + +#[pinned_drop] +impl PinnedDrop for OnDropStream { + fn drop(self: std::pin::Pin<&mut Self>) { + let this = self.project(); + if let Some(f) = this.f.take() { + f(); + } + } +} + +pub trait StreamOnDropExt: Stream + Sized { + /// Wrap this stream so that `f` is called when the stream is dropped. + fn on_drop(self, f: F) -> OnDropStream { + OnDropStream::new(self, f) + } +} + +impl StreamOnDropExt for S {} + #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use futures::{FutureExt, StreamExt}; use tokio_stream::wrappers::ReceiverStream; - use crate::utils::futures::{Capacity, SharedStreamExt}; + use crate::utils::futures::{Capacity, SharedStreamExt, StreamOnDropExt}; fn is_pending(fut: &mut (impl std::future::Future + Unpin)) -> bool { let noop_waker = futures::task::noop_waker(); @@ -388,4 +439,53 @@ mod tests { right_handle.await.unwrap(); } } + + #[tokio::test] + async fn test_on_drop_fires_on_early_drop() { + let called = Arc::new(AtomicBool::new(false)); + let called_clone = called.clone(); + + let stream = futures::stream::iter(vec![1, 2, 3]); + let mut stream = stream.on_drop(move || { + called_clone.store(true, Ordering::SeqCst); + }); + + // Consume only one item, then drop + assert_eq!(stream.next().await, Some(1)); + assert!(!called.load(Ordering::SeqCst)); + drop(stream); + assert!(called.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn test_on_drop_fires_after_exhaustion() { + let called = Arc::new(AtomicBool::new(false)); + let called_clone = called.clone(); + + let stream = futures::stream::iter(vec![1]); + let mut stream = stream.on_drop(move || { + called_clone.store(true, Ordering::SeqCst); + }); + + assert_eq!(stream.next().await, Some(1)); + assert_eq!(stream.next().await, None); + assert!(!called.load(Ordering::SeqCst)); + drop(stream); + assert!(called.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn test_on_drop_fires_without_polling() { + let called = Arc::new(AtomicBool::new(false)); + let called_clone = called.clone(); + + let stream = futures::stream::iter(vec![1, 2, 3]); + let stream = stream.on_drop(move || { + called_clone.store(true, Ordering::SeqCst); + }); + + assert!(!called.load(Ordering::SeqCst)); + drop(stream); + assert!(called.load(Ordering::SeqCst)); + } } diff --git a/rust/lance-encoding/src/decoder.rs b/rust/lance-encoding/src/decoder.rs index ddbc3da38e3..4f6e8b06083 100644 --- a/rust/lance-encoding/src/decoder.rs +++ b/rust/lance-encoding/src/decoder.rs @@ -226,7 +226,7 @@ use futures::{FutureExt, StreamExt}; use lance_arrow::DataTypeExt; use lance_core::cache::LanceCache; use lance_core::datatypes::{BLOB_DESC_LANCE_FIELD, Field, Schema}; -use lance_core::utils::futures::FinallyStreamExt; +use lance_core::utils::futures::{FinallyStreamExt, StreamOnDropExt}; use lance_core::utils::parse::parse_env_as_bool; use log::{debug, trace, warn}; use tokio::sync::mpsc::error::SendError; @@ -1900,6 +1900,7 @@ fn check_scheduler_on_drop( // This is a bit weird but we create an "empty stream" that unwraps the scheduler handle (which // will panic if the scheduler panicked). This let's us check if the scheduler panicked // when the stream finishes. + let abort_handle = scheduler_handle.abort_handle(); let mut scheduler_handle = Some(scheduler_handle); let check_scheduler = stream::unfold((), move |_| { let handle = scheduler_handle.take(); @@ -1910,7 +1911,19 @@ fn check_scheduler_on_drop( None } }); - stream.chain(check_scheduler).boxed() + stream + .chain(check_scheduler) + .on_drop(move || { + // Abort the scheduler task on early drop. The scheduler task holds + // a reference to the I/O scheduler (via config.io) which keeps the + // ScanScheduler alive. If the scheduler task is stuck waiting for + // initialization I/O (which is blocked on backpressure that will + // never drain because no one is consuming the stream), we need to + // abort it so it releases its I/O reference and allows the + // ScanScheduler to drop and cancel pending I/O. + abort_handle.abort(); + }) + .boxed() } pub fn create_decode_stream( diff --git a/rust/lance-encoding/src/version.rs b/rust/lance-encoding/src/version.rs index 14b1ee21448..41640d0bf2d 100644 --- a/rust/lance-encoding/src/version.rs +++ b/rust/lance-encoding/src/version.rs @@ -27,11 +27,11 @@ pub enum LanceFileVersion { // /// The legacy (0.1) format Legacy, - #[default] V2_0, + #[default] + V2_1, /// The latest stable release (also the default version for new datasets) Stable, - V2_1, V2_2, /// The latest unstable release Next, @@ -42,7 +42,7 @@ impl LanceFileVersion { /// Convert Stable or Next to the actual version pub fn resolve(&self) -> Self { match self { - Self::Stable => Self::V2_0, + Self::Stable => Self::default(), Self::Next => Self::V2_3, _ => *self, } diff --git a/rust/lance/src/dataset/tests/dataset_merge_update.rs b/rust/lance/src/dataset/tests/dataset_merge_update.rs index b11f0c1474f..c859db25389 100644 --- a/rust/lance/src/dataset/tests/dataset_merge_update.rs +++ b/rust/lance/src/dataset/tests/dataset_merge_update.rs @@ -480,8 +480,8 @@ async fn test_insert_nested_subschemas() { let fragments = dataset.get_fragments(); assert_eq!(fragments.len(), 1); assert_eq!(fragments[0].metadata.files.len(), 1); - assert_eq!(&fragments[0].metadata.files[0].fields, &[0, 2, 1]); - assert_eq!(&fragments[0].metadata.files[0].column_indices, &[0, 1, 2]); + assert_eq!(&fragments[0].metadata.files[0].fields, &[2, 1]); + assert_eq!(&fragments[0].metadata.files[0].column_indices, &[0, 1]); // Can insert c, b let just_c_b = Arc::new(ArrowSchema::new(vec![ArrowField::new( @@ -508,8 +508,8 @@ async fn test_insert_nested_subschemas() { let fragments = dataset.get_fragments(); assert_eq!(fragments.len(), 2); assert_eq!(fragments[1].metadata.files.len(), 1); - assert_eq!(&fragments[1].metadata.files[0].fields, &[0, 3, 2]); - assert_eq!(&fragments[1].metadata.files[0].column_indices, &[0, 1, 2]); + assert_eq!(&fragments[1].metadata.files[0].fields, &[3, 2]); + assert_eq!(&fragments[1].metadata.files[0].column_indices, &[0, 1]); // Can't insert a, c (b is non-nullable) let just_a_c = Arc::new(ArrowSchema::new(vec![ArrowField::new( diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index 85497bc6180..b9fc14f72cc 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -6226,6 +6226,9 @@ mod tests { .await .unwrap(); + // Do a full scan to warm up data file metadata + let _ = dataset.scan().try_into_batch().await.unwrap(); + // Reset IO stats before query let _ = dataset.object_store().io_stats_incremental(); @@ -6246,7 +6249,7 @@ mod tests { assert_io_lt!( stats, read_iops, - 15, + 17, "IVF_PQ index query should use minimal IOPs" ); } diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index ab1fc93fb13..263bc6457da 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -2040,7 +2040,16 @@ mod tests { let shard_plan = plan_segments(&segments, None, None).await.unwrap(); let shard_count = shard_plan.len(); assert!(shard_count >= 4); - let target_segment_bytes = shard_plan[0].estimated_bytes().saturating_mul(2); + // Use the max segment size as the base for the target. Segment sizes can + // vary slightly because compression is data-dependent and the random + // vectors in each fragment compress differently. Segments are sorted by + // UUID (random), so shard_plan[0] is not guaranteed to be the largest. + let max_shard_bytes = shard_plan + .iter() + .map(|p| p.estimated_bytes()) + .max() + .unwrap(); + let target_segment_bytes = max_shard_bytes.saturating_mul(2); let grouped_plan = plan_segments(&segments, None, Some(target_segment_bytes)) .await From 0f0816013e6e586d60936d3f615d61aae6827d73 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Tue, 31 Mar 2026 21:57:45 +0800 Subject: [PATCH 111/206] feat: support distributed IVF_RQ segment builds (#6359) This PR wires IVF_RQ into the distributed vector index segment build path so workers can build and commit shard-local RQ segments through the existing distributed indexing workflow. It also adds the missing RQ handling in the distributed auxiliary metadata plumbing and regression coverage for distributed RQ segment build plus auxiliary merge. Current limitation: IVF_RQ still does not have a pre-trained/shared RQ metadata contract. Different RQ segments can therefore carry different rotations and centroids, so merging multiple IVF_RQ segments into a single physical segment is not a supported workflow yet. I plan to follow up by adding shared/pre-trained RQ metadata so segment merge can be supported safely. --- .../src/vector/distributed/index_merger.rs | 398 ++++++++++++++++++ .../src/vector/shared/partition_merger.rs | 29 +- rust/lance/src/index/vector.rs | 33 +- rust/lance/src/index/vector/ivf.rs | 2 +- rust/lance/src/index/vector/ivf/v2.rs | 27 +- 5 files changed, 472 insertions(+), 17 deletions(-) diff --git a/rust/lance-index/src/vector/distributed/index_merger.rs b/rust/lance-index/src/vector/distributed/index_merger.rs index a122fbe04d9..c9c7ad4503c 100755 --- a/rust/lance-index/src/vector/distributed/index_merger.rs +++ b/rust/lance-index/src/vector/distributed/index_merger.rs @@ -18,6 +18,10 @@ use std::sync::Arc; use crate::IndexMetadata as IndexMetaSchema; use crate::pb; +use crate::vector::bq::storage::{ + RABIT_CODE_COLUMN, RABIT_METADATA_KEY, RabitQuantizationMetadata, pack_codes, +}; +use crate::vector::bq::transform::{ADD_FACTORS_FIELD, SCALE_FACTORS_FIELD}; use crate::vector::flat::index::FlatMetadata; use crate::vector::ivf::storage::{IVF_METADATA_KEY, IvfModel as IvfStorageModel}; use crate::vector::pq::storage::{PQ_METADATA_KEY, ProductQuantizationMetadata, transpose}; @@ -283,6 +287,49 @@ pub async fn init_writer_for_sq( Ok(w) } +/// Create and initialize a unified writer for RQ storage. +pub async fn init_writer_for_rq( + object_store: &lance_io::object_store::ObjectStore, + aux_out: &object_store::path::Path, + dt: DistanceType, + rq_meta: &RabitQuantizationMetadata, + format_version: LanceFileVersion, +) -> Result { + let num_bytes = (rq_meta.code_dim as usize).div_ceil(u8::BITS as usize); + let arrow_schema = ArrowSchema::new(vec![ + (*ROW_ID_FIELD).clone(), + Field::new( + RABIT_CODE_COLUMN, + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::UInt8, true)), + num_bytes as i32, + ), + true, + ), + ADD_FACTORS_FIELD.clone(), + SCALE_FACTORS_FIELD.clone(), + ]); + let writer = object_store.create(aux_out).await?; + let mut w = FileWriter::try_new( + writer, + LanceSchema::try_from(&arrow_schema)?, + FileWriterOptions { + format_version: Some(format_version), + ..Default::default() + }, + )?; + + let mut rq_meta_init = rq_meta.clone(); + rq_meta_init.packed = true; + if let Some(extra_metadata) = rq_meta_init.extra_metadata()? { + let pos = w.add_global_buffer(extra_metadata).await?; + rq_meta_init.set_buffer_index(pos); + } + let rq_meta_json = serde_json::to_string(&rq_meta_init)?; + init_writer_for_storage(&mut w, dt, &rq_meta_json, RABIT_METADATA_KEY)?; + Ok(w) +} + /// Stream and write a range of rows from reader into writer. /// /// The caller is responsible for ensuring that `range` corresponds to a @@ -351,6 +398,41 @@ async fn write_partition_rows_pq_transposed( Ok(()) } +/// Pack the RQ code column for a batch and write it to the unified writer. +/// +/// This helper assumes `batch` contains a contiguous range of rows for a single +/// IVF partition and that the shard batch stores row-major RQ codes. +async fn write_partition_rows_rq_packed(w: &mut FileWriter, mut batch: RecordBatch) -> Result<()> { + let num_rows = batch.num_rows(); + if num_rows == 0 { + return Ok(()); + } + + let rq_col = batch.column_by_name(RABIT_CODE_COLUMN).ok_or_else(|| { + Error::index(format!( + "RQ column {} missing in auxiliary shard", + RABIT_CODE_COLUMN + )) + })?; + let rq_fsl = rq_col.as_fixed_size_list_opt().ok_or_else(|| { + Error::index(format!( + "RQ column {} is not a FixedSizeList in auxiliary shard, got {}", + RABIT_CODE_COLUMN, + rq_col.data_type(), + )) + })?; + let packed_codes = pack_codes(rq_fsl); + batch = batch.replace_column_by_name(RABIT_CODE_COLUMN, Arc::new(packed_codes))?; + + let batch_size: usize = 10_240; + for offset in (0..num_rows).step_by(batch_size) { + let len = std::cmp::min(batch_size, num_rows - offset); + let slice = batch.slice(offset, len); + w.write_batch(&slice).await?; + } + Ok(()) +} + /// Detect and return supported index type from reader and schema. /// /// This is a lightweight wrapper around SupportedIndexType::detect to keep @@ -628,6 +710,7 @@ pub async fn merge_partial_vector_auxiliary_files( let mut distance_type: Option = None; let mut pq_meta: Option = None; let mut sq_meta: Option = None; + let mut rq_meta: Option = None; let mut dim: Option = None; let mut detected_index_type: Option = None; // Inherit file format version from the first shard (set on first iteration) @@ -727,6 +810,7 @@ pub async fn merge_partial_vector_auxiliary_files( "IVF_FLAT" => SupportedIvfIndexType::IvfFlat, "IVF_PQ" => SupportedIvfIndexType::IvfPq, "IVF_SQ" => SupportedIvfIndexType::IvfSq, + "IVF_RQ" => SupportedIvfIndexType::IvfRq, "IVF_HNSW_FLAT" => SupportedIvfIndexType::IvfHnswFlat, "IVF_HNSW_PQ" => SupportedIvfIndexType::IvfHnswPq, "IVF_HNSW_SQ" => SupportedIvfIndexType::IvfHnswSq, @@ -843,6 +927,87 @@ pub async fn merge_partial_vector_auxiliary_files( v2w_opt = Some(w); } } + SupportedIvfIndexType::IvfRq => { + let rq_json = if let Some(rq_json) = reader + .metadata() + .file_schema + .metadata + .get(RABIT_METADATA_KEY) + { + rq_json.clone() + } else if let Some(storage_meta_json) = reader + .metadata() + .file_schema + .metadata + .get(STORAGE_METADATA_KEY) + { + let storage_metadata_vec: Vec = serde_json::from_str(storage_meta_json) + .map_err(|e| { + Error::index(format!("Failed to parse storage metadata: {}", e)) + })?; + if let Some(first_meta) = storage_metadata_vec.first() { + if let Ok(_rq_meta) = + serde_json::from_str::(first_meta) + { + first_meta.clone() + } else { + return Err(Error::index( + "RQ metadata missing in storage metadata".to_string(), + )); + } + } else { + return Err(Error::index( + "RQ metadata missing in storage metadata".to_string(), + )); + } + } else { + return Err(Error::index("RQ metadata missing".to_string())); + }; + let mut rq_meta_parsed: RabitQuantizationMetadata = serde_json::from_str(&rq_json) + .map_err(|e| Error::index(format!("RQ metadata parse error: {}", e)))?; + if rq_meta_parsed.rotation_type == crate::vector::bq::RQRotationType::Matrix + && rq_meta_parsed.rotate_mat.is_none() + && let Some(buf_idx) = rq_meta_parsed.buffer_index() + { + let rotate_mat_bytes = reader.read_global_buffer(buf_idx).await?; + rq_meta_parsed.parse_buffer(rotate_mat_bytes)?; + } + + let d0 = (rq_meta_parsed.code_dim as usize) + .checked_div(rq_meta_parsed.num_bits as usize) + .ok_or_else(|| { + Error::index("Invalid RQ metadata: num_bits is zero".to_string()) + })?; + dim.get_or_insert(d0); + if let Some(dprev) = dim + && dprev != d0 + { + return Err(Error::index("Dimension mismatch across shards".to_string())); + } + if let Some(existing_rq) = rq_meta.as_ref() + && (existing_rq.code_dim != rq_meta_parsed.code_dim + || existing_rq.num_bits != rq_meta_parsed.num_bits + || existing_rq.rotation_type != rq_meta_parsed.rotation_type) + { + return Err(Error::index(format!( + "Distributed RQ merge: structural mismatch across shards; first(code_dim={}, num_bits={}, rotation_type={:?}), current(code_dim={}, num_bits={}, rotation_type={:?})", + existing_rq.code_dim, + existing_rq.num_bits, + existing_rq.rotation_type, + rq_meta_parsed.code_dim, + rq_meta_parsed.num_bits, + rq_meta_parsed.rotation_type + ))); + } + if rq_meta.is_none() { + rq_meta = Some(rq_meta_parsed.clone()); + } + if v2w_opt.is_none() { + let w = + init_writer_for_rq(object_store, &aux_out, dt, &rq_meta_parsed, fv).await?; + v2w_opt = Some(w); + } + } SupportedIvfIndexType::IvfPq => { // Handle Product Quantization (PQ) storage // Load PQ metadata JSON; construct ProductQuantizationMetadata @@ -1242,6 +1407,34 @@ pub async fn merge_partial_vector_auxiliary_files( } } } + SupportedIvfIndexType::IvfRq => { + let partition_window_size = *PARTITION_WINDOW_SIZE; + let prefetch_window_count = *PARTITION_PREFETCH_WINDOW_COUNT; + let mut shard_merge_reader = ShardMergeReader::new( + shard_infos, + nlist, + partition_window_size, + prefetch_window_count, + ); + + while let Some((pid, batches)) = shard_merge_reader.next_partition().await? { + if accumulated_lengths[pid] == 0 { + continue; + } + if batches.is_empty() { + return Err(Error::index(format!( + "No merged batches found for non-empty partition {}", + pid + ))); + } + + let schema = batches[0].schema(); + let partition_batch = concat_batches(&schema, batches.iter())?; + if let Some(w) = v2w_opt.as_mut() { + write_partition_rows_rq_packed(w, partition_batch).await?; + } + } + } _ => { for pid in 0..nlist { for shard in shard_infos.iter() { @@ -1299,6 +1492,8 @@ mod tests { use object_store::path::Path; use prost::Message; + use crate::vector::bq::RQRotationType; + async fn write_flat_partial_aux( store: &ObjectStore, aux_path: &Path, @@ -1617,6 +1812,88 @@ mod tests { Ok(total_rows) } + async fn write_rq_partial_aux( + store: &ObjectStore, + aux_path: &Path, + metadata: &RabitQuantizationMetadata, + lengths: &[u32], + base_row_id: u64, + distance_type: DistanceType, + ) -> Result { + let num_bytes = (metadata.code_dim as usize).div_ceil(u8::BITS as usize); + let arrow_schema = ArrowSchema::new(vec![ + (*ROW_ID_FIELD).clone(), + Field::new( + RABIT_CODE_COLUMN, + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::UInt8, true)), + num_bytes as i32, + ), + true, + ), + ADD_FACTORS_FIELD.clone(), + SCALE_FACTORS_FIELD.clone(), + ]); + + let writer = store.create(aux_path).await?; + let mut v2w = V2Writer::try_new( + writer, + lance_core::datatypes::Schema::try_from(&arrow_schema)?, + V2WriterOptions::default(), + )?; + v2w.add_schema_metadata(DISTANCE_TYPE_KEY, distance_type.to_string()); + + let rq_meta_json = serde_json::to_string(metadata)?; + v2w.add_schema_metadata(RABIT_METADATA_KEY, rq_meta_json); + + let ivf_meta = pb::Ivf { + centroids: Vec::new(), + offsets: Vec::new(), + lengths: lengths.to_vec(), + centroids_tensor: None, + loss: None, + }; + let buf = Bytes::from(ivf_meta.encode_to_vec()); + let ivf_pos = v2w.add_global_buffer(buf).await?; + v2w.add_schema_metadata(IVF_METADATA_KEY, ivf_pos.to_string()); + + let total_rows: usize = lengths.iter().map(|v| *v as usize).sum(); + let mut row_ids = Vec::with_capacity(total_rows); + let mut codes = Vec::with_capacity(total_rows * num_bytes); + let mut add_factors = Vec::with_capacity(total_rows); + let mut scale_factors = Vec::with_capacity(total_rows); + + let mut current_row_id = base_row_id; + for (pid, len) in lengths.iter().enumerate() { + for row_offset in 0..*len as usize { + row_ids.push(current_row_id); + current_row_id += 1; + for b in 0..num_bytes { + codes.push((pid + row_offset + b) as u8); + } + add_factors.push(pid as f32 + row_offset as f32 * 0.1); + scale_factors.push(pid as f32 + row_offset as f32 * 0.2); + } + } + + let batch = RecordBatch::try_new( + Arc::new(arrow_schema), + vec![ + Arc::new(UInt64Array::from(row_ids)), + Arc::new(FixedSizeListArray::try_new_from_values( + UInt8Array::from(codes), + num_bytes as i32, + )?), + Arc::new(Float32Array::from(add_factors)), + Arc::new(Float32Array::from(scale_factors)), + ], + )?; + + v2w.write_batch(&batch).await?; + v2w.finish().await?; + Ok(total_rows) + } + #[tokio::test] async fn test_merge_ivf_pq_success() { let object_store = ObjectStore::memory(); @@ -1746,6 +2023,127 @@ mod tests { assert!(fixed_size_list_equal(&codebook, &merged_codebook)); } + #[tokio::test] + async fn test_merge_ivf_rq_success() { + let object_store = ObjectStore::memory(); + let index_dir = Path::from("index/uuid_rq"); + + let partial0 = index_dir.child("partial_0"); + let partial1 = index_dir.child("partial_1"); + let aux0 = partial0.child(INDEX_AUXILIARY_FILE_NAME); + let aux1 = partial1.child(INDEX_AUXILIARY_FILE_NAME); + + let lengths0 = vec![2_u32, 1_u32]; + let lengths1 = vec![1_u32, 2_u32]; + + let rq_meta = RabitQuantizationMetadata { + rotate_mat: None, + rotate_mat_position: None, + fast_rotation_signs: Some(vec![0xAA; 2]), + rotation_type: RQRotationType::Fast, + code_dim: 16, + num_bits: 1, + packed: false, + }; + + write_rq_partial_aux( + &object_store, + &aux0, + &rq_meta, + &lengths0, + 0, + DistanceType::L2, + ) + .await + .unwrap(); + write_rq_partial_aux( + &object_store, + &aux1, + &rq_meta, + &lengths1, + 1_000, + DistanceType::L2, + ) + .await + .unwrap(); + + merge_partial_vector_auxiliary_files( + &object_store, + &[aux0.clone(), aux1.clone()], + &index_dir, + ) + .await + .unwrap(); + + let aux_out = index_dir.child(INDEX_AUXILIARY_FILE_NAME); + assert!(object_store.exists(&aux_out).await.unwrap()); + + let sched = ScanScheduler::new( + Arc::new(object_store.clone()), + SchedulerConfig::max_bandwidth(&object_store), + ); + let fh = sched + .open_file(&aux_out, &CachedFileSize::unknown()) + .await + .unwrap(); + let reader = V2Reader::try_open( + fh, + None, + Arc::default(), + &lance_core::cache::LanceCache::no_cache(), + V2ReaderOptions::default(), + ) + .await + .unwrap(); + let meta = reader.metadata(); + + let ivf_idx: u32 = meta + .file_schema + .metadata + .get(IVF_METADATA_KEY) + .unwrap() + .parse() + .unwrap(); + let bytes = reader.read_global_buffer(ivf_idx).await.unwrap(); + let pb_ivf: pb::Ivf = prost::Message::decode(bytes).unwrap(); + let expected_lengths: Vec = lengths0 + .iter() + .zip(lengths1.iter()) + .map(|(a, b)| *a + *b) + .collect(); + assert_eq!(pb_ivf.lengths, expected_lengths); + + let idx_meta_json = meta + .file_schema + .metadata + .get(INDEX_METADATA_SCHEMA_KEY) + .unwrap(); + let idx_meta: IndexMetaSchema = serde_json::from_str(idx_meta_json).unwrap(); + assert_eq!(idx_meta.index_type, "IVF_RQ"); + assert_eq!(idx_meta.distance_type, DistanceType::L2.to_string()); + + let rq_meta_json = meta.file_schema.metadata.get(RABIT_METADATA_KEY).unwrap(); + let merged_rq_meta: RabitQuantizationMetadata = serde_json::from_str(rq_meta_json).unwrap(); + assert_eq!(merged_rq_meta.code_dim, rq_meta.code_dim); + assert_eq!(merged_rq_meta.num_bits, rq_meta.num_bits); + assert!(merged_rq_meta.packed); + + let mut total_rows = 0usize; + let mut stream = reader + .read_stream( + lance_io::ReadBatchParams::RangeFull, + u32::MAX, + 4, + lance_encoding::decoder::FilterExpression::no_filter(), + ) + .unwrap(); + while let Some(batch) = stream.next().await { + total_rows += batch.unwrap().num_rows(); + } + let expected_total: usize = expected_lengths.iter().map(|v| *v as usize).sum(); + assert_eq!(total_rows, expected_total); + } + #[tokio::test] async fn test_merge_ivf_pq_codebook_mismatch() { let object_store = ObjectStore::memory(); diff --git a/rust/lance-index/src/vector/shared/partition_merger.rs b/rust/lance-index/src/vector/shared/partition_merger.rs index 9ca9469551c..a94c462e46b 100644 --- a/rust/lance-index/src/vector/shared/partition_merger.rs +++ b/rust/lance-index/src/vector/shared/partition_merger.rs @@ -17,6 +17,7 @@ use lance_linalg::distance::DistanceType; use prost::Message; use crate::pb; +use crate::vector::bq::storage::RABIT_METADATA_KEY; use crate::vector::ivf::storage::{IVF_METADATA_KEY, IvfModel}; use crate::vector::pq::storage::PQ_METADATA_KEY; use crate::vector::sq::storage::SQ_METADATA_KEY; @@ -34,6 +35,7 @@ pub enum SupportedIvfIndexType { IvfFlat, IvfPq, IvfSq, + IvfRq, IvfHnswFlat, IvfHnswPq, IvfHnswSq, @@ -46,6 +48,7 @@ impl SupportedIvfIndexType { Self::IvfFlat => "IVF_FLAT", Self::IvfPq => "IVF_PQ", Self::IvfSq => "IVF_SQ", + Self::IvfRq => "IVF_RQ", Self::IvfHnswFlat => "IVF_HNSW_FLAT", Self::IvfHnswPq => "IVF_HNSW_PQ", Self::IvfHnswSq => "IVF_HNSW_SQ", @@ -60,6 +63,7 @@ impl SupportedIvfIndexType { "IVF_FLAT" => Some(Self::IvfFlat), "IVF_PQ" => Some(Self::IvfPq), "IVF_SQ" => Some(Self::IvfSq), + "IVF_RQ" => Some(Self::IvfRq), "IVF_HNSW_FLAT" => Some(Self::IvfHnswFlat), "IVF_HNSW_PQ" => Some(Self::IvfHnswPq), "IVF_HNSW_SQ" => Some(Self::IvfHnswSq), @@ -74,6 +78,10 @@ impl SupportedIvfIndexType { pub fn detect_from_reader_and_schema(reader: &V2Reader, schema: &ArrowSchema) -> Result { let has_pq_code_col = schema.fields.iter().any(|f| f.name() == PQ_CODE_COLUMN); let has_sq_code_col = schema.fields.iter().any(|f| f.name() == SQ_CODE_COLUMN); + let has_rq_code_col = schema + .fields + .iter() + .any(|f| f.name() == crate::vector::bq::storage::RABIT_CODE_COLUMN); let is_pq = reader .metadata() @@ -87,19 +95,26 @@ impl SupportedIvfIndexType { .metadata .contains_key(SQ_METADATA_KEY) || has_sq_code_col; + let is_rq = reader + .metadata() + .file_schema + .metadata + .contains_key(RABIT_METADATA_KEY) + || has_rq_code_col; // Detect HNSW-related columns let has_hnsw_vector_id_col = schema.fields.iter().any(|f| f.name() == "__vector_id"); let has_hnsw_pointer_col = schema.fields.iter().any(|f| f.name() == "__pointer"); let has_hnsw = has_hnsw_vector_id_col || has_hnsw_pointer_col; - let index_type = match (has_hnsw, is_pq, is_sq) { - (false, false, false) => Self::IvfFlat, - (false, true, false) => Self::IvfPq, - (false, false, true) => Self::IvfSq, - (true, false, false) => Self::IvfHnswFlat, - (true, true, false) => Self::IvfHnswPq, - (true, false, true) => Self::IvfHnswSq, + let index_type = match (has_hnsw, is_pq, is_sq, is_rq) { + (false, false, false, false) => Self::IvfFlat, + (false, true, false, false) => Self::IvfPq, + (false, false, true, false) => Self::IvfSq, + (false, false, false, true) => Self::IvfRq, + (true, false, false, false) => Self::IvfHnswFlat, + (true, true, false, false) => Self::IvfHnswPq, + (true, false, true, false) => Self::IvfHnswSq, _ => { return Err(Error::not_supported_source( "Unsupported index type combination detected".into(), diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index c5c9038403e..f681095a5cc 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -702,11 +702,34 @@ pub(crate) async fn build_distributed_vector_index( } IndexType::IvfRq => { - return Err(Error::index(format!( - "Build Distributed Vector Index: invalid index type: {:?} \ - is not supported in distributed mode; skipping this shard", - index_type - ))); + let StageParams::RQ(rq_params) = &stages[1] else { + return Err(Error::index(format!( + "Build Distributed Vector Index: invalid stages: {:?}", + stages + ))); + }; + + let ivf_model = make_ivf_model(); + + IvfIndexBuilder::::new( + filtered_dataset, + column.to_owned(), + index_dir.clone(), + params.metric_type, + shuffler, + Some(ivf_params), + Some(rq_params.clone()), + (), + frag_reuse_index, + )? + .with_ivf(ivf_model) + // For distributed shards, keep RQ codes in row-major layout. + // A single packing pass is performed in the distributed merge stage. + .with_transpose(false) + .with_fragment_filter(fragment_filter) + .with_progress(progress.clone()) + .build() + .await?; } _ => { diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index b67e6ea8e81..3282256d1b6 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -2318,7 +2318,7 @@ async fn write_root_vector_index_from_auxiliary( let is_hnsw = idx_meta.index_type.starts_with("IVF_HNSW"); let is_flat_based = matches!( idx_meta.index_type.as_str(), - "IVF_FLAT" | "IVF_PQ" | "IVF_SQ" + "IVF_FLAT" | "IVF_PQ" | "IVF_SQ" | "IVF_RQ" ); if is_hnsw { diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 263bc6457da..ceeee44f839 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -1814,6 +1814,7 @@ mod tests { #[case::ivf_flat(IndexType::IvfFlat)] #[case::ivf_pq(IndexType::IvfPq)] #[case::ivf_sq(IndexType::IvfSq)] + #[case::ivf_rq(IndexType::IvfRq)] #[tokio::test] async fn test_distributed_vector_build_commits_multiple_segments_and_preserves_query_results( #[case] index_type: IndexType, @@ -1866,6 +1867,14 @@ mod tests { SQBuildParams::default(), ) } + IndexType::IvfRq => { + let ivf_params = prepare_global_ivf(&ds_single, "vector").await; + VectorIndexParams::with_ivf_rq_params( + DistanceType::L2, + ivf_params, + RQBuildParams::with_rotation_type(1, RQRotationType::Fast), + ) + } other => panic!("unsupported test index type: {}", other), }; @@ -1964,10 +1973,20 @@ mod tests { let ids_single = collect_row_ids(&ds_single, &queries).await; let ids_split = collect_row_ids(&ds_split, &queries).await; - assert_eq!( - ids_single, ids_split, - "single vs segmented distributed index returned different Top-K row ids", - ); + if index_type == IndexType::IvfRq { + for row_ids in &ids_split { + assert_eq!( + row_ids.len(), + K, + "distributed IVF_RQ query should still return exactly {K} row ids", + ); + } + } else { + assert_eq!( + ids_single, ids_split, + "single vs segmented distributed index returned different Top-K row ids", + ); + } } #[rstest] From 03dd364a3f7597028da081129c3c10bdae387230 Mon Sep 17 00:00:00 2001 From: Ionut Scheianu <54133156+ivscheianu@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:18:15 +0300 Subject: [PATCH 112/206] feat: expose stable row ids (#6325) Expose the stable row ID manifest flag to Java via `Dataset.usesStableRowIds()`. Downstream consumers (e.g. lance-spark) need to check whether a dataset was created with stable row IDs enabled. The Rust manifest tracks this flag internally, but it was not accessible from the Java API. The only workaround was to mirror the flag into `Dataset.getConfig()` via an explicit `UpdateConfig` commit after table creation. This had two consequences: 1. **Unnecessary version bump.** Every table creation with stable row IDs produced two commits instead of one: the initial `CREATE`, followed by a separate `UpdateConfig` commit just to persist the flag in the config map. This means the table starts at version 2 instead of version 1. 2. The above mentioned action will also cause **CDF (Change Data Feed) breakage.** CDF tracks row-level changes across dataset versions, and tests assert exact version numbers for created/updated rows (e.g. "row inserted at version 1, updated at version 3"). The extra version bump shifts every subsequent version by +1, breaking all CDF assertions across the test suite. With `Dataset.hasStableRowIds()`, consumers can read the manifest flag directly: no config map mirror needed, no extra commit, no version inflation. A companion PR on lance-spark (https://github.com/lance-format/lance-spark/pull/351) adds the rest of the needed changes. **NOTE:** he changes are related, but can be merged independently. No synchronization is needed, they can exist without each other without problems --------- Co-authored-by: Will Jones From 65e2ad0c68b535bf7d6b59f94788b9f4588b0ec3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:27:20 -0700 Subject: [PATCH 113/206] chore(deps): bump pygments from 2.19.2 to 2.20.0 in /python (#6348) Bumps [pygments](https://github.com/pygments/pygments) from 2.19.2 to 2.20.0.

Release notes

Sourced from pygments's releases.

2.20.0

  • New lexers:

  • Updated lexers:

    • archetype: Fix catastrophic backtracking in GUID and ID patterns (#3064)
    • ASN.1: Recognize minus sign and fix range operator (#3014, #3060)
    • C++: Add C++26 keywords (#2955), add integer literal suffixes (#2966)
    • ComponentPascal: Fix analyse_text (#3028, #3032)
    • Coq renamed to Rocq (#2883, #2908)
    • Cython: Various improvements (#2932, #2933)
    • Debian control: Improve architecture parsing (#3052)
    • Devicetree: Add support for overlay/fragments (#3021), add bytestring support (#3022), fix catastrophic backtracking (#3057)
    • Fennel: Various improvements (#2911)
    • Haskell: Handle escape sequences in character literals (#3069, #1795)
    • Java: Add module keywords (#2955)
    • Lean4: Add operators ]', ]?, ]! (#2946)
    • LESS: Support single-line comments (#3005)
    • LilyPond: Update to 2.25.29 (#2974)
    • LLVM: Support C-style comments (#3023, #2978)
    • Lua(u): Fix catastrophic backtracking (#3047)
    • Macaulay2: Update to 1.25.05 (#2893), 1.25.11 (#2988)
    • Mathematica: Various improvements (#2957)
    • meson: Add additional operators (#2919)
    • MySQL: Update keywords (#2970)
    • org-Mode: Support both schedule and deadline (#2899)
    • PHP: Add __PROPERTY__ magic constant (#2924), add reserved keywords (#3002)
    • PostgreSQL: Add more keywords (#2985)
    • protobuf: Fix namespace tokenization (#2929)
    • Python: Add t-string support (#2973, #3009, #3010)
    • Tablegen: Fix infinite loop (#2972, #2940)
    • Tera Term macro: Add commands introduced in v5.3 through v5.6 (#2951)
    • TOML: Support TOML 1.1.0 (#3026, #3027)
    • Turtle: Allow empty comment lines (#2980)
    • XML: Added .xbrl as file ending (#2890, #2891)
  • Drop Python 3.8, and add Python 3.14 as a supported version (#2987, #3012)

  • Various improvements to autopygmentize (#2894)

  • Update onedark style to support more token types (#2977)

  • Update rtt style to support more token types (#2895)

  • Cache entry points to improve performance (#2979)

  • Fix xterm-256 color table (#3043)

  • Fix kwargs dictionary getting mutated on each call (#3044)

Changelog

Sourced from pygments's changelog.

Version 2.20.0

(released March 29th, 2026)

  • New lexers:

  • Updated lexers:

    • archetype: Fix catastrophic backtracking in GUID and ID patterns (#3064)
    • ASN.1: Recognize minus sign and fix range operator (#3014, #3060)
    • C++: Add C++26 keywords (#2955), add integer literal suffixes (#2966)
    • ComponentPascal: Fix analyse_text (#3028, #3032)
    • Coq renamed to Rocq (#2883, #2908)
    • Cython: Various improvements (#2932, #2933)
    • Debian control: Improve architecture parsing (#3052)
    • Devicetree: Add support for overlay/fragments (#3021), add bytestring support (#3022), fix catastrophic backtracking (#3057)
    • Fennel: Various improvements (#2911)
    • Haskell: Handle escape sequences in character literals (#3069, #1795)
    • Java: Add module keywords (#2955)
    • Lean4: Add operators ]', ]?, ]! (#2946)
    • LESS: Support single-line comments (#3005)
    • LilyPond: Update to 2.25.29 (#2974)
    • LLVM: Support C-style comments (#3023, #2978)
    • Lua(u): Fix catastrophic backtracking (#3047)
    • Macaulay2: Update to 1.25.05 (#2893), 1.25.11 (#2988)
    • Mathematica: Various improvements (#2957)
    • meson: Add additional operators (#2919)
    • MySQL: Update keywords (#2970)
    • org-Mode: Support both schedule and deadline (#2899)
    • PHP: Add __PROPERTY__ magic constant (#2924), add reserved keywords (#3002)
    • PostgreSQL: Add more keywords (#2985)
    • protobuf: Fix namespace tokenization (#2929)
    • Python: Add t-string support (#2973, #3009, #3010)
    • Tablegen: Fix infinite loop (#2972, #2940)
    • Tera Term macro: Add commands introduced in v5.3 through v5.6 (#2951)
    • TOML: Support TOML 1.1.0 (#3026, #3027)
    • Turtle: Allow empty comment lines (#2980)
    • XML: Added .xbrl as file ending (#2890, #2891)
  • Drop Python 3.8, and add Python 3.14 as a supported version (#2987, #3012)

  • Various improvements to autopygmentize (#2894)

  • Update onedark style to support more token types (#2977)

  • Update rtt style to support more token types (#2895)

  • Cache entry points to improve performance (#2979)

  • Fix xterm-256 color table (#3043)

  • Fix kwargs dictionary getting mutated on each call (#3044)

Commits
  • 708197d Fix underline length.
  • 1d4538a Prepare 2.20 release.
  • 2ceaee4 Update CHANGES.
  • e3a3c54 Fix Haskell lexer: handle escape sequences in character literals (#3069)
  • d7c3453 Merge pull request #3071 from pygments/harden-html-formatter
  • 0f97e7c Harden the HTML formatter against CSS.
  • 9f981b2 Update CHANGES.
  • 1d88915 Update CHANGES.
  • c3d93ad Fix ASN.1 lexer: recognize minus sign and fix range operator (#3060)
  • 4f06bcf fix bad behaving backtracking regex in CommonLispLexer
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pygments&package-manager=uv&previous-version=2.19.2&new-version=2.20.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/lance-format/lance/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/uv.lock | 4312 +++++++++++++++++++++++++++--------------------- 1 file changed, 2390 insertions(+), 1922 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 351f63aa8ed..b589925acf0 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,29 +1,31 @@ version = 1 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", ] [[package]] name = "absl-py" version = "2.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/2a/c93173ffa1b39c1d0395b7e842bbdc62e556ca9d8d3b5572926f3e4ca752/absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", size = 116588 } +sdist = { url = "https://files.pythonhosted.org/packages/10/2a/c93173ffa1b39c1d0395b7e842bbdc62e556ca9d8d3b5572926f3e4ca752/absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", size = 116588, upload-time = "2025-07-03T09:31:44.05Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811 }, + { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811, upload-time = "2025-07-03T09:31:42.253Z" }, ] [[package]] name = "aiohappyeyeballs" version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] @@ -40,76 +42,93 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/dc/ef9394bde9080128ad401ac7ede185267ed637df03b51f05d14d1c99ad67/aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc", size = 703921 }, - { url = "https://files.pythonhosted.org/packages/8f/42/63fccfc3a7ed97eb6e1a71722396f409c46b60a0552d8a56d7aad74e0df5/aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af", size = 480288 }, - { url = "https://files.pythonhosted.org/packages/9c/a2/7b8a020549f66ea2a68129db6960a762d2393248f1994499f8ba9728bbed/aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421", size = 468063 }, - { url = "https://files.pythonhosted.org/packages/8f/f5/d11e088da9176e2ad8220338ae0000ed5429a15f3c9dfd983f39105399cd/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79", size = 1650122 }, - { url = "https://files.pythonhosted.org/packages/b0/6b/b60ce2757e2faed3d70ed45dafee48cee7bfb878785a9423f7e883f0639c/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77", size = 1624176 }, - { url = "https://files.pythonhosted.org/packages/dd/de/8c9fde2072a1b72c4fadecf4f7d4be7a85b1d9a4ab333d8245694057b4c6/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c", size = 1696583 }, - { url = "https://files.pythonhosted.org/packages/0c/ad/07f863ca3d895a1ad958a54006c6dafb4f9310f8c2fdb5f961b8529029d3/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4", size = 1738896 }, - { url = "https://files.pythonhosted.org/packages/20/43/2bd482ebe2b126533e8755a49b128ec4e58f1a3af56879a3abdb7b42c54f/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6", size = 1643561 }, - { url = "https://files.pythonhosted.org/packages/23/40/2fa9f514c4cf4cbae8d7911927f81a1901838baf5e09a8b2c299de1acfe5/aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2", size = 1583685 }, - { url = "https://files.pythonhosted.org/packages/b8/c3/94dc7357bc421f4fb978ca72a201a6c604ee90148f1181790c129396ceeb/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d", size = 1627533 }, - { url = "https://files.pythonhosted.org/packages/bf/3f/1f8911fe1844a07001e26593b5c255a685318943864b27b4e0267e840f95/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb", size = 1638319 }, - { url = "https://files.pythonhosted.org/packages/4e/46/27bf57a99168c4e145ffee6b63d0458b9c66e58bb70687c23ad3d2f0bd17/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5", size = 1613776 }, - { url = "https://files.pythonhosted.org/packages/0f/7e/1d2d9061a574584bb4ad3dbdba0da90a27fdc795bc227def3a46186a8bc1/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b", size = 1693359 }, - { url = "https://files.pythonhosted.org/packages/08/98/bee429b52233c4a391980a5b3b196b060872a13eadd41c3a34be9b1469ed/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065", size = 1716598 }, - { url = "https://files.pythonhosted.org/packages/57/39/b0314c1ea774df3392751b686104a3938c63ece2b7ce0ba1ed7c0b4a934f/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1", size = 1644940 }, - { url = "https://files.pythonhosted.org/packages/1b/83/3dacb8d3f8f512c8ca43e3fa8a68b20583bd25636ffa4e56ee841ffd79ae/aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a", size = 429239 }, - { url = "https://files.pythonhosted.org/packages/eb/f9/470b5daba04d558c9673ca2034f28d067f3202a40e17804425f0c331c89f/aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830", size = 452297 }, - { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246 }, - { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515 }, - { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776 }, - { url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977 }, - { url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645 }, - { url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437 }, - { url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482 }, - { url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944 }, - { url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020 }, - { url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292 }, - { url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451 }, - { url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634 }, - { url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238 }, - { url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701 }, - { url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758 }, - { url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868 }, - { url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273 }, - { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333 }, - { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948 }, - { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787 }, - { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590 }, - { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241 }, - { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335 }, - { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491 }, - { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929 }, - { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733 }, - { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790 }, - { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245 }, - { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899 }, - { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459 }, - { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434 }, - { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045 }, - { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591 }, - { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266 }, - { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741 }, - { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407 }, - { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703 }, - { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532 }, - { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794 }, - { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865 }, - { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238 }, - { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566 }, - { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270 }, - { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294 }, - { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958 }, - { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553 }, - { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688 }, - { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157 }, - { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050 }, - { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647 }, - { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067 }, +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/dc/ef9394bde9080128ad401ac7ede185267ed637df03b51f05d14d1c99ad67/aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc", size = 703921, upload-time = "2025-07-29T05:49:43.584Z" }, + { url = "https://files.pythonhosted.org/packages/8f/42/63fccfc3a7ed97eb6e1a71722396f409c46b60a0552d8a56d7aad74e0df5/aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af", size = 480288, upload-time = "2025-07-29T05:49:47.851Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a2/7b8a020549f66ea2a68129db6960a762d2393248f1994499f8ba9728bbed/aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421", size = 468063, upload-time = "2025-07-29T05:49:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f5/d11e088da9176e2ad8220338ae0000ed5429a15f3c9dfd983f39105399cd/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79", size = 1650122, upload-time = "2025-07-29T05:49:51.874Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6b/b60ce2757e2faed3d70ed45dafee48cee7bfb878785a9423f7e883f0639c/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77", size = 1624176, upload-time = "2025-07-29T05:49:53.805Z" }, + { url = "https://files.pythonhosted.org/packages/dd/de/8c9fde2072a1b72c4fadecf4f7d4be7a85b1d9a4ab333d8245694057b4c6/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c", size = 1696583, upload-time = "2025-07-29T05:49:55.338Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ad/07f863ca3d895a1ad958a54006c6dafb4f9310f8c2fdb5f961b8529029d3/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4", size = 1738896, upload-time = "2025-07-29T05:49:57.045Z" }, + { url = "https://files.pythonhosted.org/packages/20/43/2bd482ebe2b126533e8755a49b128ec4e58f1a3af56879a3abdb7b42c54f/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6", size = 1643561, upload-time = "2025-07-29T05:49:58.762Z" }, + { url = "https://files.pythonhosted.org/packages/23/40/2fa9f514c4cf4cbae8d7911927f81a1901838baf5e09a8b2c299de1acfe5/aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2", size = 1583685, upload-time = "2025-07-29T05:50:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c3/94dc7357bc421f4fb978ca72a201a6c604ee90148f1181790c129396ceeb/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d", size = 1627533, upload-time = "2025-07-29T05:50:02.306Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3f/1f8911fe1844a07001e26593b5c255a685318943864b27b4e0267e840f95/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb", size = 1638319, upload-time = "2025-07-29T05:50:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/4e/46/27bf57a99168c4e145ffee6b63d0458b9c66e58bb70687c23ad3d2f0bd17/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5", size = 1613776, upload-time = "2025-07-29T05:50:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7e/1d2d9061a574584bb4ad3dbdba0da90a27fdc795bc227def3a46186a8bc1/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b", size = 1693359, upload-time = "2025-07-29T05:50:07.563Z" }, + { url = "https://files.pythonhosted.org/packages/08/98/bee429b52233c4a391980a5b3b196b060872a13eadd41c3a34be9b1469ed/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065", size = 1716598, upload-time = "2025-07-29T05:50:09.33Z" }, + { url = "https://files.pythonhosted.org/packages/57/39/b0314c1ea774df3392751b686104a3938c63ece2b7ce0ba1ed7c0b4a934f/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1", size = 1644940, upload-time = "2025-07-29T05:50:11.334Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/3dacb8d3f8f512c8ca43e3fa8a68b20583bd25636ffa4e56ee841ffd79ae/aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a", size = 429239, upload-time = "2025-07-29T05:50:12.803Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f9/470b5daba04d558c9673ca2034f28d067f3202a40e17804425f0c331c89f/aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830", size = 452297, upload-time = "2025-07-29T05:50:14.266Z" }, + { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246, upload-time = "2025-07-29T05:50:15.937Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515, upload-time = "2025-07-29T05:50:17.442Z" }, + { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776, upload-time = "2025-07-29T05:50:19.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977, upload-time = "2025-07-29T05:50:21.665Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645, upload-time = "2025-07-29T05:50:23.333Z" }, + { url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437, upload-time = "2025-07-29T05:50:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482, upload-time = "2025-07-29T05:50:26.693Z" }, + { url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944, upload-time = "2025-07-29T05:50:28.382Z" }, + { url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020, upload-time = "2025-07-29T05:50:30.032Z" }, + { url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292, upload-time = "2025-07-29T05:50:31.983Z" }, + { url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451, upload-time = "2025-07-29T05:50:33.989Z" }, + { url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634, upload-time = "2025-07-29T05:50:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238, upload-time = "2025-07-29T05:50:37.597Z" }, + { url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701, upload-time = "2025-07-29T05:50:39.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758, upload-time = "2025-07-29T05:50:41.292Z" }, + { url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868, upload-time = "2025-07-29T05:50:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273, upload-time = "2025-07-29T05:50:44.613Z" }, + { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, + { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, + { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, + { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, + { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/18/8d/da08099af8db234d1cd43163e6ffc8e9313d0e988cee1901610f2fa5c764/aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98", size = 706829, upload-time = "2025-07-29T05:51:54.434Z" }, + { url = "https://files.pythonhosted.org/packages/4e/94/8eed385cfb60cf4fdb5b8a165f6148f3bebeb365f08663d83c35a5f273ef/aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406", size = 481806, upload-time = "2025-07-29T05:51:56.355Z" }, + { url = "https://files.pythonhosted.org/packages/38/68/b13e1a34584fbf263151b3a72a084e89f2102afe38df1dce5a05a15b83e9/aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d", size = 469205, upload-time = "2025-07-29T05:51:58.277Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/3d7348bf53aa4af54416bc64cbef3a2ac5e8b9bfa97cc45f1cf9a94d9c8d/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf", size = 1644174, upload-time = "2025-07-29T05:52:00.23Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/fd9b5b22b0f6ca1a85c33bb4868cbcc6ae5eae070a0f4c9c5cad003c89d7/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6", size = 1618672, upload-time = "2025-07-29T05:52:02.272Z" }, + { url = "https://files.pythonhosted.org/packages/39/f7/f6530ab5f8c8c409e44a63fcad35e839c87aabecdfe5b8e96d671ed12f64/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142", size = 1692295, upload-time = "2025-07-29T05:52:04.546Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/3cf483bb0106566dc97ebaa2bb097f5e44d4bc4ab650a6f107151cd7b193/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89", size = 1731609, upload-time = "2025-07-29T05:52:06.552Z" }, + { url = "https://files.pythonhosted.org/packages/de/a4/fd04bf807851197077d9cac9381d58f86d91c95c06cbaf9d3a776ac4467a/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263", size = 1637852, upload-time = "2025-07-29T05:52:08.975Z" }, + { url = "https://files.pythonhosted.org/packages/98/03/29d626ca3bcdcafbd74b45d77ca42645a5c94d396f2ee3446880ad2405fb/aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530", size = 1572852, upload-time = "2025-07-29T05:52:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cd/b4777a9e204f4e01091091027e5d1e2fa86decd0fee5067bc168e4fa1e76/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75", size = 1620813, upload-time = "2025-07-29T05:52:13.891Z" }, + { url = "https://files.pythonhosted.org/packages/ae/26/1a44a6e8417e84057beaf8c462529b9e05d4b53b8605784f1eb571f0ff68/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05", size = 1630951, upload-time = "2025-07-29T05:52:15.955Z" }, + { url = "https://files.pythonhosted.org/packages/dd/7f/10c605dbd01c40e2b27df7ef9004bec75d156f0705141e11047ecdfe264d/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54", size = 1607595, upload-time = "2025-07-29T05:52:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/66/f6/2560dcb01731c1d7df1d34b64de95bc4b3ed02bb78830fd82299c1eb314e/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02", size = 1695194, upload-time = "2025-07-29T05:52:20.255Z" }, + { url = "https://files.pythonhosted.org/packages/e7/02/ee105ae82dc2b981039fd25b0cf6eaa52b493731960f9bc861375a72b463/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0", size = 1710872, upload-time = "2025-07-29T05:52:22.769Z" }, + { url = "https://files.pythonhosted.org/packages/88/16/70c4e42ed6a04f78fb58d1a46500a6ce560741d13afde2a5f33840746a5f/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09", size = 1640539, upload-time = "2025-07-29T05:52:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1d/a7eb5fa8a6967117c5c0ad5ab4b1dec0d21e178c89aa08bc442a0b836392/aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d", size = 430164, upload-time = "2025-07-29T05:52:27.905Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/e0cf8793aedc41c6d7f2aad646a27e27bdacafe3b402bb373d7651c94d73/aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8", size = 453370, upload-time = "2025-07-29T05:52:29.936Z" }, ] [[package]] @@ -120,18 +139,18 @@ dependencies = [ { name = "frozenlist" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -141,59 +160,72 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/01/f06342d2eb822153f63d188153e41fbeabb29b48247f7a11ce76c538f7d1/arro3_core-0.6.5.tar.gz", hash = "sha256:768078887cd7ac82de4736f94bbd91f6d660f10779848bd5b019f511badd9d75", size = 107522 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/8a/24b35cf01a68621f5f07e3191ca96f70a145022ca367347266901eb504a7/arro3_core-0.6.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:da193dc2fb8c2005d0b3887b09d1a90d42cec1f59f17a8a1a5791f0de90946ae", size = 2678116 }, - { url = "https://files.pythonhosted.org/packages/5a/7a/4398bb0582fb22d575f256f2b9ac7be735c765222cc61fb214d606bdb77c/arro3_core-0.6.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed1a760ec39fe19c65e98f45515582408002d0212df5db227a5959ffeb07ad4a", size = 2383214 }, - { url = "https://files.pythonhosted.org/packages/82/3f/a321501c5da4bf3ff7438c3e5eb6e63bcecb5630c0f4a89a017cbfa8e4a0/arro3_core-0.6.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6584a3d28007740afcef1e301332876e2b785bd8edd59a458a6bc9b051bce052", size = 2883536 }, - { url = "https://files.pythonhosted.org/packages/0d/50/1d1e55b9a8c4cf2fdeb954947aa135010554a3333b709e8cad3d5d084be2/arro3_core-0.6.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8e0af4789618f02bead4a0cd4d0a54abd9c8aa4fcedf9872b4891d2e3e984161", size = 2908828 }, - { url = "https://files.pythonhosted.org/packages/12/75/b4b1de1ccb17890bada9a3f4131cf3137f145d5d10490db51de6b8799926/arro3_core-0.6.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c73f212e549e9b6d11cfe3f14bbf3fba9d0891426afb5916688d16d0df724085", size = 3145458 }, - { url = "https://files.pythonhosted.org/packages/08/4f/f42ce1840490fd0863bfbc56f28eaaec3bcb4eb322079af9c070111657e5/arro3_core-0.6.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f88f62e4e276a9e84f250722d2e5ffc078af9a3f67ac691f572a0e05dd6095", size = 2775793 }, - { url = "https://files.pythonhosted.org/packages/2b/aa/9637efc8d8733c34bedef44e5b2c170dea14d15ab56b3566d8d7963c2616/arro3_core-0.6.5-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:b2635e4c227f25ff8784dc8efb38cb7c1674646cfdc68ded53f2426289885f0e", size = 2516697 }, - { url = "https://files.pythonhosted.org/packages/60/84/1fcfadf956bc25eb5251b1ea7a7099f05198a55764635d2fc9ceafdbdbd1/arro3_core-0.6.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5f3e936686bcd8542fafc94c68fdb23ec42d1d51a4777967ae815c90aff7296", size = 3023625 }, - { url = "https://files.pythonhosted.org/packages/58/d0/52d0cb3c0dfa8e94ba2118b7e91a70da76d6ede9de4e70374f831f38cfdf/arro3_core-0.6.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:705c32fec03dadc08f807d69ce557882005d43eb20ec62699f7036340f0d580f", size = 2701346 }, - { url = "https://files.pythonhosted.org/packages/69/bf/42a6f6501805c31cb65d8a6e3379eeec4fa6c26dc07c9ce894f363ccad1c/arro3_core-0.6.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:56d8166235a4c54e4f7ba082ec76890c820fa8c1b6c995ec59cead62a9698e59", size = 3153207 }, - { url = "https://files.pythonhosted.org/packages/4f/e5/41fdee468b33759b42958347c2d70b0461bf8f70ba1762a94cdf2e9b0142/arro3_core-0.6.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1ba43ba9081c00767083195222b6be74913de668296f55599658c4b0bb7cd327", size = 3105033 }, - { url = "https://files.pythonhosted.org/packages/03/e0/b6d733b4540c05bac546162e045b547031f4d88c67b7c864929d9bce29ad/arro3_core-0.6.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4f5df13c6742e3f0b494cfe9025dccdc8426a74cc9e3e5a1239311e07a4b24e0", size = 2954793 }, - { url = "https://files.pythonhosted.org/packages/c0/34/8353ba79c8d0498eaacc077d58b384ef785e0b69c9cbff7c2580136b8fe3/arro3_core-0.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:34676b728178236df63c9ea10b21432392d4b5bb51e2030e77c68eed4dede2ad", size = 2837495 }, - { url = "https://files.pythonhosted.org/packages/78/85/20e46d3ed59d2f93be4a4d1abea4f6bef3e96acd59bf5a50726f84303c51/arro3_core-0.6.5-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9d5999506daec1ab31096b3deb1e3573041d6ecadb4ca99c96f7ab26720c592c", size = 2685615 }, - { url = "https://files.pythonhosted.org/packages/d0/9c/427d578f7d2bf3149515a8b75217e7189e7b1d74e5c5609e1a7e7f0f8d3c/arro3_core-0.6.5-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:bd3e251184c2dd6ade81c5613256b6d85ab3ddbd5af838b1de657e0ddec017f8", size = 2391944 }, - { url = "https://files.pythonhosted.org/packages/90/24/7e4af478eb889bfa401e1c1b8868048ca692e6205affbf81cf3666347852/arro3_core-0.6.5-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cadb29349960d3821b0515d9df80f2725cea155ad966c699f6084de32e313cb", size = 2888376 }, - { url = "https://files.pythonhosted.org/packages/70/3b/01006a96bc980275aa4d2eb759c5f10afb7c85fcdce3c36ddb18635ad23b/arro3_core-0.6.5-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a922e560ed2ccee3293d51b39e013b51cc233895d25ddafcacfb83c540a19e6f", size = 2916568 }, - { url = "https://files.pythonhosted.org/packages/a2/2f/4e04c7f5687de6fb6f88aa7590b16bcf507ba17ddbd268525f27b70b7a68/arro3_core-0.6.5-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68fe6672bf51f039b12046a209cba0a9405e10ae44e5a0d557f091b356a62051", size = 3144223 }, - { url = "https://files.pythonhosted.org/packages/31/4a/72dc383d1a0d14f1d453e334e3461e229762edb1bf3f75b3ab977e9386ed/arro3_core-0.6.5-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c3ee95603e375401a58ff763ce2c8aa858e0c4f757c1fb719f48fb070f540b2", size = 2781862 }, - { url = "https://files.pythonhosted.org/packages/14/dc/0df7684b683114eaf8e57989b4230edb359cbfb6e98b8770d69128b27572/arro3_core-0.6.5-cp311-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:fbaf6b65213630007b798b565e0701c2092a330deeba16bd3d896d401f7e9f28", size = 2522442 }, - { url = "https://files.pythonhosted.org/packages/c9/04/75f8627cd7fe4d103eca51760d50269cfbc0bf6beaf83a3cdefb4ebd37c7/arro3_core-0.6.5-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20679f874558bb2113e96325522625ec64a72687000b7a9578031a4d082c6ef5", size = 3033454 }, - { url = "https://files.pythonhosted.org/packages/ea/19/f2d54985da65bf6d3da76218bee56383285035541c8d0cadb53095845b3e/arro3_core-0.6.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d82d6ec32d5c7c73057fb9c528390289fd5bc94b8d8f28fca9c56fc8e41c412c", size = 2705984 }, - { url = "https://files.pythonhosted.org/packages/6c/53/b1d7742d6db7b4aa44d3785956955d651b3ac36db321625fd15466be1aca/arro3_core-0.6.5-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4cba4db0a4203a3ccf131c3fb7804d77f0740d6165ec9efa3aa3acbca87c43a3", size = 3157472 }, - { url = "https://files.pythonhosted.org/packages/05/31/68711327dbdd480aed54158fc1c46ab245e860ab0286e0916ce788f9889e/arro3_core-0.6.5-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:e358affc4a0fe5c1b5dccf4f92c43a836aaa4c4eab0906c83b00b60275de3b6d", size = 3117099 }, - { url = "https://files.pythonhosted.org/packages/31/e3/15ffca0797d9500b23759ae4477cf052fde8dd47a3890f4e4e1d04639016/arro3_core-0.6.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:324e43f07b7681846d00a8995b78bdc4b4a719047aa0d34426b462b8f208ee98", size = 2963677 }, - { url = "https://files.pythonhosted.org/packages/bc/02/69e60dbe3bbe2bfc8b6dfa4f4bfcb8d1dd240a137bf2a5f7bcc84703f05c/arro3_core-0.6.5-cp311-abi3-win_amd64.whl", hash = "sha256:285f802c8a42fe29ecb84584d1700bc4c4f974552b75f805e1f4362d28b97080", size = 2850445 }, - { url = "https://files.pythonhosted.org/packages/b1/29/2e5b091f6b5cffb6489dbe7ed353841568dde8ac4d1232c77321da1d0925/arro3_core-0.6.5-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:8c20e69c3b3411fd6ed56091f388e699072651e880e682be5bd14f3a392ed3e8", size = 2671985 }, - { url = "https://files.pythonhosted.org/packages/30/74/764ac4b58fef3fdfc655416c42349206156db5c687fa24a0674acaeaadbb/arro3_core-0.6.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:92211f1d03221ff74d0b535a576b39601083d8e98e9d47228314573f9d4f9ae2", size = 2382931 }, - { url = "https://files.pythonhosted.org/packages/6a/07/bd8c92e218240ae8a30150a5d7a2dab359b452ab54a8bb7b90effe806e3d/arro3_core-0.6.5-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:280d933b75f2649779d76e32a07f91d2352a952f2c97ddf7b320e267f440cd42", size = 2879900 }, - { url = "https://files.pythonhosted.org/packages/0f/d4/253725019fe2ae5f5fde87928118ffa568cc59f07b2d6a0e90620938c537/arro3_core-0.6.5-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfc3f6b93b924f43fb7985b06202343c30b43da6bd5055ba8b84eda431e494d4", size = 2904149 }, - { url = "https://files.pythonhosted.org/packages/f0/b0/7a3dea641ac8de041c1a34859a2f2a82d3cdf3c3360872101c1d198a1e24/arro3_core-0.6.5-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5963635eb698ebc7da689e641f68b3998864bab894cf0ca84bd058b8c60d97f", size = 3143477 }, - { url = "https://files.pythonhosted.org/packages/a7/05/1a50575be33fe9240898a1b5a8574658a905b5675865285585e070dcf7e2/arro3_core-0.6.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac291b3e74b57e56e03373d57530540cbbbfd92e4219fe2778ea531006673fe9", size = 2776522 }, - { url = "https://files.pythonhosted.org/packages/2e/bd/e7b03207e7906e94e327cd4190fdb2d26ae52bc4ee1edeb057fed760796b/arro3_core-0.6.5-cp313-cp313t-manylinux_2_24_aarch64.whl", hash = "sha256:5d3f4cc58a654037d61f61ba230419da2c8f88a0ac82b9d41fe307f7cf9fda97", size = 2515426 }, - { url = "https://files.pythonhosted.org/packages/f9/ed/82d1febd5c104eccdfb82434e3619125c328c36da143e19dfa3c86de4a81/arro3_core-0.6.5-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:93cddac90238d64451f5e66c630ded89d0b5fd6d2c099bf3a5151dde2c1ddf1d", size = 3024759 }, - { url = "https://files.pythonhosted.org/packages/da/cd/00e06907e42e404c21eb08282dee94ac7a1961facfa9a96d116829031721/arro3_core-0.6.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1fa7ac10db5846c33f4e8b66a6eaa705d84998e38575a835acac9a6a6649933d", size = 2700191 }, - { url = "https://files.pythonhosted.org/packages/a3/11/a4bb9a900f456a6905d481bd2289f7a2371dcde024de56779621fd6a92c3/arro3_core-0.6.5-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:ca69f698a065cdbf845d59d412bc204e8f8af12f93737d82e6a18f3cff812349", size = 3149963 }, - { url = "https://files.pythonhosted.org/packages/28/8a/79c76ad88b16f2fac25684f7313593738f353355eb1af2307e43efd7b1ca/arro3_core-0.6.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:de74a2512e2e2366d4b064c498c38672bf6ddea38acec8b1999b4e66182dd001", size = 3104663 }, - { url = "https://files.pythonhosted.org/packages/20/66/9152feaa87f851a37c1a2bd74fb89d7e82e4c76447ee590bf8e6fff5e9d8/arro3_core-0.6.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:806ca8e20507675b2de68b3d009f76e898cc3c3e441c834ea5220866f68aac50", size = 2956440 }, - { url = "https://files.pythonhosted.org/packages/ad/66/f4179ef64d5c18fe76ec93cfbff42c0f401438ef771c6766b880044d7e13/arro3_core-0.6.5-cp313-cp313t-win_amd64.whl", hash = "sha256:8f6f0cc78877ade7ad6e678a4671b191406547e7b407bc9637436869c017ed47", size = 2845345 }, - { url = "https://files.pythonhosted.org/packages/10/ca/b2139dbb25f9fefb9b1cdce8a73785615de6763af6a16bf6ff96a3b630f2/arro3_core-0.6.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:26d5b50139f1a96727fa1760b4d70393acf5ee0fba45346ad2d4f69824d3bdc2", size = 2676788 }, - { url = "https://files.pythonhosted.org/packages/34/a1/c68dde2944f493c8ccfcb91bf6da6d27a27c3674316dd09c9560f9e6ab1a/arro3_core-0.6.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b65b3d8d7f65f2f3c36002dc467380d7a31ea771132986dddc6341c5a9dc726f", size = 2382809 }, - { url = "https://files.pythonhosted.org/packages/c6/fc/2fb81d42a3cecd632deace97dc23ac74083d60d158106440c783bae4ff01/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c3442a79a757ed3fbd7793de180019ae3201f04237537c2e2e3f1e3dd99b31c", size = 2882818 }, - { url = "https://files.pythonhosted.org/packages/58/7f/16f741e1d49ba5c5a893ce6f8eb0283d64bc68d6cc9e07ac62f96eaadfae/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:def7b0065a684d6f903a658d2567da47e2fcecde716e0b34eff4d899c6468c8d", size = 2907503 }, - { url = "https://files.pythonhosted.org/packages/eb/45/2eb7972e0bbec0ee0ab22b0f166ec1ea74b53bd76c93a18ced434713e495/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbfe2f2d4d0d393833cd6a4bd9c15266a02307a3028f159155a1c536469c3ae7", size = 3143706 }, - { url = "https://files.pythonhosted.org/packages/2d/af/b78e28842faa675e4e6c4d82e861accf21ac08bbab80a65fa80c578f80a1/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a191a3e4f72c34f7ace7724a94f2d90b06c804a6cbece4ae0f18d36325479cf3", size = 2775462 }, - { url = "https://files.pythonhosted.org/packages/45/df/950e57e4915e0457acadaaca13c4423d5e2652e403135eb7606d5e6e5443/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:e3f6ab4c6ea96c451eff72aa6c5b9835a0ea8a9847cfe3995c88cce0c7701fb5", size = 2516212 }, - { url = "https://files.pythonhosted.org/packages/07/73/821640d0827a829ed2565c2d4812080ab7fb86f0d271b462f9b37e6d946e/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:27df5239835330299636a02977f2cb34d5c460cc03b2ae1d6ab6a03d28051b08", size = 3023342 }, - { url = "https://files.pythonhosted.org/packages/fd/30/51302d2f4d1b627dd11e2be979f2c48550b782d8d58d0378316342e284a8/arro3_core-0.6.5-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:71dce89c0e91be4cfb42591f03809235bbc374c396e08acdf93c4d85b09e40f5", size = 2700740 }, - { url = "https://files.pythonhosted.org/packages/1d/e8/0c8a345a013bb64abea60b4864bacc01e43b8699b8874794baec9c8a7e76/arro3_core-0.6.5-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:d380c28f85568ed99c1686fb9d64b5a811d76d569f367cbec8ef7e58f6e2fdf9", size = 3152749 }, - { url = "https://files.pythonhosted.org/packages/6a/42/003b30c4da394366d5967a5b993f7471a74182c983d8f757891b3dd5d594/arro3_core-0.6.5-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:8e359c0c4fe9992f5a863a4a31502ea58eb2f92988fc2e501850540b3eff0328", size = 3104676 }, - { url = "https://files.pythonhosted.org/packages/0b/fd/4f8dac58ea17e05978bf35cb9a3e485b1ff3cdd6e2cc29deb08f54080de4/arro3_core-0.6.5-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a58acbc61480b533aa84d735db04b1e68fc7f6807ab694d606c03b5e694d83d", size = 2954405 }, +sdist = { url = "https://files.pythonhosted.org/packages/2a/01/f06342d2eb822153f63d188153e41fbeabb29b48247f7a11ce76c538f7d1/arro3_core-0.6.5.tar.gz", hash = "sha256:768078887cd7ac82de4736f94bbd91f6d660f10779848bd5b019f511badd9d75", size = 107522, upload-time = "2025-10-13T23:12:38.872Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/8a/24b35cf01a68621f5f07e3191ca96f70a145022ca367347266901eb504a7/arro3_core-0.6.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:da193dc2fb8c2005d0b3887b09d1a90d42cec1f59f17a8a1a5791f0de90946ae", size = 2678116, upload-time = "2025-10-13T23:09:04.198Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/4398bb0582fb22d575f256f2b9ac7be735c765222cc61fb214d606bdb77c/arro3_core-0.6.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed1a760ec39fe19c65e98f45515582408002d0212df5db227a5959ffeb07ad4a", size = 2383214, upload-time = "2025-10-13T23:09:06.841Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/a321501c5da4bf3ff7438c3e5eb6e63bcecb5630c0f4a89a017cbfa8e4a0/arro3_core-0.6.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6584a3d28007740afcef1e301332876e2b785bd8edd59a458a6bc9b051bce052", size = 2883536, upload-time = "2025-10-13T23:09:08.877Z" }, + { url = "https://files.pythonhosted.org/packages/0d/50/1d1e55b9a8c4cf2fdeb954947aa135010554a3333b709e8cad3d5d084be2/arro3_core-0.6.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8e0af4789618f02bead4a0cd4d0a54abd9c8aa4fcedf9872b4891d2e3e984161", size = 2908828, upload-time = "2025-10-13T23:09:10.958Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/b4b1de1ccb17890bada9a3f4131cf3137f145d5d10490db51de6b8799926/arro3_core-0.6.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c73f212e549e9b6d11cfe3f14bbf3fba9d0891426afb5916688d16d0df724085", size = 3145458, upload-time = "2025-10-13T23:09:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/08/4f/f42ce1840490fd0863bfbc56f28eaaec3bcb4eb322079af9c070111657e5/arro3_core-0.6.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f88f62e4e276a9e84f250722d2e5ffc078af9a3f67ac691f572a0e05dd6095", size = 2775793, upload-time = "2025-10-13T23:09:15.342Z" }, + { url = "https://files.pythonhosted.org/packages/2b/aa/9637efc8d8733c34bedef44e5b2c170dea14d15ab56b3566d8d7963c2616/arro3_core-0.6.5-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:b2635e4c227f25ff8784dc8efb38cb7c1674646cfdc68ded53f2426289885f0e", size = 2516697, upload-time = "2025-10-13T23:09:17.584Z" }, + { url = "https://files.pythonhosted.org/packages/60/84/1fcfadf956bc25eb5251b1ea7a7099f05198a55764635d2fc9ceafdbdbd1/arro3_core-0.6.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5f3e936686bcd8542fafc94c68fdb23ec42d1d51a4777967ae815c90aff7296", size = 3023625, upload-time = "2025-10-13T23:09:21.556Z" }, + { url = "https://files.pythonhosted.org/packages/58/d0/52d0cb3c0dfa8e94ba2118b7e91a70da76d6ede9de4e70374f831f38cfdf/arro3_core-0.6.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:705c32fec03dadc08f807d69ce557882005d43eb20ec62699f7036340f0d580f", size = 2701346, upload-time = "2025-10-13T23:09:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/69/bf/42a6f6501805c31cb65d8a6e3379eeec4fa6c26dc07c9ce894f363ccad1c/arro3_core-0.6.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:56d8166235a4c54e4f7ba082ec76890c820fa8c1b6c995ec59cead62a9698e59", size = 3153207, upload-time = "2025-10-13T23:09:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e5/41fdee468b33759b42958347c2d70b0461bf8f70ba1762a94cdf2e9b0142/arro3_core-0.6.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1ba43ba9081c00767083195222b6be74913de668296f55599658c4b0bb7cd327", size = 3105033, upload-time = "2025-10-13T23:09:31.545Z" }, + { url = "https://files.pythonhosted.org/packages/03/e0/b6d733b4540c05bac546162e045b547031f4d88c67b7c864929d9bce29ad/arro3_core-0.6.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4f5df13c6742e3f0b494cfe9025dccdc8426a74cc9e3e5a1239311e07a4b24e0", size = 2954793, upload-time = "2025-10-13T23:09:34.988Z" }, + { url = "https://files.pythonhosted.org/packages/c0/34/8353ba79c8d0498eaacc077d58b384ef785e0b69c9cbff7c2580136b8fe3/arro3_core-0.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:34676b728178236df63c9ea10b21432392d4b5bb51e2030e77c68eed4dede2ad", size = 2837495, upload-time = "2025-10-13T23:09:38.539Z" }, + { url = "https://files.pythonhosted.org/packages/78/85/20e46d3ed59d2f93be4a4d1abea4f6bef3e96acd59bf5a50726f84303c51/arro3_core-0.6.5-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9d5999506daec1ab31096b3deb1e3573041d6ecadb4ca99c96f7ab26720c592c", size = 2685615, upload-time = "2025-10-13T23:09:41.793Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/427d578f7d2bf3149515a8b75217e7189e7b1d74e5c5609e1a7e7f0f8d3c/arro3_core-0.6.5-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:bd3e251184c2dd6ade81c5613256b6d85ab3ddbd5af838b1de657e0ddec017f8", size = 2391944, upload-time = "2025-10-13T23:09:45.266Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/7e4af478eb889bfa401e1c1b8868048ca692e6205affbf81cf3666347852/arro3_core-0.6.5-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cadb29349960d3821b0515d9df80f2725cea155ad966c699f6084de32e313cb", size = 2888376, upload-time = "2025-10-13T23:09:48.737Z" }, + { url = "https://files.pythonhosted.org/packages/70/3b/01006a96bc980275aa4d2eb759c5f10afb7c85fcdce3c36ddb18635ad23b/arro3_core-0.6.5-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a922e560ed2ccee3293d51b39e013b51cc233895d25ddafcacfb83c540a19e6f", size = 2916568, upload-time = "2025-10-13T23:09:51.95Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/4e04c7f5687de6fb6f88aa7590b16bcf507ba17ddbd268525f27b70b7a68/arro3_core-0.6.5-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68fe6672bf51f039b12046a209cba0a9405e10ae44e5a0d557f091b356a62051", size = 3144223, upload-time = "2025-10-13T23:09:55.387Z" }, + { url = "https://files.pythonhosted.org/packages/31/4a/72dc383d1a0d14f1d453e334e3461e229762edb1bf3f75b3ab977e9386ed/arro3_core-0.6.5-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c3ee95603e375401a58ff763ce2c8aa858e0c4f757c1fb719f48fb070f540b2", size = 2781862, upload-time = "2025-10-13T23:09:59.035Z" }, + { url = "https://files.pythonhosted.org/packages/14/dc/0df7684b683114eaf8e57989b4230edb359cbfb6e98b8770d69128b27572/arro3_core-0.6.5-cp311-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:fbaf6b65213630007b798b565e0701c2092a330deeba16bd3d896d401f7e9f28", size = 2522442, upload-time = "2025-10-13T23:10:02.134Z" }, + { url = "https://files.pythonhosted.org/packages/c9/04/75f8627cd7fe4d103eca51760d50269cfbc0bf6beaf83a3cdefb4ebd37c7/arro3_core-0.6.5-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20679f874558bb2113e96325522625ec64a72687000b7a9578031a4d082c6ef5", size = 3033454, upload-time = "2025-10-13T23:10:05.192Z" }, + { url = "https://files.pythonhosted.org/packages/ea/19/f2d54985da65bf6d3da76218bee56383285035541c8d0cadb53095845b3e/arro3_core-0.6.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d82d6ec32d5c7c73057fb9c528390289fd5bc94b8d8f28fca9c56fc8e41c412c", size = 2705984, upload-time = "2025-10-13T23:10:08.518Z" }, + { url = "https://files.pythonhosted.org/packages/6c/53/b1d7742d6db7b4aa44d3785956955d651b3ac36db321625fd15466be1aca/arro3_core-0.6.5-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4cba4db0a4203a3ccf131c3fb7804d77f0740d6165ec9efa3aa3acbca87c43a3", size = 3157472, upload-time = "2025-10-13T23:10:11.976Z" }, + { url = "https://files.pythonhosted.org/packages/05/31/68711327dbdd480aed54158fc1c46ab245e860ab0286e0916ce788f9889e/arro3_core-0.6.5-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:e358affc4a0fe5c1b5dccf4f92c43a836aaa4c4eab0906c83b00b60275de3b6d", size = 3117099, upload-time = "2025-10-13T23:10:15.374Z" }, + { url = "https://files.pythonhosted.org/packages/31/e3/15ffca0797d9500b23759ae4477cf052fde8dd47a3890f4e4e1d04639016/arro3_core-0.6.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:324e43f07b7681846d00a8995b78bdc4b4a719047aa0d34426b462b8f208ee98", size = 2963677, upload-time = "2025-10-13T23:10:18.828Z" }, + { url = "https://files.pythonhosted.org/packages/bc/02/69e60dbe3bbe2bfc8b6dfa4f4bfcb8d1dd240a137bf2a5f7bcc84703f05c/arro3_core-0.6.5-cp311-abi3-win_amd64.whl", hash = "sha256:285f802c8a42fe29ecb84584d1700bc4c4f974552b75f805e1f4362d28b97080", size = 2850445, upload-time = "2025-10-13T23:10:22.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/2e5b091f6b5cffb6489dbe7ed353841568dde8ac4d1232c77321da1d0925/arro3_core-0.6.5-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:8c20e69c3b3411fd6ed56091f388e699072651e880e682be5bd14f3a392ed3e8", size = 2671985, upload-time = "2025-10-13T23:10:25.515Z" }, + { url = "https://files.pythonhosted.org/packages/30/74/764ac4b58fef3fdfc655416c42349206156db5c687fa24a0674acaeaadbb/arro3_core-0.6.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:92211f1d03221ff74d0b535a576b39601083d8e98e9d47228314573f9d4f9ae2", size = 2382931, upload-time = "2025-10-13T23:10:29.893Z" }, + { url = "https://files.pythonhosted.org/packages/6a/07/bd8c92e218240ae8a30150a5d7a2dab359b452ab54a8bb7b90effe806e3d/arro3_core-0.6.5-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:280d933b75f2649779d76e32a07f91d2352a952f2c97ddf7b320e267f440cd42", size = 2879900, upload-time = "2025-10-13T23:10:33.238Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d4/253725019fe2ae5f5fde87928118ffa568cc59f07b2d6a0e90620938c537/arro3_core-0.6.5-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfc3f6b93b924f43fb7985b06202343c30b43da6bd5055ba8b84eda431e494d4", size = 2904149, upload-time = "2025-10-13T23:10:36.547Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b0/7a3dea641ac8de041c1a34859a2f2a82d3cdf3c3360872101c1d198a1e24/arro3_core-0.6.5-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5963635eb698ebc7da689e641f68b3998864bab894cf0ca84bd058b8c60d97f", size = 3143477, upload-time = "2025-10-13T23:10:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/a7/05/1a50575be33fe9240898a1b5a8574658a905b5675865285585e070dcf7e2/arro3_core-0.6.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac291b3e74b57e56e03373d57530540cbbbfd92e4219fe2778ea531006673fe9", size = 2776522, upload-time = "2025-10-13T23:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/2e/bd/e7b03207e7906e94e327cd4190fdb2d26ae52bc4ee1edeb057fed760796b/arro3_core-0.6.5-cp313-cp313t-manylinux_2_24_aarch64.whl", hash = "sha256:5d3f4cc58a654037d61f61ba230419da2c8f88a0ac82b9d41fe307f7cf9fda97", size = 2515426, upload-time = "2025-10-13T23:10:46.926Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ed/82d1febd5c104eccdfb82434e3619125c328c36da143e19dfa3c86de4a81/arro3_core-0.6.5-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:93cddac90238d64451f5e66c630ded89d0b5fd6d2c099bf3a5151dde2c1ddf1d", size = 3024759, upload-time = "2025-10-13T23:10:50.281Z" }, + { url = "https://files.pythonhosted.org/packages/da/cd/00e06907e42e404c21eb08282dee94ac7a1961facfa9a96d116829031721/arro3_core-0.6.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1fa7ac10db5846c33f4e8b66a6eaa705d84998e38575a835acac9a6a6649933d", size = 2700191, upload-time = "2025-10-13T23:10:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/a3/11/a4bb9a900f456a6905d481bd2289f7a2371dcde024de56779621fd6a92c3/arro3_core-0.6.5-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:ca69f698a065cdbf845d59d412bc204e8f8af12f93737d82e6a18f3cff812349", size = 3149963, upload-time = "2025-10-13T23:10:57.163Z" }, + { url = "https://files.pythonhosted.org/packages/28/8a/79c76ad88b16f2fac25684f7313593738f353355eb1af2307e43efd7b1ca/arro3_core-0.6.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:de74a2512e2e2366d4b064c498c38672bf6ddea38acec8b1999b4e66182dd001", size = 3104663, upload-time = "2025-10-13T23:11:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/20/66/9152feaa87f851a37c1a2bd74fb89d7e82e4c76447ee590bf8e6fff5e9d8/arro3_core-0.6.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:806ca8e20507675b2de68b3d009f76e898cc3c3e441c834ea5220866f68aac50", size = 2956440, upload-time = "2025-10-13T23:11:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/f4179ef64d5c18fe76ec93cfbff42c0f401438ef771c6766b880044d7e13/arro3_core-0.6.5-cp313-cp313t-win_amd64.whl", hash = "sha256:8f6f0cc78877ade7ad6e678a4671b191406547e7b407bc9637436869c017ed47", size = 2845345, upload-time = "2025-10-13T23:11:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/07/c2/407d6bc19813fb74cc2b087ad3e959e102b29ff81e35dcc0ad0dfb5b946c/arro3_core-0.6.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:dfac7fac3c6a302399d94644d48682a19488a5b67bd1ccbdf6c560a7ffabde6d", size = 2680237, upload-time = "2025-10-13T23:11:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/d3/73/c67156794d7e9734f4cc03d2eca7e44a1cc014686e6b7663f5110f58581d/arro3_core-0.6.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9fc70042e558d1cd5fbe917b58e8ef52701441e38ff30b1912858050f796a62c", size = 2386228, upload-time = "2025-10-13T23:11:14.02Z" }, + { url = "https://files.pythonhosted.org/packages/79/e8/817ee1abb0cfa7e266ef00749b144553d2bb9c4679ca932ecbca9dc7dea9/arro3_core-0.6.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1583b29b2ba83927a33e5435e5d9d134114c45a6360a8bb4db4beda13dab4fd8", size = 2886476, upload-time = "2025-10-13T23:11:17.579Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d6/1b9beceab797c4510abfc25ef6e657e4c940d06a9615927ce506463691dd/arro3_core-0.6.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6a170fe53f18dda4a4647fd3b8b4a9373fc11ac42c41a4b65f55d79ad531a33e", size = 2911941, upload-time = "2025-10-13T23:11:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ed/4fe1fb9a24698fe6189111836d22c9582cbc92fa159b24b8664e924738dc/arro3_core-0.6.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83047b4e6e18835c91c8d12c5494e6ababc7c185c5a772d3429e8f9b0c185894", size = 3150419, upload-time = "2025-10-13T23:11:24.503Z" }, + { url = "https://files.pythonhosted.org/packages/a1/91/d6215b782fa91493f504ae13623db889beeaf0519037c28fc6744464439a/arro3_core-0.6.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3d4393d281d1ef18927915a11187da27287d279f99d5325bc9afb417f76084f", size = 2777891, upload-time = "2025-10-13T23:11:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/d4/de/0aa3504e6cbf406086de49b59cb0dcb3ab11f64acbb38602143e479831dc/arro3_core-0.6.5-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:f0c88d8babcf51affdd69390882e2f0ecb1890a1b8a5abfc087d003e7181eb6e", size = 2519673, upload-time = "2025-10-13T23:11:31.426Z" }, + { url = "https://files.pythonhosted.org/packages/05/69/47bf9c9ab66bafc7056a41f6db9d2149639eea6417299e3fe6c01ef99b6c/arro3_core-0.6.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:36424e1d62212466a5cacdc27d414e99bf0fdab1544cc2b7e5b81e41437e5970", size = 3026254, upload-time = "2025-10-13T23:11:36.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/638582437ab41ba52d3c7f2a1b0a98e4a05a51e3f660985e594b4f6c18d5/arro3_core-0.6.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4eb4d96f7db618f100758a8b7ec1b221c8737d543073701b7ffee74bc5019d46", size = 2704582, upload-time = "2025-10-13T23:11:39.408Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0a/7bc46ee799459cce72a2e15b0eb184170f26cac37eace0b813e855fbc4d8/arro3_core-0.6.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:2cfe9b4b1dd663d256754f1aa7aae783a1cddd3eb5698892b9caf381431f0af7", size = 3155815, upload-time = "2025-10-13T23:11:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/99/8a/f20eff8f4ff5bd7db9b37b70ea058b37375a930a10e03d584a7597b6b740/arro3_core-0.6.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a3b2621505f97eb5ce80f1c6fa8c77d18d757ab48d1f11d33a805e9ccbcd6fb6", size = 3107791, upload-time = "2025-10-13T23:11:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/79/da/60c66f0cc4a6af7f54e57973190540f77b84da1218fad2a9917e17bd897b/arro3_core-0.6.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6c1becbb96ceba0b20f3d4318dd35f3417ee9a49065813d99f52b0fa285fc569", size = 2957730, upload-time = "2025-10-13T23:11:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/6e3235894196e1fd2be34e01ac2d4280dd24e6c9019e3b12603858651e91/arro3_core-0.6.5-cp39-cp39-win_amd64.whl", hash = "sha256:5459e7bd39bb9dd8c57aa06856d2bebc5c1ca782cbccab0e186c6c89530e4ca9", size = 2839298, upload-time = "2025-10-13T23:11:53.566Z" }, + { url = "https://files.pythonhosted.org/packages/10/ca/b2139dbb25f9fefb9b1cdce8a73785615de6763af6a16bf6ff96a3b630f2/arro3_core-0.6.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:26d5b50139f1a96727fa1760b4d70393acf5ee0fba45346ad2d4f69824d3bdc2", size = 2676788, upload-time = "2025-10-13T23:11:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c68dde2944f493c8ccfcb91bf6da6d27a27c3674316dd09c9560f9e6ab1a/arro3_core-0.6.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b65b3d8d7f65f2f3c36002dc467380d7a31ea771132986dddc6341c5a9dc726f", size = 2382809, upload-time = "2025-10-13T23:12:00.175Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fc/2fb81d42a3cecd632deace97dc23ac74083d60d158106440c783bae4ff01/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c3442a79a757ed3fbd7793de180019ae3201f04237537c2e2e3f1e3dd99b31c", size = 2882818, upload-time = "2025-10-13T23:12:03.721Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/16f741e1d49ba5c5a893ce6f8eb0283d64bc68d6cc9e07ac62f96eaadfae/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:def7b0065a684d6f903a658d2567da47e2fcecde716e0b34eff4d899c6468c8d", size = 2907503, upload-time = "2025-10-13T23:12:07.066Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/2eb7972e0bbec0ee0ab22b0f166ec1ea74b53bd76c93a18ced434713e495/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbfe2f2d4d0d393833cd6a4bd9c15266a02307a3028f159155a1c536469c3ae7", size = 3143706, upload-time = "2025-10-13T23:12:10.492Z" }, + { url = "https://files.pythonhosted.org/packages/2d/af/b78e28842faa675e4e6c4d82e861accf21ac08bbab80a65fa80c578f80a1/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a191a3e4f72c34f7ace7724a94f2d90b06c804a6cbece4ae0f18d36325479cf3", size = 2775462, upload-time = "2025-10-13T23:12:14.026Z" }, + { url = "https://files.pythonhosted.org/packages/45/df/950e57e4915e0457acadaaca13c4423d5e2652e403135eb7606d5e6e5443/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:e3f6ab4c6ea96c451eff72aa6c5b9835a0ea8a9847cfe3995c88cce0c7701fb5", size = 2516212, upload-time = "2025-10-13T23:12:17.548Z" }, + { url = "https://files.pythonhosted.org/packages/07/73/821640d0827a829ed2565c2d4812080ab7fb86f0d271b462f9b37e6d946e/arro3_core-0.6.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:27df5239835330299636a02977f2cb34d5c460cc03b2ae1d6ab6a03d28051b08", size = 3023342, upload-time = "2025-10-13T23:12:21.308Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/51302d2f4d1b627dd11e2be979f2c48550b782d8d58d0378316342e284a8/arro3_core-0.6.5-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:71dce89c0e91be4cfb42591f03809235bbc374c396e08acdf93c4d85b09e40f5", size = 2700740, upload-time = "2025-10-13T23:12:24.968Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e8/0c8a345a013bb64abea60b4864bacc01e43b8699b8874794baec9c8a7e76/arro3_core-0.6.5-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:d380c28f85568ed99c1686fb9d64b5a811d76d569f367cbec8ef7e58f6e2fdf9", size = 3152749, upload-time = "2025-10-13T23:12:28.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/003b30c4da394366d5967a5b993f7471a74182c983d8f757891b3dd5d594/arro3_core-0.6.5-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:8e359c0c4fe9992f5a863a4a31502ea58eb2f92988fc2e501850540b3eff0328", size = 3104676, upload-time = "2025-10-13T23:12:31.711Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fd/4f8dac58ea17e05978bf35cb9a3e485b1ff3cdd6e2cc29deb08f54080de4/arro3_core-0.6.5-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a58acbc61480b533aa84d735db04b1e68fc7f6807ab694d606c03b5e694d83d", size = 2954405, upload-time = "2025-10-13T23:12:35.328Z" }, ] [[package]] @@ -204,27 +236,27 @@ dependencies = [ { name = "six" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/af/4182184d3c338792894f34a62672919db7ca008c89abee9b564dd34d8029/astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872", size = 18290 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/af/4182184d3c338792894f34a62672919db7ca008c89abee9b564dd34d8029/astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872", size = 18290, upload-time = "2019-12-22T18:12:13.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/03/13dde6512ad7b4557eb792fbcf0c653af6076b81e5941d36ec61f7ce6028/astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8", size = 12732 }, + { url = "https://files.pythonhosted.org/packages/2b/03/13dde6512ad7b4557eb792fbcf0c653af6076b81e5941d36ec61f7ce6028/astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8", size = 12732, upload-time = "2019-12-22T18:12:11.297Z" }, ] [[package]] name = "async-timeout" version = "5.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] [[package]] name = "attrs" version = "25.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] [[package]] @@ -236,9 +268,9 @@ dependencies = [ { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/43/0ef93cd27a8e753e66d93d7b94f686315384ab6cd63f065a14a4a6c9ee20/boto3-1.40.43.tar.gz", hash = "sha256:9ad9190672ce8736898bec2d94875aea6ae1ead2ac6d158e01d820f3ff9c23e0", size = 111552 } +sdist = { url = "https://files.pythonhosted.org/packages/1e/43/0ef93cd27a8e753e66d93d7b94f686315384ab6cd63f065a14a4a6c9ee20/boto3-1.40.43.tar.gz", hash = "sha256:9ad9190672ce8736898bec2d94875aea6ae1ead2ac6d158e01d820f3ff9c23e0", size = 111552, upload-time = "2025-10-01T19:38:26.089Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/86/377e2b9aeddfdb7468223c7b48e29a1697b86c200c44916ddfb8dae05a68/boto3-1.40.43-py3-none-any.whl", hash = "sha256:c5d64ba2fb2d90c33c3969f3751869c45746d5efb5136e4cc619e3630ece89a3", size = 139344 }, + { url = "https://files.pythonhosted.org/packages/f5/86/377e2b9aeddfdb7468223c7b48e29a1697b86c200c44916ddfb8dae05a68/boto3-1.40.43-py3-none-any.whl", hash = "sha256:c5d64ba2fb2d90c33c3969f3751869c45746d5efb5136e4cc619e3630ece89a3", size = 139344, upload-time = "2025-10-01T19:38:25Z" }, ] [[package]] @@ -248,93 +280,105 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, - { name = "urllib3" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/d0/3888673417202262ddd7e6361cab8e01ee2705e39643af8445e2eb276eab/botocore-1.40.43.tar.gz", hash = "sha256:d87412dc1ea785df156f412627d3417c9f9eb45601fd0846d8fe96fe3c78b630", size = 14389164 } +sdist = { url = "https://files.pythonhosted.org/packages/49/d0/3888673417202262ddd7e6361cab8e01ee2705e39643af8445e2eb276eab/botocore-1.40.43.tar.gz", hash = "sha256:d87412dc1ea785df156f412627d3417c9f9eb45601fd0846d8fe96fe3c78b630", size = 14389164, upload-time = "2025-10-01T19:38:16.06Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/46/2eb4802e15e38befbea6cab7dafa1ab796722ab6f0833991c2a05e9f8ef0/botocore-1.40.43-py3-none-any.whl", hash = "sha256:1639f38999fc0cf42c92c5c83c5fbe189a4857a86f55b842be868e3283c6d3bb", size = 14057986 }, + { url = "https://files.pythonhosted.org/packages/79/46/2eb4802e15e38befbea6cab7dafa1ab796722ab6f0833991c2a05e9f8ef0/botocore-1.40.43-py3-none-any.whl", hash = "sha256:1639f38999fc0cf42c92c5c83c5fbe189a4857a86f55b842be868e3283c6d3bb", size = 14057986, upload-time = "2025-10-01T19:38:13.714Z" }, ] [[package]] name = "certifi" version = "2025.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216 }, + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695 }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153 }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428 }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627 }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388 }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077 }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631 }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210 }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739 }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825 }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452 }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483 }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520 }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876 }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083 }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295 }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379 }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018 }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430 }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600 }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616 }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108 }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 }, +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520, upload-time = "2025-08-09T07:57:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307, upload-time = "2025-08-09T07:57:12.4Z" }, + { url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448, upload-time = "2025-08-09T07:57:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758, upload-time = "2025-08-09T07:57:14.979Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487, upload-time = "2025-08-09T07:57:16.332Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054, upload-time = "2025-08-09T07:57:17.576Z" }, + { url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703, upload-time = "2025-08-09T07:57:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096, upload-time = "2025-08-09T07:57:21.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852, upload-time = "2025-08-09T07:57:22.608Z" }, + { url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840, upload-time = "2025-08-09T07:57:23.883Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438, upload-time = "2025-08-09T07:57:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -342,17 +386,17 @@ name = "datafusion" version = "52.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/04/4dabd255e04801b942221bf7eeea661f540d8c116e6b4a783fe2479410f0/datafusion-52.0.0.tar.gz", hash = "sha256:842cf9cdb523d04a053c5408da24645e3b2adce5d6c42ddc80a8c5edf9013ff3", size = 204988 } +sdist = { url = "https://files.pythonhosted.org/packages/58/04/4dabd255e04801b942221bf7eeea661f540d8c116e6b4a783fe2479410f0/datafusion-52.0.0.tar.gz", hash = "sha256:842cf9cdb523d04a053c5408da24645e3b2adce5d6c42ddc80a8c5edf9013ff3", size = 204988, upload-time = "2026-02-23T12:22:50.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/38/66b2f2fd77d3fb66ff48a8922130379dece3ba6d2e29fc86fbb4298a874b/datafusion-52.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:999881df12ab78b6c8f04dd2056b24389374e93775a649ed20c5e35db2f42f65", size = 31473623 }, - { url = "https://files.pythonhosted.org/packages/d0/b5/ce6c6030fa8e4fc38d10d5c4aa9cc6fe1cda625e409a18eb08ea09a87c8d/datafusion-52.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd58e64158152f5c4a5836a3ce3bcca2a109d600c9ce7efdcf82e61c1ab0fbc8", size = 28108736 }, - { url = "https://files.pythonhosted.org/packages/d8/c1/d7ac9ddc9f54a8f178900f529a723d6121361111f0d0d2527bb47f86f6ce/datafusion-52.0.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ab3591904f32ce290ff7161fb804e1c7bf323de16e3ddc8cf1f76310e994208e", size = 30699663 }, - { url = "https://files.pythonhosted.org/packages/b0/2f/14cffc5305abe05d56f3e99e8054c96bd94411185de059a98fc1ca0e5ec0/datafusion-52.0.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ac4b364937c277bbfcac032dbc49d08c078b13ba3f8bfda117da5fda4ea328bc", size = 33050161 }, - { url = "https://files.pythonhosted.org/packages/24/ae/3fdea50fa88f304db96728a67deb6e07bb0d9a02f665ca09db4237a9a199/datafusion-52.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:67e252ef20b918537c8fdb47e6c825c0bd639795e19715a85fedde331a83d2e1", size = 33717685 }, + { url = "https://files.pythonhosted.org/packages/77/38/66b2f2fd77d3fb66ff48a8922130379dece3ba6d2e29fc86fbb4298a874b/datafusion-52.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:999881df12ab78b6c8f04dd2056b24389374e93775a649ed20c5e35db2f42f65", size = 31473623, upload-time = "2026-02-23T12:22:30.437Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b5/ce6c6030fa8e4fc38d10d5c4aa9cc6fe1cda625e409a18eb08ea09a87c8d/datafusion-52.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd58e64158152f5c4a5836a3ce3bcca2a109d600c9ce7efdcf82e61c1ab0fbc8", size = 28108736, upload-time = "2026-02-23T12:22:33.5Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c1/d7ac9ddc9f54a8f178900f529a723d6121361111f0d0d2527bb47f86f6ce/datafusion-52.0.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ab3591904f32ce290ff7161fb804e1c7bf323de16e3ddc8cf1f76310e994208e", size = 30699663, upload-time = "2026-02-23T12:22:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2f/14cffc5305abe05d56f3e99e8054c96bd94411185de059a98fc1ca0e5ec0/datafusion-52.0.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ac4b364937c277bbfcac032dbc49d08c078b13ba3f8bfda117da5fda4ea328bc", size = 33050161, upload-time = "2026-02-23T12:22:40.858Z" }, + { url = "https://files.pythonhosted.org/packages/24/ae/3fdea50fa88f304db96728a67deb6e07bb0d9a02f665ca09db4237a9a199/datafusion-52.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:67e252ef20b918537c8fdb47e6c825c0bd639795e19715a85fedde331a83d2e1", size = 33717685, upload-time = "2026-02-23T12:22:44.463Z" }, ] [[package]] @@ -365,61 +409,68 @@ dependencies = [ { name = "fsspec", extra = ["http"] }, { name = "huggingface-hub" }, { name = "multiprocess" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, { name = "pandas" }, - { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyyaml" }, { name = "requests" }, { name = "tqdm" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/a4/73f8e6ef52c535e1d20d5b2ca83bfe6de399d8b8b8a61ccc8d63d60735aa/datasets-4.1.1.tar.gz", hash = "sha256:7d8d5ba8b12861d2c44bfff9c83484ebfafff1ff553371e5901a8d3aab5450e2", size = 579324 } +sdist = { url = "https://files.pythonhosted.org/packages/91/a4/73f8e6ef52c535e1d20d5b2ca83bfe6de399d8b8b8a61ccc8d63d60735aa/datasets-4.1.1.tar.gz", hash = "sha256:7d8d5ba8b12861d2c44bfff9c83484ebfafff1ff553371e5901a8d3aab5450e2", size = 579324, upload-time = "2025-09-18T13:14:27.108Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/c8/09012ac195a0aab58755800d2efdc0e7d5905053509f12cb5d136c911cda/datasets-4.1.1-py3-none-any.whl", hash = "sha256:62e4f6899a36be9ec74a7e759a6951253cc85b3fcfa0a759b0efa8353b149dac", size = 503623 }, + { url = "https://files.pythonhosted.org/packages/f4/c8/09012ac195a0aab58755800d2efdc0e7d5905053509f12cb5d136c911cda/datasets-4.1.1-py3-none-any.whl", hash = "sha256:62e4f6899a36be9ec74a7e759a6951253cc85b3fcfa0a759b0efa8353b149dac", size = 503623, upload-time = "2025-09-18T13:14:25.111Z" }, ] [[package]] name = "dill" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976 } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668 }, + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, ] [[package]] name = "duckdb" version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/93/adc0d183642fc9a602ca9b97cb16754c84b8c1d92e5b99aec412e0c419a8/duckdb-1.4.0.tar.gz", hash = "sha256:bd5edee8bd5a73b5822f2b390668597b5fcdc2d3292c244d8d933bb87ad6ac4c", size = 18453175 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/4a/b2e17dbe2953481b084f355f162ed319a67ef760e28794c6870058583aec/duckdb-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e24e981a6c87e299201694b9bb24fff0beb04ccad399fca6f13072a59814488f", size = 31293005 }, - { url = "https://files.pythonhosted.org/packages/a9/89/e34ed03cce7e35b83c1f056126aa4e8e8097eb93e7324463020f85d5cbfa/duckdb-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db500ef2c8cb7dc1ca078740ecf1dceaa20d3f5dc5bce269be45d5cff4170c0f", size = 17288207 }, - { url = "https://files.pythonhosted.org/packages/f8/17/7ff24799ee98c4dbb177c3ec6c93e38e9513828785c31757c727b47ad71e/duckdb-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a65739b8a7106634e6e77d0e110fc5e057b88edc9df6cb1683d499a1e5aa3177", size = 14817523 }, - { url = "https://files.pythonhosted.org/packages/fc/ab/7a482a76ff75212b5cf4f2172a802f2a59b4ab096416e5821aa62a305bc4/duckdb-1.4.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d59f7be24862adb803a1ddfc9c3b8cb09e6005bca0c9c6f7c631a1da1c3aa0c", size = 18410654 }, - { url = "https://files.pythonhosted.org/packages/1e/f6/a235233b973652b31448b6d600604620d02fc552b90ab94ca7f645fd5ac0/duckdb-1.4.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d052a87e9edf4eb3bab0b7a6ac995676018c6083b8049421628dfa3b983a2d4", size = 20399121 }, - { url = "https://files.pythonhosted.org/packages/b1/cf/63fedb74d00d7c4e19ffc73a1d8d98ee8d3d6498cf2865509c104aa8e799/duckdb-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:0329b81e587f745b2fc6f3a488ea3188b0f029c3b5feef43792a25eaac84ac01", size = 12283288 }, - { url = "https://files.pythonhosted.org/packages/60/e9/b29cc5bceac52e049b20d613551a2171a092df07f26d4315f3f9651c80d4/duckdb-1.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6505fed1ccae8df9f574e744c48fa32ee2feaeebe5346c2daf4d4d10a8dac5aa", size = 31290878 }, - { url = "https://files.pythonhosted.org/packages/1f/68/d88a15dba48bf6a4b33f1be5097ef45c83f7b9e97c854cc638a85bb07d70/duckdb-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:36974a04b29c74ac2143457e95420a7422016d050e28573060b89a90b9cf2b57", size = 17288823 }, - { url = "https://files.pythonhosted.org/packages/8c/7e/e3d2101dc6bbd60f2b3c1d748351ff541fc8c48790ac1218c0199cb930f6/duckdb-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90484b896e5059f145d1facfabea38e22c54a2dcc2bd62dd6c290423f0aee258", size = 14819684 }, - { url = "https://files.pythonhosted.org/packages/c4/bb/4ec8e4d03cb5b77d75b9ee0057c2c714cffaa9bda1e55ffec833458af0a3/duckdb-1.4.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a969d624b385853b31a43b0a23089683297da2f14846243921c6dbec8382d659", size = 18410075 }, - { url = "https://files.pythonhosted.org/packages/ec/21/e896616d892d50dc1e0c142428e9359b483d4dd6e339231d822e57834ad3/duckdb-1.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5935644f96a75e9f6f3c3eeb3da14cdcaf7bad14d1199c08439103decb29466a", size = 20402984 }, - { url = "https://files.pythonhosted.org/packages/c4/c0/b5eb9497e4a9167d23fbad745969eaa36e28d346648e17565471892d1b33/duckdb-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:300aa0e963af97969c38440877fffd576fc1f49c1f5914789a9d01f2fe7def91", size = 12282971 }, - { url = "https://files.pythonhosted.org/packages/e8/6d/0c774d6af1aed82dbe855d266cb000a1c09ea31ed7d6c3a79e2167a38e7a/duckdb-1.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:18b3a048fca6cc7bafe08b10e1b0ab1509d7a0381ffb2c70359e7dc56d8a705d", size = 31307425 }, - { url = "https://files.pythonhosted.org/packages/d3/c0/1fd7b7b2c0c53d8d748d2f28ea9096df5ee9dc39fa736cca68acabe69656/duckdb-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c1271cb85aeacccfd0b1284e816280a7450df1dd4dd85ccb2848563cfdf90e9", size = 17295727 }, - { url = "https://files.pythonhosted.org/packages/98/d3/4d4c4bd667b7ada5f6c207c2f127591ebb8468333f207f8f10ff0532578e/duckdb-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55064dd2e25711eeaa6a72c25405bdd7994c81a3221657e94309a2faf65d25a6", size = 14826879 }, - { url = "https://files.pythonhosted.org/packages/b0/48/e0c1b97d76fb7567c53db5739931323238fad54a642707008104f501db37/duckdb-1.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0536d7c81bc506532daccf373ddbc8c6add46aeb70ef3cd5ee70ad5c2b3165ea", size = 18417856 }, - { url = "https://files.pythonhosted.org/packages/12/78/297b838f3b9511589badc8f472f70b31cf3bbf9eb99fa0a4d6e911d3114a/duckdb-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:784554e3ddfcfc5c5c7b1aa1f9925fedb7938f6628729adba48f7ea37554598f", size = 20427154 }, - { url = "https://files.pythonhosted.org/packages/ea/57/500d251b886494f6c52d56eeab8a1860572ee62aed05d7d50c71ba2320f3/duckdb-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:c5d2aa4d6981f525ada95e6db41bb929403632bb5ff24bd6d6dd551662b1b613", size = 12290108 }, - { url = "https://files.pythonhosted.org/packages/2f/64/ee22b2b8572746e1523143b9f28d606575782e0204de5020656a1d15dd14/duckdb-1.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d94d010a09b1a62d9021a2a71cf266188750f3c9b1912ccd6afe104a6ce8010", size = 31307662 }, - { url = "https://files.pythonhosted.org/packages/76/2e/4241cd00046ca6b781bd1d9002e8223af061e85d1cc21830aa63e7a7db7c/duckdb-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c61756fa8b3374627e5fa964b8e0d5b58e364dce59b87dba7fb7bc6ede196b26", size = 17295617 }, - { url = "https://files.pythonhosted.org/packages/f7/98/5ab136bc7b12ac18580350a220db7c00606be9eac2d89de259cce733f64c/duckdb-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e70d7d9881ea2c0836695de70ea68c970e18a2856ba3d6502e276c85bd414ae7", size = 14826727 }, - { url = "https://files.pythonhosted.org/packages/23/32/57866cf8881288b3dfb9212720221fb890daaa534dbdc6fe3fff3979ecd1/duckdb-1.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2de258a93435c977a0ec3a74ec8f60c2f215ddc73d427ee49adc4119558facd3", size = 18421289 }, - { url = "https://files.pythonhosted.org/packages/a0/83/7438fb43be451a7d4a04650aaaf662b2ff2d95895bbffe3e0e28cbe030c9/duckdb-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6d3659641d517dd9ed1ab66f110cdbdaa6900106f116effaf2dbedd83c38de3", size = 20426547 }, - { url = "https://files.pythonhosted.org/packages/21/b2/98fb89ae81611855f35984e96f648d871f3967bb3f524b51d1372d052f0c/duckdb-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:07fcc612ea5f0fe6032b92bcc93693034eb00e7a23eb9146576911d5326af4f7", size = 12290467 }, +sdist = { url = "https://files.pythonhosted.org/packages/82/93/adc0d183642fc9a602ca9b97cb16754c84b8c1d92e5b99aec412e0c419a8/duckdb-1.4.0.tar.gz", hash = "sha256:bd5edee8bd5a73b5822f2b390668597b5fcdc2d3292c244d8d933bb87ad6ac4c", size = 18453175, upload-time = "2025-09-16T10:22:41.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/4a/b2e17dbe2953481b084f355f162ed319a67ef760e28794c6870058583aec/duckdb-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e24e981a6c87e299201694b9bb24fff0beb04ccad399fca6f13072a59814488f", size = 31293005, upload-time = "2025-09-16T10:21:28.296Z" }, + { url = "https://files.pythonhosted.org/packages/a9/89/e34ed03cce7e35b83c1f056126aa4e8e8097eb93e7324463020f85d5cbfa/duckdb-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db500ef2c8cb7dc1ca078740ecf1dceaa20d3f5dc5bce269be45d5cff4170c0f", size = 17288207, upload-time = "2025-09-16T10:21:31.129Z" }, + { url = "https://files.pythonhosted.org/packages/f8/17/7ff24799ee98c4dbb177c3ec6c93e38e9513828785c31757c727b47ad71e/duckdb-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a65739b8a7106634e6e77d0e110fc5e057b88edc9df6cb1683d499a1e5aa3177", size = 14817523, upload-time = "2025-09-16T10:21:33.397Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ab/7a482a76ff75212b5cf4f2172a802f2a59b4ab096416e5821aa62a305bc4/duckdb-1.4.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d59f7be24862adb803a1ddfc9c3b8cb09e6005bca0c9c6f7c631a1da1c3aa0c", size = 18410654, upload-time = "2025-09-16T10:21:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f6/a235233b973652b31448b6d600604620d02fc552b90ab94ca7f645fd5ac0/duckdb-1.4.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d052a87e9edf4eb3bab0b7a6ac995676018c6083b8049421628dfa3b983a2d4", size = 20399121, upload-time = "2025-09-16T10:21:38.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cf/63fedb74d00d7c4e19ffc73a1d8d98ee8d3d6498cf2865509c104aa8e799/duckdb-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:0329b81e587f745b2fc6f3a488ea3188b0f029c3b5feef43792a25eaac84ac01", size = 12283288, upload-time = "2025-09-16T10:21:40.732Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b29cc5bceac52e049b20d613551a2171a092df07f26d4315f3f9651c80d4/duckdb-1.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6505fed1ccae8df9f574e744c48fa32ee2feaeebe5346c2daf4d4d10a8dac5aa", size = 31290878, upload-time = "2025-09-16T10:21:43.256Z" }, + { url = "https://files.pythonhosted.org/packages/1f/68/d88a15dba48bf6a4b33f1be5097ef45c83f7b9e97c854cc638a85bb07d70/duckdb-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:36974a04b29c74ac2143457e95420a7422016d050e28573060b89a90b9cf2b57", size = 17288823, upload-time = "2025-09-16T10:21:45.716Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7e/e3d2101dc6bbd60f2b3c1d748351ff541fc8c48790ac1218c0199cb930f6/duckdb-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90484b896e5059f145d1facfabea38e22c54a2dcc2bd62dd6c290423f0aee258", size = 14819684, upload-time = "2025-09-16T10:21:48.117Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/4ec8e4d03cb5b77d75b9ee0057c2c714cffaa9bda1e55ffec833458af0a3/duckdb-1.4.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a969d624b385853b31a43b0a23089683297da2f14846243921c6dbec8382d659", size = 18410075, upload-time = "2025-09-16T10:21:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/e896616d892d50dc1e0c142428e9359b483d4dd6e339231d822e57834ad3/duckdb-1.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5935644f96a75e9f6f3c3eeb3da14cdcaf7bad14d1199c08439103decb29466a", size = 20402984, upload-time = "2025-09-16T10:21:52.808Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c0/b5eb9497e4a9167d23fbad745969eaa36e28d346648e17565471892d1b33/duckdb-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:300aa0e963af97969c38440877fffd576fc1f49c1f5914789a9d01f2fe7def91", size = 12282971, upload-time = "2025-09-16T10:21:55.314Z" }, + { url = "https://files.pythonhosted.org/packages/e8/6d/0c774d6af1aed82dbe855d266cb000a1c09ea31ed7d6c3a79e2167a38e7a/duckdb-1.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:18b3a048fca6cc7bafe08b10e1b0ab1509d7a0381ffb2c70359e7dc56d8a705d", size = 31307425, upload-time = "2025-09-16T10:21:57.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c0/1fd7b7b2c0c53d8d748d2f28ea9096df5ee9dc39fa736cca68acabe69656/duckdb-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c1271cb85aeacccfd0b1284e816280a7450df1dd4dd85ccb2848563cfdf90e9", size = 17295727, upload-time = "2025-09-16T10:22:02.242Z" }, + { url = "https://files.pythonhosted.org/packages/98/d3/4d4c4bd667b7ada5f6c207c2f127591ebb8468333f207f8f10ff0532578e/duckdb-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55064dd2e25711eeaa6a72c25405bdd7994c81a3221657e94309a2faf65d25a6", size = 14826879, upload-time = "2025-09-16T10:22:05.162Z" }, + { url = "https://files.pythonhosted.org/packages/b0/48/e0c1b97d76fb7567c53db5739931323238fad54a642707008104f501db37/duckdb-1.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0536d7c81bc506532daccf373ddbc8c6add46aeb70ef3cd5ee70ad5c2b3165ea", size = 18417856, upload-time = "2025-09-16T10:22:07.919Z" }, + { url = "https://files.pythonhosted.org/packages/12/78/297b838f3b9511589badc8f472f70b31cf3bbf9eb99fa0a4d6e911d3114a/duckdb-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:784554e3ddfcfc5c5c7b1aa1f9925fedb7938f6628729adba48f7ea37554598f", size = 20427154, upload-time = "2025-09-16T10:22:10.216Z" }, + { url = "https://files.pythonhosted.org/packages/ea/57/500d251b886494f6c52d56eeab8a1860572ee62aed05d7d50c71ba2320f3/duckdb-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:c5d2aa4d6981f525ada95e6db41bb929403632bb5ff24bd6d6dd551662b1b613", size = 12290108, upload-time = "2025-09-16T10:22:12.668Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/ee22b2b8572746e1523143b9f28d606575782e0204de5020656a1d15dd14/duckdb-1.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d94d010a09b1a62d9021a2a71cf266188750f3c9b1912ccd6afe104a6ce8010", size = 31307662, upload-time = "2025-09-16T10:22:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/76/2e/4241cd00046ca6b781bd1d9002e8223af061e85d1cc21830aa63e7a7db7c/duckdb-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c61756fa8b3374627e5fa964b8e0d5b58e364dce59b87dba7fb7bc6ede196b26", size = 17295617, upload-time = "2025-09-16T10:22:17.239Z" }, + { url = "https://files.pythonhosted.org/packages/f7/98/5ab136bc7b12ac18580350a220db7c00606be9eac2d89de259cce733f64c/duckdb-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e70d7d9881ea2c0836695de70ea68c970e18a2856ba3d6502e276c85bd414ae7", size = 14826727, upload-time = "2025-09-16T10:22:19.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/32/57866cf8881288b3dfb9212720221fb890daaa534dbdc6fe3fff3979ecd1/duckdb-1.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2de258a93435c977a0ec3a74ec8f60c2f215ddc73d427ee49adc4119558facd3", size = 18421289, upload-time = "2025-09-16T10:22:21.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/7438fb43be451a7d4a04650aaaf662b2ff2d95895bbffe3e0e28cbe030c9/duckdb-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6d3659641d517dd9ed1ab66f110cdbdaa6900106f116effaf2dbedd83c38de3", size = 20426547, upload-time = "2025-09-16T10:22:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/21/b2/98fb89ae81611855f35984e96f648d871f3967bb3f524b51d1372d052f0c/duckdb-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:07fcc612ea5f0fe6032b92bcc93693034eb00e7a23eb9146576911d5326af4f7", size = 12290467, upload-time = "2025-09-16T10:22:25.923Z" }, + { url = "https://files.pythonhosted.org/packages/8d/42/0f355319b3e8ee1703d0e17378dd829db391434306621f85c110134f2763/duckdb-1.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1c97ee61c582002b654331f7fd967d6b1e83bf7fdb0772f409dfd4b6af3a70f4", size = 31292373, upload-time = "2025-09-16T10:22:28.118Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/091dbef5eb2ac4e60a9c6d38fcc7c7530a75fafa0f37658450e8731a265b/duckdb-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:74e3d6295355160df5d3588b880e8bcae23fdd6f573f538793a8a1abf4c2c29d", size = 17288145, upload-time = "2025-09-16T10:22:30.346Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6c/879317d9c3ac7a2a1f0618ca536a48ebfa4b9fe202f9783e07070e168192/duckdb-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d0c76425e4ffe98069dd4fc4752ab919a4125dc0d176bb676b3065fdea152c42", size = 14816258, upload-time = "2025-09-16T10:22:32.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/87/83ac8e67c0530b69fe39f91bbb7f3bd0a49b0c24216cffa9c5561fb2845c/duckdb-1.4.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c122bd7d80ab5057f53024ee3922d7612a5cdc99583fae730990964aebc3fd4", size = 18391043, upload-time = "2025-09-16T10:22:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/d6/01/1d70bd6c594ef915c004edc0f1119d1602173dc5ce91c1eed7368f6aab34/duckdb-1.4.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30689c1436bca723526be6102fe1f4f82ea6d4780fb9ca196bda7ed5ec227950", size = 20385348, upload-time = "2025-09-16T10:22:36.982Z" }, + { url = "https://files.pythonhosted.org/packages/b6/04/0650128cdcdc5208c4f51341a0a3f8db436ecaba51032c6065e20ea0baae/duckdb-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:4c55a367c1296617cff89c5e1c7153f1dc3c3b556ef70711a45b0236515f80c2", size = 12283322, upload-time = "2025-09-16T10:22:39.388Z" }, ] [[package]] @@ -429,130 +480,147 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] [[package]] name = "filelock" version = "3.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687 } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988 }, + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] [[package]] name = "flatbuffers" version = "25.9.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/1f/3ee70b0a55137442038f2a33469cc5fddd7e0ad2abf83d7497c18a2b6923/flatbuffers-25.9.23.tar.gz", hash = "sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12", size = 22067 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1f/3ee70b0a55137442038f2a33469cc5fddd7e0ad2abf83d7497c18a2b6923/flatbuffers-25.9.23.tar.gz", hash = "sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12", size = 22067, upload-time = "2025-09-24T05:25:30.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/1b/00a78aa2e8fbd63f9af08c9c19e6deb3d5d66b4dda677a0f61654680ee89/flatbuffers-25.9.23-py2.py3-none-any.whl", hash = "sha256:255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2", size = 30869 }, + { url = "https://files.pythonhosted.org/packages/ee/1b/00a78aa2e8fbd63f9af08c9c19e6deb3d5d66b4dda677a0f61654680ee89/flatbuffers-25.9.23-py2.py3-none-any.whl", hash = "sha256:255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2", size = 30869, upload-time = "2025-09-24T05:25:28.912Z" }, ] [[package]] name = "frozenlist" version = "1.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304 }, - { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735 }, - { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775 }, - { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644 }, - { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125 }, - { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455 }, - { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339 }, - { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969 }, - { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862 }, - { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492 }, - { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250 }, - { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720 }, - { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585 }, - { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248 }, - { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621 }, - { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578 }, - { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830 }, - { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251 }, - { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183 }, - { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107 }, - { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333 }, - { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724 }, - { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842 }, - { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767 }, - { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130 }, - { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301 }, - { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606 }, - { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372 }, - { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860 }, - { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893 }, - { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323 }, - { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149 }, - { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565 }, - { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019 }, - { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424 }, - { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952 }, - { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688 }, - { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084 }, - { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524 }, - { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493 }, - { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116 }, - { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557 }, - { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820 }, - { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542 }, - { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350 }, - { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093 }, - { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482 }, - { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590 }, - { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785 }, - { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487 }, - { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874 }, - { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791 }, - { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165 }, - { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881 }, - { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409 }, - { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132 }, - { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638 }, - { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539 }, - { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646 }, - { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233 }, - { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996 }, - { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280 }, - { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717 }, - { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644 }, - { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879 }, - { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502 }, - { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169 }, - { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219 }, - { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345 }, - { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880 }, - { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498 }, - { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296 }, - { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103 }, - { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869 }, - { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467 }, - { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028 }, - { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294 }, - { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898 }, - { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465 }, - { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385 }, - { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771 }, - { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206 }, - { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620 }, - { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059 }, - { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516 }, - { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106 }, +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, + { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, + { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, + { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, + { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, + { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, + { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, + { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, + { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, + { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, + { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, + { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, + { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, + { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, + { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, + { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/ee59496f51cd244039330015d60f13ce5a54a0f2bd8d79e4a4a375ab7469/frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630", size = 82434, upload-time = "2025-06-09T23:02:05.195Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/d518391ce36a6279b3fa5bc14327dde80bcb646bb50d059c6ca0756b8d05/frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71", size = 48232, upload-time = "2025-06-09T23:02:07.728Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8d/a0d04f28b6e821a9685c22e67b5fb798a5a7b68752f104bfbc2dccf080c4/frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44", size = 47186, upload-time = "2025-06-09T23:02:09.243Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/a5334c0535c8b7c78eeabda1579179e44fe3d644e07118e59a2276dedaf1/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878", size = 226617, upload-time = "2025-06-09T23:02:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/8258d971f519dc3f278c55069a775096cda6610a267b53f6248152b72b2f/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb", size = 224179, upload-time = "2025-06-09T23:02:12.603Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/8225905bf889b97c6d935dd3aeb45668461e59d415cb019619383a8a7c3b/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6", size = 235783, upload-time = "2025-06-09T23:02:14.678Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/ef52375aa93d4bc510d061df06205fa6dcfd94cd631dd22956b09128f0d4/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35", size = 229210, upload-time = "2025-06-09T23:02:16.313Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/62c87d1a6547bfbcd645df10432c129100c5bd0fd92a384de6e3378b07c1/frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87", size = 215994, upload-time = "2025-06-09T23:02:17.9Z" }, + { url = "https://files.pythonhosted.org/packages/45/d2/263fea1f658b8ad648c7d94d18a87bca7e8c67bd6a1bbf5445b1bd5b158c/frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677", size = 225122, upload-time = "2025-06-09T23:02:19.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/7145e35d12fb368d92124f679bea87309495e2e9ddf14c6533990cb69218/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938", size = 224019, upload-time = "2025-06-09T23:02:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/44/1e/7dae8c54301beb87bcafc6144b9a103bfd2c8f38078c7902984c9a0c4e5b/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2", size = 239925, upload-time = "2025-06-09T23:02:22.466Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1e/99c93e54aa382e949a98976a73b9b20c3aae6d9d893f31bbe4991f64e3a8/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319", size = 220881, upload-time = "2025-06-09T23:02:24.521Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9c/ca5105fa7fb5abdfa8837581be790447ae051da75d32f25c8f81082ffc45/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890", size = 234046, upload-time = "2025-06-09T23:02:26.206Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4d/e99014756093b4ddbb67fb8f0df11fe7a415760d69ace98e2ac6d5d43402/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd", size = 235756, upload-time = "2025-06-09T23:02:27.79Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/a19a40bcdaa28a51add2aaa3a1a294ec357f36f27bd836a012e070c5e8a5/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb", size = 222894, upload-time = "2025-06-09T23:02:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/0042469993e023a758af81db68c76907cd29e847d772334d4d201cbe9a42/frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e", size = 39848, upload-time = "2025-06-09T23:02:31.413Z" }, + { url = "https://files.pythonhosted.org/packages/5a/45/827d86ee475c877f5f766fbc23fb6acb6fada9e52f1c9720e2ba3eae32da/frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63", size = 44102, upload-time = "2025-06-09T23:02:32.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] [[package]] name = "fsspec" version = "2025.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/e0/bab50af11c2d75c9c4a2a26a5254573c0bd97cea152254401510950486fa/fsspec-2025.9.0.tar.gz", hash = "sha256:19fd429483d25d28b65ec68f9f4adc16c17ea2c7c7bf54ec61360d478fb19c19", size = 304847 } +sdist = { url = "https://files.pythonhosted.org/packages/de/e0/bab50af11c2d75c9c4a2a26a5254573c0bd97cea152254401510950486fa/fsspec-2025.9.0.tar.gz", hash = "sha256:19fd429483d25d28b65ec68f9f4adc16c17ea2c7c7bf54ec61360d478fb19c19", size = 304847, upload-time = "2025-09-02T19:10:49.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289 }, + { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" }, ] [package.optional-dependencies] @@ -564,9 +632,9 @@ http = [ name = "gast" version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/14/c566f5ca00c115db7725263408ff952b8ae6d6a4e792ef9c84e77d9af7a1/gast-0.6.0.tar.gz", hash = "sha256:88fc5300d32c7ac6ca7b515310862f71e6fdf2c029bbec7c66c0f5dd47b6b1fb", size = 27708 } +sdist = { url = "https://files.pythonhosted.org/packages/3c/14/c566f5ca00c115db7725263408ff952b8ae6d6a4e792ef9c84e77d9af7a1/gast-0.6.0.tar.gz", hash = "sha256:88fc5300d32c7ac6ca7b515310862f71e6fdf2c029bbec7c66c0f5dd47b6b1fb", size = 27708, upload-time = "2024-06-27T20:31:49.527Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/61/8001b38461d751cd1a0c3a6ae84346796a5758123f3ed97a1b121dfbf4f3/gast-0.6.0-py3-none-any.whl", hash = "sha256:52b182313f7330389f72b069ba00f174cfe2a06411099547288839c6cbafbd54", size = 21173 }, + { url = "https://files.pythonhosted.org/packages/a3/61/8001b38461d751cd1a0c3a6ae84346796a5758123f3ed97a1b121dfbf4f3/gast-0.6.0-py3-none-any.whl", hash = "sha256:52b182313f7330389f72b069ba00f174cfe2a06411099547288839c6cbafbd54", size = 21173, upload-time = "2024-07-09T13:15:15.615Z" }, ] [[package]] @@ -575,55 +643,65 @@ version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "arro3-core" }, - { name = "pyproj", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pyproj", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyproj", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "pyproj", version = "3.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/77/2d/3e994dd76223fac0eb597a6f55647cca51bd5a4f446d09b668697f901724/geoarrow_rust_core-0.6.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:84d972cc3dd45a797fd99588d7ee68f257e4083ebdcecad9ec773260067f71a6", size = 3570129 }, - { url = "https://files.pythonhosted.org/packages/5f/2a/e19df203b4ffb225f39627e1bd1b89ce7b2220e39f1d6972692174820c57/geoarrow_rust_core-0.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bc0f382d4ed41e85d2d89fc2c7c8c3d046681c9a5e19350ce79e0e930cf69821", size = 3333881 }, - { url = "https://files.pythonhosted.org/packages/52/98/b749a2165dfc5d9c54a1c19eb3e6a75b6d005ecde42289b25c1c355346b7/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e719edcaf6698ed2b1aa9525bd97cf79e23a500a39b1e83566cd9a16a294d3", size = 3806366 }, - { url = "https://files.pythonhosted.org/packages/84/93/7c0e42ba7d46208fb0f851e06c05de071962170f3a3b2a2260d8a3f66e7a/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0f3546a15503329880063aca31266b301b0b781f618f832585bcd1c9efcc876", size = 3981800 }, - { url = "https://files.pythonhosted.org/packages/de/43/9c5736569dead60b33e46b7c485e24804d950693df70dee306e153547789/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6937f3cabebf673f8b726d60d8ca160b46401de8b08c8e257be22772c12c2001", size = 5068955 }, - { url = "https://files.pythonhosted.org/packages/71/5e/f26f9bea2af96b0d070e980dcc2196d369a678e06141ed260de5ca72bcc2/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f29ba92053e8ad4bd60d72188518f033ca4abc1f34eecebeb41ee7b790612e00", size = 4104946 }, - { url = "https://files.pythonhosted.org/packages/fa/08/473796b3e0c03b35292220de88c8efa3e74d6174e807b26a371f2523a4b0/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a5d05a312fbb76821566b1d144c64d0923fcbd790b2c7376ee11f62472b2fe", size = 3917533 }, - { url = "https://files.pythonhosted.org/packages/b9/7a/7b62b839c3a9878a7d91b8395e0b7b04483e4bec687e073df0fbd4056583/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88fe8fd33b16a06e9b3b7638b51d24047f1d01af12cc2e3e2653140877bddef6", size = 4318837 }, - { url = "https://files.pythonhosted.org/packages/ea/86/309c55a9c63f316e3a04949ade8847b8e5acbdd21645696911175f0e1814/geoarrow_rust_core-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:dbecc2487cc95526ac77797cd70c199e196811b0a9e877c1b61fcaca508575fa", size = 3320081 }, - { url = "https://files.pythonhosted.org/packages/1a/ed/514cff089185d71242a62e774e2c59dda147baab65929851b66d72198d5d/geoarrow_rust_core-0.6.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e26ca240d7a6a0fa1b4f56a9ebe07b2e14fc7c1c9507aa862bd31ef14e0521f0", size = 3572326 }, - { url = "https://files.pythonhosted.org/packages/77/21/22f8233235bd020db22b4f2bf888f9aeed08813eda7b8b421a6963bdc7e4/geoarrow_rust_core-0.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46876e3528685673e08b4cbc696dca7f22fb073a83318708b0eaf640107b923b", size = 3335166 }, - { url = "https://files.pythonhosted.org/packages/bb/eb/0c2e40a6a1bd450347a8a9fc7648ca840710bc177ff6eed3fc5da6ef981a/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5502bd12ede712d9b4725753df4db231a0aa6d3e131079bc4b6452c436e37b7", size = 3800540 }, - { url = "https://files.pythonhosted.org/packages/4c/42/22d3b8441bb7041a6fcdb4cf0a1108e150513a52f8a407715188412bc71f/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f04dd7dd03449dba6d15f7d35c6c708637ac05f125638f56206e876756cd4c5", size = 3984840 }, - { url = "https://files.pythonhosted.org/packages/12/44/477b6b2389398dc983026a4ab7dbb7ec121284ad5fb864a1b7a4658c3881/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2afce33d0c3fa87d5d4d24d6617732e4297da3372b1746569b759f9b62aede1", size = 5067358 }, - { url = "https://files.pythonhosted.org/packages/62/50/6995e9d11462635972b2fc09c8e1e510928563ca4fb0fd2c9145cf6ef771/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e63cdb661652a9836dc86cb5995ad269817d88b80f4cce6ed236a7f80f0aba", size = 4105773 }, - { url = "https://files.pythonhosted.org/packages/a3/21/b369208495f213db0a0e7d563358307a706cc6af0cb9c897dacf28ae06a1/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adbaf97cb770aef69df8a16437c9faa67adb2b04856faf45bcb61d5b986101dc", size = 3914659 }, - { url = "https://files.pythonhosted.org/packages/1d/49/fccb14c6ee9bb715451e4d5bbe3d571eb59a8a1abe21b2abe0d9d48a7fac/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:202f35b301caa5154d95fd74424a1ef6449306e4f6fbfb5140270e48e94188a5", size = 4315153 }, - { url = "https://files.pythonhosted.org/packages/c0/1c/88b16510e24a4a3332284669085673701b9fe4d6a511b4466c90655a9daf/geoarrow_rust_core-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:491405dfcc821a2c599e381cc9923e04a758deb1cc84fdb5794b519446c2f8a8", size = 3320510 }, - { url = "https://files.pythonhosted.org/packages/cb/5f/1dbdbc1dde2140937cff20188cb25034b6f39e1734c14ca6510cf464bf77/geoarrow_rust_core-0.6.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a8145a562e94419402dd0882bb62429853804c53d47dbea944f2a24abc57abd2", size = 3568115 }, - { url = "https://files.pythonhosted.org/packages/fd/e1/b62676f89ef3b866676967989ee8dbbd3d16c77f69aa4287825703268c42/geoarrow_rust_core-0.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51040a5afcfa0cd3ab372d981375c7fe8eb652d155e3964d52ed51d14faa04e8", size = 3325336 }, - { url = "https://files.pythonhosted.org/packages/1f/89/94e20f255712ff0eaccf9bfeac4bf51953ebcef0599cfc92f67037f8ab1a/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fbf8506848b0254b3c89b27c045be38bbef6372b21714cad45d76b0c8cb92ce", size = 3808535 }, - { url = "https://files.pythonhosted.org/packages/e7/e4/37c7e2c9e251148be17292d0656d7d1ab35019678f6bd11090a41c270d18/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1a0d9c14bf2f36676016c753517d9470381969c2a67859716cceae33735f3ee", size = 3978997 }, - { url = "https://files.pythonhosted.org/packages/71/27/c4ba353d9b77889136bdfd1c0cd1a04d6eade9da6e0748b06719c458afb5/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6df97301782ecbaf5f2f0252011a9ff309471cde25435bdf1e17b29c263ebc16", size = 5066492 }, - { url = "https://files.pythonhosted.org/packages/a6/81/34107fc9aacc489e41afed420202645675b41d85b46dc70d5ba222312791/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1948cfdd0e1c7d03a0c2067821dd536ab34d1e726515202e51fbd6b0d9f775f", size = 4106130 }, - { url = "https://files.pythonhosted.org/packages/92/5f/2e348b884738fb213fb3b4745955baeeaf047aecb37639e39a4dd8f12d99/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95b1611b66c386cc6c74e990df4f114bcf24956a35e18e51bf6331c079a36688", size = 3913166 }, - { url = "https://files.pythonhosted.org/packages/bf/81/fdda8bb5f84df82bc9e000435a88be46d46dda41eb5149f624ed96b7031c/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1751357a1aaa26aeb5feb6f66873b6a2d369655039f7278dedcb692b512111cc", size = 4313573 }, - { url = "https://files.pythonhosted.org/packages/a0/14/ca0bc7d3b158094e769ba2bbc43d203330e7e457ed67b50af97d3eac45df/geoarrow_rust_core-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:16fe159043a444579948864808ebec8c49ec167ec0df3cb772dfb88de268bc91", size = 3318746 }, - { url = "https://files.pythonhosted.org/packages/85/b8/94e4f8fb32ef705cf65031a24c58cdc441042a68a794b74757a6561cbc60/geoarrow_rust_core-0.6.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6c1b692f76b613757438bf23cfe3be4a8715f0268afd8ad3ca0063c257a3be4b", size = 3568328 }, - { url = "https://files.pythonhosted.org/packages/7c/45/a96e64f9febc3436766c5055508c4e823cce56577529d7b76c4e4f584bc4/geoarrow_rust_core-0.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a2b4f9a8cfe852a0ba9a667258307db9e354b470b7e0a03edffd0b7daf9b6f5", size = 3325879 }, - { url = "https://files.pythonhosted.org/packages/58/c0/c719ce3fb4e982e28c71f65a80cf697d07d733336e6b74d7d1b8a7daf9d0/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8248330f5c3e7ec5852d0a23c23b31a08395300ef9544109e2991317beddfee3", size = 3809144 }, - { url = "https://files.pythonhosted.org/packages/e2/8e/2ab3563b2ffd13f2dd69c050a901de0a4bb325879531a66f56d30bc7337e/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:775e9fe45c06d02be59b1497c60aa4f7a7c1d460387bf5f63142faf39b8ad4ff", size = 3978886 }, - { url = "https://files.pythonhosted.org/packages/db/0a/31625caa0a32e8e9e7aaf2514a840dda0dadf8e2452710ebc10e5f469494/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94de8fb01da3f22332eab28b03570c43cc36492ce482c254fe87e851ae21285b", size = 5065429 }, - { url = "https://files.pythonhosted.org/packages/11/8d/ee247bd4ccf3b0791b8669357d440e3960d4dbd5cca940a2e226e8910c31/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c70a63d1d36687a53dc6c2933446b1435c187e4c616cd84844d89b6ba13bc4f6", size = 4105436 }, - { url = "https://files.pythonhosted.org/packages/a9/fb/c1e92716ee5aa00d48b650f0cb43220a1bf4088c8d572dfc21d400b16723/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e505312f2761393fe5158242f3f2d77e9daa5cca63badd8d66e6d1d69fc17bf", size = 3913672 }, - { url = "https://files.pythonhosted.org/packages/f8/6f/ef47f6070c5d5cf0d061d5f5ba95aed7e895e4720a784b84c911c0209fc0/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a732e58549108df8267ab72fa6cc7c54e5a9e30b818d8d869e301a9de9d3029e", size = 4313496 }, - { url = "https://files.pythonhosted.org/packages/3c/ac/2696b979623ea02129e342f8820c89d03fa5a253a913ad00b588d6dd2948/geoarrow_rust_core-0.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:9e1d6492b1388b9d5ae898728838ada78dbf2340d2e9dd25ad3df6ccdd058813", size = 3318780 }, - { url = "https://files.pythonhosted.org/packages/4e/42/0cb3af24b01d3897a9eee6af5cc0676bf6b80364e0d4638e45a5fc873d35/geoarrow_rust_core-0.6.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3748cc8e8cb2bcedaede27cefed6749d4eea93e358b49a2f0b061d8974dd1b91", size = 3560313 }, - { url = "https://files.pythonhosted.org/packages/51/bc/33f8c918e46188707ab358752b993bee9184fa62e580998c1ec4c37885c1/geoarrow_rust_core-0.6.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1b0e232fe4e239ca435d0bab638934eee87d758024c1727ee24a2b8bc4d8bc7b", size = 3321855 }, - { url = "https://files.pythonhosted.org/packages/f4/d7/aeb2a3922670ad57f62cb591bd0309a8300ceeec6efc7f925a563c9da672/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:843444ada2c7f7670fd9df3bdebd93e5247b376d1dd20c4fb3828632847ab78e", size = 3799057 }, - { url = "https://files.pythonhosted.org/packages/76/08/606e55fc2a0e85b02e0fde7dec2014eb8f1463e8a823496d72a3095de73d/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:880641183a09ebfbca3a6357071f137d1a4b0f1ba606fb9127a01cf58faaef56", size = 3968892 }, - { url = "https://files.pythonhosted.org/packages/10/1f/e75fd5b59e9e582190c11ec73c91728d96e90608a22e0aed7365439d9534/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6bb69024257d2fd20da691d1e15bcced874d278884218b64690256982fa30cb1", size = 5049247 }, - { url = "https://files.pythonhosted.org/packages/7e/95/2257b9b148c8c6557387e67828a5096ebc519b997a158ffb67a0987589e5/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85464a1bab81068789de5fb19684e43709d2ba6d64d5655aace7c50b35893d6d", size = 4099850 }, - { url = "https://files.pythonhosted.org/packages/b9/07/8c8aaf8755ee7c137f0898823bd005ffb16edaa6accc0cc1a9a747d56ddc/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7eb773a101f1d9716d750bb326991885a7c4576e85d9a016a567a3b07380bf07", size = 3908308 }, - { url = "https://files.pythonhosted.org/packages/dc/7e/b8f1933be03d9a3a6416edf29fc23d520e45f00fbde6bd8f0614ad6f8a69/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:920e6fed857acd2145a8fca7c6fad17094873f586ac5efed7049ce43a7af4ff6", size = 4307178 }, - { url = "https://files.pythonhosted.org/packages/df/95/a8ba3d7e51ec02ec954d0247c6021b36de5935a9a3845c1cf6c1348cd6e3/geoarrow_rust_core-0.6.1-cp314-cp314-win_amd64.whl", hash = "sha256:9887119cc31a763c34ed8676d06434b47971517e86f8e35c640b494d05e7d5ac", size = 3316511 }, + { url = "https://files.pythonhosted.org/packages/77/2d/3e994dd76223fac0eb597a6f55647cca51bd5a4f446d09b668697f901724/geoarrow_rust_core-0.6.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:84d972cc3dd45a797fd99588d7ee68f257e4083ebdcecad9ec773260067f71a6", size = 3570129, upload-time = "2025-12-03T18:51:07.148Z" }, + { url = "https://files.pythonhosted.org/packages/5f/2a/e19df203b4ffb225f39627e1bd1b89ce7b2220e39f1d6972692174820c57/geoarrow_rust_core-0.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bc0f382d4ed41e85d2d89fc2c7c8c3d046681c9a5e19350ce79e0e930cf69821", size = 3333881, upload-time = "2025-11-21T01:49:28.959Z" }, + { url = "https://files.pythonhosted.org/packages/52/98/b749a2165dfc5d9c54a1c19eb3e6a75b6d005ecde42289b25c1c355346b7/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e719edcaf6698ed2b1aa9525bd97cf79e23a500a39b1e83566cd9a16a294d3", size = 3806366, upload-time = "2025-11-21T01:48:03.525Z" }, + { url = "https://files.pythonhosted.org/packages/84/93/7c0e42ba7d46208fb0f851e06c05de071962170f3a3b2a2260d8a3f66e7a/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0f3546a15503329880063aca31266b301b0b781f618f832585bcd1c9efcc876", size = 3981800, upload-time = "2025-11-21T01:48:17.789Z" }, + { url = "https://files.pythonhosted.org/packages/de/43/9c5736569dead60b33e46b7c485e24804d950693df70dee306e153547789/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6937f3cabebf673f8b726d60d8ca160b46401de8b08c8e257be22772c12c2001", size = 5068955, upload-time = "2025-11-21T01:48:32.569Z" }, + { url = "https://files.pythonhosted.org/packages/71/5e/f26f9bea2af96b0d070e980dcc2196d369a678e06141ed260de5ca72bcc2/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f29ba92053e8ad4bd60d72188518f033ca4abc1f34eecebeb41ee7b790612e00", size = 4104946, upload-time = "2025-11-21T01:48:45.801Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/473796b3e0c03b35292220de88c8efa3e74d6174e807b26a371f2523a4b0/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a5d05a312fbb76821566b1d144c64d0923fcbd790b2c7376ee11f62472b2fe", size = 3917533, upload-time = "2025-11-21T01:49:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/b9/7a/7b62b839c3a9878a7d91b8395e0b7b04483e4bec687e073df0fbd4056583/geoarrow_rust_core-0.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88fe8fd33b16a06e9b3b7638b51d24047f1d01af12cc2e3e2653140877bddef6", size = 4318837, upload-time = "2025-11-21T01:48:58.953Z" }, + { url = "https://files.pythonhosted.org/packages/ea/86/309c55a9c63f316e3a04949ade8847b8e5acbdd21645696911175f0e1814/geoarrow_rust_core-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:dbecc2487cc95526ac77797cd70c199e196811b0a9e877c1b61fcaca508575fa", size = 3320081, upload-time = "2025-11-21T01:49:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ed/514cff089185d71242a62e774e2c59dda147baab65929851b66d72198d5d/geoarrow_rust_core-0.6.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e26ca240d7a6a0fa1b4f56a9ebe07b2e14fc7c1c9507aa862bd31ef14e0521f0", size = 3572326, upload-time = "2025-12-03T18:51:08.477Z" }, + { url = "https://files.pythonhosted.org/packages/77/21/22f8233235bd020db22b4f2bf888f9aeed08813eda7b8b421a6963bdc7e4/geoarrow_rust_core-0.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46876e3528685673e08b4cbc696dca7f22fb073a83318708b0eaf640107b923b", size = 3335166, upload-time = "2025-11-21T01:49:30.632Z" }, + { url = "https://files.pythonhosted.org/packages/bb/eb/0c2e40a6a1bd450347a8a9fc7648ca840710bc177ff6eed3fc5da6ef981a/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5502bd12ede712d9b4725753df4db231a0aa6d3e131079bc4b6452c436e37b7", size = 3800540, upload-time = "2025-11-21T01:48:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/4c/42/22d3b8441bb7041a6fcdb4cf0a1108e150513a52f8a407715188412bc71f/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f04dd7dd03449dba6d15f7d35c6c708637ac05f125638f56206e876756cd4c5", size = 3984840, upload-time = "2025-11-21T01:48:19.719Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/477b6b2389398dc983026a4ab7dbb7ec121284ad5fb864a1b7a4658c3881/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2afce33d0c3fa87d5d4d24d6617732e4297da3372b1746569b759f9b62aede1", size = 5067358, upload-time = "2025-11-21T01:48:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/6995e9d11462635972b2fc09c8e1e510928563ca4fb0fd2c9145cf6ef771/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e63cdb661652a9836dc86cb5995ad269817d88b80f4cce6ed236a7f80f0aba", size = 4105773, upload-time = "2025-11-21T01:48:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/a3/21/b369208495f213db0a0e7d563358307a706cc6af0cb9c897dacf28ae06a1/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adbaf97cb770aef69df8a16437c9faa67adb2b04856faf45bcb61d5b986101dc", size = 3914659, upload-time = "2025-11-21T01:49:16.35Z" }, + { url = "https://files.pythonhosted.org/packages/1d/49/fccb14c6ee9bb715451e4d5bbe3d571eb59a8a1abe21b2abe0d9d48a7fac/geoarrow_rust_core-0.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:202f35b301caa5154d95fd74424a1ef6449306e4f6fbfb5140270e48e94188a5", size = 4315153, upload-time = "2025-11-21T01:49:01.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/88b16510e24a4a3332284669085673701b9fe4d6a511b4466c90655a9daf/geoarrow_rust_core-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:491405dfcc821a2c599e381cc9923e04a758deb1cc84fdb5794b519446c2f8a8", size = 3320510, upload-time = "2025-11-21T01:50:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5f/1dbdbc1dde2140937cff20188cb25034b6f39e1734c14ca6510cf464bf77/geoarrow_rust_core-0.6.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a8145a562e94419402dd0882bb62429853804c53d47dbea944f2a24abc57abd2", size = 3568115, upload-time = "2025-12-03T18:51:09.743Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/b62676f89ef3b866676967989ee8dbbd3d16c77f69aa4287825703268c42/geoarrow_rust_core-0.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51040a5afcfa0cd3ab372d981375c7fe8eb652d155e3964d52ed51d14faa04e8", size = 3325336, upload-time = "2025-11-21T01:49:32.67Z" }, + { url = "https://files.pythonhosted.org/packages/1f/89/94e20f255712ff0eaccf9bfeac4bf51953ebcef0599cfc92f67037f8ab1a/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fbf8506848b0254b3c89b27c045be38bbef6372b21714cad45d76b0c8cb92ce", size = 3808535, upload-time = "2025-11-21T01:48:07.618Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e4/37c7e2c9e251148be17292d0656d7d1ab35019678f6bd11090a41c270d18/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1a0d9c14bf2f36676016c753517d9470381969c2a67859716cceae33735f3ee", size = 3978997, upload-time = "2025-11-21T01:48:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/71/27/c4ba353d9b77889136bdfd1c0cd1a04d6eade9da6e0748b06719c458afb5/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6df97301782ecbaf5f2f0252011a9ff309471cde25435bdf1e17b29c263ebc16", size = 5066492, upload-time = "2025-11-21T01:48:36.142Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/34107fc9aacc489e41afed420202645675b41d85b46dc70d5ba222312791/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1948cfdd0e1c7d03a0c2067821dd536ab34d1e726515202e51fbd6b0d9f775f", size = 4106130, upload-time = "2025-11-21T01:48:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/2e348b884738fb213fb3b4745955baeeaf047aecb37639e39a4dd8f12d99/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95b1611b66c386cc6c74e990df4f114bcf24956a35e18e51bf6331c079a36688", size = 3913166, upload-time = "2025-11-21T01:49:18.228Z" }, + { url = "https://files.pythonhosted.org/packages/bf/81/fdda8bb5f84df82bc9e000435a88be46d46dda41eb5149f624ed96b7031c/geoarrow_rust_core-0.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1751357a1aaa26aeb5feb6f66873b6a2d369655039f7278dedcb692b512111cc", size = 4313573, upload-time = "2025-11-21T01:49:03.184Z" }, + { url = "https://files.pythonhosted.org/packages/a0/14/ca0bc7d3b158094e769ba2bbc43d203330e7e457ed67b50af97d3eac45df/geoarrow_rust_core-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:16fe159043a444579948864808ebec8c49ec167ec0df3cb772dfb88de268bc91", size = 3318746, upload-time = "2025-11-21T01:50:17.319Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/94e4f8fb32ef705cf65031a24c58cdc441042a68a794b74757a6561cbc60/geoarrow_rust_core-0.6.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6c1b692f76b613757438bf23cfe3be4a8715f0268afd8ad3ca0063c257a3be4b", size = 3568328, upload-time = "2025-12-03T18:51:11.291Z" }, + { url = "https://files.pythonhosted.org/packages/7c/45/a96e64f9febc3436766c5055508c4e823cce56577529d7b76c4e4f584bc4/geoarrow_rust_core-0.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a2b4f9a8cfe852a0ba9a667258307db9e354b470b7e0a03edffd0b7daf9b6f5", size = 3325879, upload-time = "2025-11-21T01:49:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/58/c0/c719ce3fb4e982e28c71f65a80cf697d07d733336e6b74d7d1b8a7daf9d0/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8248330f5c3e7ec5852d0a23c23b31a08395300ef9544109e2991317beddfee3", size = 3809144, upload-time = "2025-11-21T01:48:09.562Z" }, + { url = "https://files.pythonhosted.org/packages/e2/8e/2ab3563b2ffd13f2dd69c050a901de0a4bb325879531a66f56d30bc7337e/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:775e9fe45c06d02be59b1497c60aa4f7a7c1d460387bf5f63142faf39b8ad4ff", size = 3978886, upload-time = "2025-11-21T01:48:23.335Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/31625caa0a32e8e9e7aaf2514a840dda0dadf8e2452710ebc10e5f469494/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94de8fb01da3f22332eab28b03570c43cc36492ce482c254fe87e851ae21285b", size = 5065429, upload-time = "2025-11-21T01:48:37.896Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/ee247bd4ccf3b0791b8669357d440e3960d4dbd5cca940a2e226e8910c31/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c70a63d1d36687a53dc6c2933446b1435c187e4c616cd84844d89b6ba13bc4f6", size = 4105436, upload-time = "2025-11-21T01:48:50.874Z" }, + { url = "https://files.pythonhosted.org/packages/a9/fb/c1e92716ee5aa00d48b650f0cb43220a1bf4088c8d572dfc21d400b16723/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e505312f2761393fe5158242f3f2d77e9daa5cca63badd8d66e6d1d69fc17bf", size = 3913672, upload-time = "2025-11-21T01:49:19.873Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6f/ef47f6070c5d5cf0d061d5f5ba95aed7e895e4720a784b84c911c0209fc0/geoarrow_rust_core-0.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a732e58549108df8267ab72fa6cc7c54e5a9e30b818d8d869e301a9de9d3029e", size = 4313496, upload-time = "2025-11-21T01:49:04.953Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ac/2696b979623ea02129e342f8820c89d03fa5a253a913ad00b588d6dd2948/geoarrow_rust_core-0.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:9e1d6492b1388b9d5ae898728838ada78dbf2340d2e9dd25ad3df6ccdd058813", size = 3318780, upload-time = "2025-11-21T01:50:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/4e/42/0cb3af24b01d3897a9eee6af5cc0676bf6b80364e0d4638e45a5fc873d35/geoarrow_rust_core-0.6.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3748cc8e8cb2bcedaede27cefed6749d4eea93e358b49a2f0b061d8974dd1b91", size = 3560313, upload-time = "2025-12-03T18:51:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/51/bc/33f8c918e46188707ab358752b993bee9184fa62e580998c1ec4c37885c1/geoarrow_rust_core-0.6.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1b0e232fe4e239ca435d0bab638934eee87d758024c1727ee24a2b8bc4d8bc7b", size = 3321855, upload-time = "2025-12-03T18:51:00.056Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d7/aeb2a3922670ad57f62cb591bd0309a8300ceeec6efc7f925a563c9da672/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:843444ada2c7f7670fd9df3bdebd93e5247b376d1dd20c4fb3828632847ab78e", size = 3799057, upload-time = "2025-12-03T18:50:28.982Z" }, + { url = "https://files.pythonhosted.org/packages/76/08/606e55fc2a0e85b02e0fde7dec2014eb8f1463e8a823496d72a3095de73d/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:880641183a09ebfbca3a6357071f137d1a4b0f1ba606fb9127a01cf58faaef56", size = 3968892, upload-time = "2025-12-03T18:50:34.661Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/e75fd5b59e9e582190c11ec73c91728d96e90608a22e0aed7365439d9534/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6bb69024257d2fd20da691d1e15bcced874d278884218b64690256982fa30cb1", size = 5049247, upload-time = "2025-12-03T18:50:40.542Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/2257b9b148c8c6557387e67828a5096ebc519b997a158ffb67a0987589e5/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85464a1bab81068789de5fb19684e43709d2ba6d64d5655aace7c50b35893d6d", size = 4099850, upload-time = "2025-12-03T18:50:45.341Z" }, + { url = "https://files.pythonhosted.org/packages/b9/07/8c8aaf8755ee7c137f0898823bd005ffb16edaa6accc0cc1a9a747d56ddc/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7eb773a101f1d9716d750bb326991885a7c4576e85d9a016a567a3b07380bf07", size = 3908308, upload-time = "2025-12-03T18:50:55.587Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7e/b8f1933be03d9a3a6416edf29fc23d520e45f00fbde6bd8f0614ad6f8a69/geoarrow_rust_core-0.6.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:920e6fed857acd2145a8fca7c6fad17094873f586ac5efed7049ce43a7af4ff6", size = 4307178, upload-time = "2025-12-03T18:50:50.429Z" }, + { url = "https://files.pythonhosted.org/packages/df/95/a8ba3d7e51ec02ec954d0247c6021b36de5935a9a3845c1cf6c1348cd6e3/geoarrow_rust_core-0.6.1-cp314-cp314-win_amd64.whl", hash = "sha256:9887119cc31a763c34ed8676d06434b47971517e86f8e35c640b494d05e7d5ac", size = 3316511, upload-time = "2025-12-03T18:51:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/4b2f51d0e4ac683217852d79c3acef719ca116f418d9ce8f4dcc6d717716/geoarrow_rust_core-0.6.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:420a720217b5a7ec6f7977cfe7e7a729c73381ed5e63112fdef33bd805b9cf8a", size = 3572216, upload-time = "2025-12-03T18:51:14.544Z" }, + { url = "https://files.pythonhosted.org/packages/f0/55/85a2948b10ad9ea347597f90355d8992745f00fedae54916205c8c9b80fb/geoarrow_rust_core-0.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0fb9c8c6bba4e712edf475ce3c78bf13f7b10f750256f57deb29c3222eaef033", size = 3335928, upload-time = "2025-11-21T01:49:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/fdd6c34ff8acd878c31e9f5fe4792f49d437e0465e0b60c24d6cdc287ed7/geoarrow_rust_core-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9547ead76eac906b7a583ee65fa137e6b8ed34c0f128c1745a290c451726f27", size = 3808249, upload-time = "2025-11-21T01:48:11.192Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a1/fd6741b5c1d7d48b5f6ab58a994a91c86e29d19ee7bca2636590b8ac9a54/geoarrow_rust_core-0.6.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eaa8e8f40ca8fcd367735cb4226c5aa5171a713d75bc2caab9a03bd9f59d7bf2", size = 3984081, upload-time = "2025-11-21T01:48:25.595Z" }, + { url = "https://files.pythonhosted.org/packages/91/1e/2b5a9b65bf19a79d212ea0fe60fa5632ec4c89bb64ee446272b47e5cd6ac/geoarrow_rust_core-0.6.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:08992719a2accbf993837a6aad615e3f2bf1954d2d9152e507dd79621c87e9d3", size = 5071749, upload-time = "2025-11-21T01:48:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/08/7a/6b37f5e52300b60854b74f4cdc9fbe613c692a15c3ae42f1952f3849bc86/geoarrow_rust_core-0.6.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:071c0e72c4c2047326ebec8d76ce2debcdd59e187207433c3a29ac2da861ca92", size = 4107621, upload-time = "2025-11-21T01:48:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3e/f849642ef4e1f54bcc651903f19a219c3d2be68d27f4ceb282a07ebba7cd/geoarrow_rust_core-0.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c49d5a9e7b73c30dd1790a3e0faf30b7a4ee393c127c5a799d543653d1d80f0c", size = 3919352, upload-time = "2025-11-21T01:49:21.495Z" }, + { url = "https://files.pythonhosted.org/packages/84/c8/57318cb04d061788d5ba523984915c98523e9eb9b7ba4937ff3438e045ef/geoarrow_rust_core-0.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:909152922ee42197b8ae846a8b6c5383c6f3ab39fe627ec8539765e3a634de68", size = 4320006, upload-time = "2025-11-21T01:49:06.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/9f/be16e191fdedbac4d9c01096327917a948625619423c666ec3db2191b4ab/geoarrow_rust_core-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:796c84184fe5e65e30df9f9f45aa8c1680f07689ea71ed1960faa7324fb67e52", size = 3321071, upload-time = "2025-11-21T01:50:20.844Z" }, ] [[package]] @@ -632,55 +710,64 @@ version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "arro3-core" }, - { name = "pyproj", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pyproj", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyproj", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "pyproj", version = "3.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/30/34858dfea53d05ccc4222cd1a40e4a8cd67a0db26dc4571c23b17184de04/geoarrow_rust_io-0.6.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3d9da006559ef26bb51f76a292182ded022516792dd44e925fb96d164f29b710", size = 9779187 }, - { url = "https://files.pythonhosted.org/packages/66/57/989ff25af2edb552047f725a4538fd2e3581e06c5a01f1928a93722b7e38/geoarrow_rust_io-0.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1485af3a34d8d04077c14b259c0d2c28bd34d73d0a09e1e57b6784fd851618a", size = 9315328 }, - { url = "https://files.pythonhosted.org/packages/0b/bc/726bc19080b16b485ba7d657b8fd8f7a90b54c2a4669fd5a68fa3562cca6/geoarrow_rust_io-0.6.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:460441ce78ebe348ace2508618c0bf04b8ffb78d6b64d7f64223c439b19677ab", size = 10307443 }, - { url = "https://files.pythonhosted.org/packages/4b/5b/488cd94412bf10d250fe0073cc77891507f4dbbd02a2ca166ad178e3cded/geoarrow_rust_io-0.6.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f3c86ddc6ef66de5f4a2107202f326defd50c91a11e61cfc3051ce53325eaac", size = 11287758 }, - { url = "https://files.pythonhosted.org/packages/05/97/4f1a8809a4b5f51cc69537c0b0990d1fa32a10eef76255093383c1999422/geoarrow_rust_io-0.6.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e097a8990c85b8d449286ad495acc0bd1fd7eeabfe168787bbd5d8100a9fa5e8", size = 13300003 }, - { url = "https://files.pythonhosted.org/packages/e0/35/bc4e80c3553a9fd8c2227bf850a9c2a6b9756623196b17f29a2f394c4304/geoarrow_rust_io-0.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bcb9788461a2e41a1b5f9041cf797b6395d010f52a5c35012c0be9f0a02ddc8", size = 10485708 }, - { url = "https://files.pythonhosted.org/packages/af/d8/86b3e8e34b9a999d7c44945a49bb09ea58f6c0d7c5600102e63d1b9a4d2d/geoarrow_rust_io-0.6.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d9836cd0469d0fabcd5b64ea85fed6ce0c4c2f508e16ee8eab7c3aad82fb351d", size = 10393066 }, - { url = "https://files.pythonhosted.org/packages/43/54/f24a08a1a9a2eafc798125c9c5897041471032d566de3d3de80244987096/geoarrow_rust_io-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:6030616355e023e18212f2593c0a0f84f31a47fa08799d343081ecce9b1011a5", size = 8987500 }, - { url = "https://files.pythonhosted.org/packages/05/2d/54a854ded5d1a233a0a13974b0abdcbd8d9bdf48ea1788b321d88500bc0c/geoarrow_rust_io-0.6.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d269f3b20176d8a54c86db2352a35a0a2b8275306d2e72cfa234691bf4d566d8", size = 9780467 }, - { url = "https://files.pythonhosted.org/packages/e0/15/d816532f335c747c724d7fdd912de1553aaf6c81b642c176d6fdc105f2ca/geoarrow_rust_io-0.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c8066aedd3ae559c4f3d4958adf05ba11f7341ec4f50858bec3360f478263978", size = 9315289 }, - { url = "https://files.pythonhosted.org/packages/e5/01/a28c42424ec6932f74ec1a1372f4d34ab2f5d557ff7d0b0b1a2a67281e10/geoarrow_rust_io-0.6.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:30658102f96006a8caa9b8ec7ad75e9eb50002b4c51017bd639d56473ec1b807", size = 10304195 }, - { url = "https://files.pythonhosted.org/packages/87/5e/c689e7095832a2304d91074579bf5c9cef5c6554c9dd15f2c32a346e9977/geoarrow_rust_io-0.6.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee76e806ddd1273acf0bec46bca045bcd70b1ab679c90c205d1ade9f70f966c4", size = 11281109 }, - { url = "https://files.pythonhosted.org/packages/e6/9e/3f81c54336ec59c96734889e107a2d11a21dbbebefbab445b133a04b804e/geoarrow_rust_io-0.6.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87deb98f87ec83bfbd2a639a9736ba3079f7e831951c6e67fafc10ca2f95b463", size = 13298204 }, - { url = "https://files.pythonhosted.org/packages/74/f2/0f3c261a85c8fb999866fdf47c6054b0238826e07209a90205abf953794e/geoarrow_rust_io-0.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512ace6604c9244eaa91110016e318f6a76ef483e2038ceb3d62006cf7940432", size = 10484580 }, - { url = "https://files.pythonhosted.org/packages/58/df/37570d23d463b1d2be8d1b8db4d60e17f976340ced55c051d24d81dc573f/geoarrow_rust_io-0.6.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1458e1f6b96bef79b966c4b360cae8a78d0aaf4e7e05d029fde227e1cbc4bd34", size = 10393813 }, - { url = "https://files.pythonhosted.org/packages/de/57/0fb5b7414c1f8bb356fb536fbb080e564bed25f1cbe38dd3b19bf67ab5a0/geoarrow_rust_io-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:fdf00469b710b1d59c6c0e14f5ca9c4c2753e14c3de3148f4c9a84415f16723a", size = 8987671 }, - { url = "https://files.pythonhosted.org/packages/6d/ee/7c841b38c9eaedbb830e1eab077c2d2f86e69f7bef3327167dd142a0d950/geoarrow_rust_io-0.6.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ee3386abfe1302b761a8436b27d45e040fc9f429820c9421793cf2575c90d3f5", size = 9761654 }, - { url = "https://files.pythonhosted.org/packages/3c/d2/aa36ab40563d95562f75e707fca2ae8e92ed5adbe77517d7b8e12ebfda44/geoarrow_rust_io-0.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc1449699d41db7c88e85eb3d8248773ad06613094c31ffdcc1e2d08aea8cc58", size = 9299644 }, - { url = "https://files.pythonhosted.org/packages/3e/ef/dd9fa56248048dd5d971a54272496731d464ecd19833b9336ec0c1bd6dc9/geoarrow_rust_io-0.6.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:182c57b481fefe2a5cd0981a9233aa501445ed8353d907189574996b571ebc8e", size = 10307787 }, - { url = "https://files.pythonhosted.org/packages/38/d6/211c7d5534a346a91033b29cd24e279956f48ec5497bb0710811121f9be0/geoarrow_rust_io-0.6.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:766e319cd2d12dcb8c00fd8e37c5577667fe916ca79ad3378c8f0f30318fb886", size = 11291384 }, - { url = "https://files.pythonhosted.org/packages/91/d0/6aefa98a808910645d96d366bde1c72bd0ccda707ec1f0a46cdbfb8c83fd/geoarrow_rust_io-0.6.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15540b29e18d43ef38b22a2451c5ac0bbd9b8e4c16493ea799bf800c7624a70f", size = 13299716 }, - { url = "https://files.pythonhosted.org/packages/16/94/dfbfd2af284313370b1664c204afa943ce31ad5b711dd2e42a464816fb20/geoarrow_rust_io-0.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5d39dadd0faaa7fe95314f22c4aab79eda0ea6b072a331157571430206e4d9e", size = 10499797 }, - { url = "https://files.pythonhosted.org/packages/b6/8a/c0e851de7f492ab10640ab30b58caabe19945cb009c1cdad9801f7620153/geoarrow_rust_io-0.6.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:72112ec449f5dff041736ab1010f4908bb0e3a50785be6199ec9753d8d35b3d1", size = 10398502 }, - { url = "https://files.pythonhosted.org/packages/d7/bd/8eb48f63a6e3dffff5cb0e9f06ec80aada0a8fa38642b88ea2d4db85e7d3/geoarrow_rust_io-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:132dcbff42fd6f6f2b92738cf7590b9dce204fbdaad4badb1717a1edf651c099", size = 8989856 }, - { url = "https://files.pythonhosted.org/packages/e9/2f/805b1b899543b71190bb7f3ee4a04c7319b62a3b17b48f0d0890a63992fa/geoarrow_rust_io-0.6.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2f678fb0cf628236f55ec0b910f0a18edaf687487e135e3a7917afb413553cb8", size = 9761553 }, - { url = "https://files.pythonhosted.org/packages/37/13/aaa2be1f840254a7c33f747653400bb22d4a3afbff7dbacc754d55af5ef0/geoarrow_rust_io-0.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f1ec8c4b869de9dc7e7f9a5704e7bf7b74f8103c79b89c1b9e02340d93387ab", size = 9298952 }, - { url = "https://files.pythonhosted.org/packages/c7/4f/e560d94218fe807cc09bea66d2c37258c819a4a6d48d8785952773cab06a/geoarrow_rust_io-0.6.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76d124beea3044827fbc21f428e2fd3c1bb9520e339c43ffd1aa2f4bc5a7a203", size = 10307162 }, - { url = "https://files.pythonhosted.org/packages/45/9c/0b3438534c5c96db4e4a65a33b0b29d374f02dfa127937c6f8213fc34420/geoarrow_rust_io-0.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00f2de6d1d531236a8fedd8aba7b8b8cfcc8499a4c9e4f2f958175e72617d970", size = 11290399 }, - { url = "https://files.pythonhosted.org/packages/5f/f4/2b1621b1c9775bb0f82834806df553431c17cd788c49bce84a24ce7f5324/geoarrow_rust_io-0.6.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca201a88976e2cb3e6fbe3e26dc20f4882de30c0611ba2ebd117be60a4f30cc4", size = 13297437 }, - { url = "https://files.pythonhosted.org/packages/d0/9a/f406b73d1d149c24f02350c4d4671fa8c901341872ca2841aca1a5bf7296/geoarrow_rust_io-0.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:080da478ee833c9888c65f48da09f6ef5952f29fd1c4848a0781ecb8ea03a1a9", size = 10499789 }, - { url = "https://files.pythonhosted.org/packages/d2/5a/f6801ec91da5cbc16f37604054d5419c9c69e9e9c2ea753aaf8dec72527f/geoarrow_rust_io-0.6.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b0d837a8b94c5f7fd7e52dfa44b8a6b088ccf8e07836d6f935c74528a700a596", size = 10399015 }, - { url = "https://files.pythonhosted.org/packages/c4/e3/1868fd3e90d33040555e34b5293b406acc3f12d0bbf9e9a99d7bfd270dba/geoarrow_rust_io-0.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:b2d4211f75893416b6ed26b07d7b94e04360055e902d9158abd65e3859a530f1", size = 8988931 }, - { url = "https://files.pythonhosted.org/packages/dc/9f/7eab9987bdcd96e6a567e6f3d06a1374dada00f1446471fb6ec15b103a55/geoarrow_rust_io-0.6.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5034be7ec038116116fdb1b2f133ee9b44d721209aeb4af9a0fc0557a0b74626", size = 9717838 }, - { url = "https://files.pythonhosted.org/packages/a5/5b/e04aa6d8852cefcc0644353dc00ed3b1ed7f27e16c57bb2a84ba437127cf/geoarrow_rust_io-0.6.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7bdae04ed50f9a9a4672e7dffeac6cac11fdc106b02afc1af39b78e71d38e0a4", size = 9295370 }, - { url = "https://files.pythonhosted.org/packages/f8/61/ef2386c1fc7ac9b607c07cfdc33e6f0dd4f84b15a7c9738d823413a81afc/geoarrow_rust_io-0.6.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac673d84b7e85f400d500d5e21e35632b9e91542c45d489506d986ec3d1c2586", size = 10296902 }, - { url = "https://files.pythonhosted.org/packages/3b/02/559acc3db5408b346d5d0bf7104943ff03ea1fdb5484b5cfb35b3c3e111c/geoarrow_rust_io-0.6.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34c6d50383a17391f29407a314c7600573440cef718acf3fa3974cc53c79ee4d", size = 11291753 }, - { url = "https://files.pythonhosted.org/packages/1d/2e/6149fe6141a49a554a355b3cdf09d65511e26e101aa16b784af302cd33fb/geoarrow_rust_io-0.6.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d11004b95a4ec75c733ab57ddc57ff2e31992c32f958bc8c016deb58688992c", size = 13283008 }, - { url = "https://files.pythonhosted.org/packages/41/14/1ec1ba4df851b477d802285e8b770f65e6774f0d6272e4e8548c8758892c/geoarrow_rust_io-0.6.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a10e67d95a134dbb5f657fe3436ea645c6760a4ffef44df211f7d9b8fb687e6", size = 10499137 }, - { url = "https://files.pythonhosted.org/packages/a5/66/7ad618415790671664e76596c000e812e0bd39e8f347f4eb7b8e3f519a55/geoarrow_rust_io-0.6.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:61ccbb528bbe4834849c501e5990a4a6f4b87976ca6a22df7859f16760c79590", size = 10394123 }, - { url = "https://files.pythonhosted.org/packages/43/4b/4520af8c694ca0932f995c91d604837741522bd02b66414fdff4521abc98/geoarrow_rust_io-0.6.1-cp314-cp314-win_amd64.whl", hash = "sha256:aa46f6beda6c267f420ea390f071fadd0161094c1db8d71ad54002c006fe7f21", size = 8989484 }, - { url = "https://files.pythonhosted.org/packages/e6/9f/32059400bb853eafe5d37d8c4ae9e48cd9c43820287e435cc1566f42208e/geoarrow_rust_io-0.6.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef94f84ba4efb42d63588241733e1b62bbdb4edeac5513baeb7bfb07db4f204a", size = 10303111 }, - { url = "https://files.pythonhosted.org/packages/6c/a2/7db0a685eafa41e9565a3c4e441f41d2630c084f616d2669c5fe8f5805ef/geoarrow_rust_io-0.6.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:872dd92c52b2df342d34ac42d1b710c91c58e9dd93f5c88098816f9cd9dc8a84", size = 11299498 }, - { url = "https://files.pythonhosted.org/packages/13/b4/1bfbfbe828ca51b4f314d9f70514c2ff19923714aa7d51ef1b0ec8600aed/geoarrow_rust_io-0.6.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:235a7ea94faa95a4699f6577765a5e5a88bee079828c3d9015d9d5c6c240459c", size = 13299230 }, - { url = "https://files.pythonhosted.org/packages/69/a0/8ff1c2143757e4e9f499992a837d9990db5f4379cdd4a1573a1f7c22e1ff/geoarrow_rust_io-0.6.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f74a6c0137e6fc8c5fde329c0ed85fd4cfc349fe85b2250b7aef974547427d57", size = 10499411 }, - { url = "https://files.pythonhosted.org/packages/6b/7e/6196a7b6c63c0875474a2c2319f2a2d92bb4acd4a8d260e1e10726ccff2b/geoarrow_rust_io-0.6.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:209ddc68c06a2f8577deaf4d744eac21696872f21d367a3ec0b15dc7cf824d5b", size = 10404698 }, + { url = "https://files.pythonhosted.org/packages/bf/30/34858dfea53d05ccc4222cd1a40e4a8cd67a0db26dc4571c23b17184de04/geoarrow_rust_io-0.6.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3d9da006559ef26bb51f76a292182ded022516792dd44e925fb96d164f29b710", size = 9779187, upload-time = "2025-11-21T02:11:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/989ff25af2edb552047f725a4538fd2e3581e06c5a01f1928a93722b7e38/geoarrow_rust_io-0.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1485af3a34d8d04077c14b259c0d2c28bd34d73d0a09e1e57b6784fd851618a", size = 9315328, upload-time = "2025-11-21T02:11:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bc/726bc19080b16b485ba7d657b8fd8f7a90b54c2a4669fd5a68fa3562cca6/geoarrow_rust_io-0.6.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:460441ce78ebe348ace2508618c0bf04b8ffb78d6b64d7f64223c439b19677ab", size = 10307443, upload-time = "2025-11-21T02:10:25.314Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/488cd94412bf10d250fe0073cc77891507f4dbbd02a2ca166ad178e3cded/geoarrow_rust_io-0.6.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f3c86ddc6ef66de5f4a2107202f326defd50c91a11e61cfc3051ce53325eaac", size = 11287758, upload-time = "2025-11-21T02:10:50.338Z" }, + { url = "https://files.pythonhosted.org/packages/05/97/4f1a8809a4b5f51cc69537c0b0990d1fa32a10eef76255093383c1999422/geoarrow_rust_io-0.6.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e097a8990c85b8d449286ad495acc0bd1fd7eeabfe168787bbd5d8100a9fa5e8", size = 13300003, upload-time = "2025-11-21T02:10:37.84Z" }, + { url = "https://files.pythonhosted.org/packages/e0/35/bc4e80c3553a9fd8c2227bf850a9c2a6b9756623196b17f29a2f394c4304/geoarrow_rust_io-0.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bcb9788461a2e41a1b5f9041cf797b6395d010f52a5c35012c0be9f0a02ddc8", size = 10485708, upload-time = "2025-11-21T02:11:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/af/d8/86b3e8e34b9a999d7c44945a49bb09ea58f6c0d7c5600102e63d1b9a4d2d/geoarrow_rust_io-0.6.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d9836cd0469d0fabcd5b64ea85fed6ce0c4c2f508e16ee8eab7c3aad82fb351d", size = 10393066, upload-time = "2025-11-21T02:10:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/43/54/f24a08a1a9a2eafc798125c9c5897041471032d566de3d3de80244987096/geoarrow_rust_io-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:6030616355e023e18212f2593c0a0f84f31a47fa08799d343081ecce9b1011a5", size = 8987500, upload-time = "2025-11-21T02:11:31.939Z" }, + { url = "https://files.pythonhosted.org/packages/05/2d/54a854ded5d1a233a0a13974b0abdcbd8d9bdf48ea1788b321d88500bc0c/geoarrow_rust_io-0.6.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d269f3b20176d8a54c86db2352a35a0a2b8275306d2e72cfa234691bf4d566d8", size = 9780467, upload-time = "2025-11-21T02:11:24.77Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/d816532f335c747c724d7fdd912de1553aaf6c81b642c176d6fdc105f2ca/geoarrow_rust_io-0.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c8066aedd3ae559c4f3d4958adf05ba11f7341ec4f50858bec3360f478263978", size = 9315289, upload-time = "2025-11-21T02:11:15.504Z" }, + { url = "https://files.pythonhosted.org/packages/e5/01/a28c42424ec6932f74ec1a1372f4d34ab2f5d557ff7d0b0b1a2a67281e10/geoarrow_rust_io-0.6.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:30658102f96006a8caa9b8ec7ad75e9eb50002b4c51017bd639d56473ec1b807", size = 10304195, upload-time = "2025-11-21T02:10:27.658Z" }, + { url = "https://files.pythonhosted.org/packages/87/5e/c689e7095832a2304d91074579bf5c9cef5c6554c9dd15f2c32a346e9977/geoarrow_rust_io-0.6.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee76e806ddd1273acf0bec46bca045bcd70b1ab679c90c205d1ade9f70f966c4", size = 11281109, upload-time = "2025-11-21T02:10:52.168Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9e/3f81c54336ec59c96734889e107a2d11a21dbbebefbab445b133a04b804e/geoarrow_rust_io-0.6.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87deb98f87ec83bfbd2a639a9736ba3079f7e831951c6e67fafc10ca2f95b463", size = 13298204, upload-time = "2025-11-21T02:10:39.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/f2/0f3c261a85c8fb999866fdf47c6054b0238826e07209a90205abf953794e/geoarrow_rust_io-0.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512ace6604c9244eaa91110016e318f6a76ef483e2038ceb3d62006cf7940432", size = 10484580, upload-time = "2025-11-21T02:11:04.15Z" }, + { url = "https://files.pythonhosted.org/packages/58/df/37570d23d463b1d2be8d1b8db4d60e17f976340ced55c051d24d81dc573f/geoarrow_rust_io-0.6.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1458e1f6b96bef79b966c4b360cae8a78d0aaf4e7e05d029fde227e1cbc4bd34", size = 10393813, upload-time = "2025-11-21T02:10:13.695Z" }, + { url = "https://files.pythonhosted.org/packages/de/57/0fb5b7414c1f8bb356fb536fbb080e564bed25f1cbe38dd3b19bf67ab5a0/geoarrow_rust_io-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:fdf00469b710b1d59c6c0e14f5ca9c4c2753e14c3de3148f4c9a84415f16723a", size = 8987671, upload-time = "2025-11-21T02:11:33.833Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/7c841b38c9eaedbb830e1eab077c2d2f86e69f7bef3327167dd142a0d950/geoarrow_rust_io-0.6.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ee3386abfe1302b761a8436b27d45e040fc9f429820c9421793cf2575c90d3f5", size = 9761654, upload-time = "2025-11-21T02:11:26.598Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d2/aa36ab40563d95562f75e707fca2ae8e92ed5adbe77517d7b8e12ebfda44/geoarrow_rust_io-0.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc1449699d41db7c88e85eb3d8248773ad06613094c31ffdcc1e2d08aea8cc58", size = 9299644, upload-time = "2025-11-21T02:11:17.454Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ef/dd9fa56248048dd5d971a54272496731d464ecd19833b9336ec0c1bd6dc9/geoarrow_rust_io-0.6.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:182c57b481fefe2a5cd0981a9233aa501445ed8353d907189574996b571ebc8e", size = 10307787, upload-time = "2025-11-21T02:10:29.713Z" }, + { url = "https://files.pythonhosted.org/packages/38/d6/211c7d5534a346a91033b29cd24e279956f48ec5497bb0710811121f9be0/geoarrow_rust_io-0.6.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:766e319cd2d12dcb8c00fd8e37c5577667fe916ca79ad3378c8f0f30318fb886", size = 11291384, upload-time = "2025-11-21T02:10:53.939Z" }, + { url = "https://files.pythonhosted.org/packages/91/d0/6aefa98a808910645d96d366bde1c72bd0ccda707ec1f0a46cdbfb8c83fd/geoarrow_rust_io-0.6.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15540b29e18d43ef38b22a2451c5ac0bbd9b8e4c16493ea799bf800c7624a70f", size = 13299716, upload-time = "2025-11-21T02:10:41.88Z" }, + { url = "https://files.pythonhosted.org/packages/16/94/dfbfd2af284313370b1664c204afa943ce31ad5b711dd2e42a464816fb20/geoarrow_rust_io-0.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5d39dadd0faaa7fe95314f22c4aab79eda0ea6b072a331157571430206e4d9e", size = 10499797, upload-time = "2025-11-21T02:11:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/c0e851de7f492ab10640ab30b58caabe19945cb009c1cdad9801f7620153/geoarrow_rust_io-0.6.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:72112ec449f5dff041736ab1010f4908bb0e3a50785be6199ec9753d8d35b3d1", size = 10398502, upload-time = "2025-11-21T02:10:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bd/8eb48f63a6e3dffff5cb0e9f06ec80aada0a8fa38642b88ea2d4db85e7d3/geoarrow_rust_io-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:132dcbff42fd6f6f2b92738cf7590b9dce204fbdaad4badb1717a1edf651c099", size = 8989856, upload-time = "2025-11-21T02:11:35.563Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2f/805b1b899543b71190bb7f3ee4a04c7319b62a3b17b48f0d0890a63992fa/geoarrow_rust_io-0.6.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2f678fb0cf628236f55ec0b910f0a18edaf687487e135e3a7917afb413553cb8", size = 9761553, upload-time = "2025-11-21T02:11:28.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/13/aaa2be1f840254a7c33f747653400bb22d4a3afbff7dbacc754d55af5ef0/geoarrow_rust_io-0.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f1ec8c4b869de9dc7e7f9a5704e7bf7b74f8103c79b89c1b9e02340d93387ab", size = 9298952, upload-time = "2025-11-21T02:11:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4f/e560d94218fe807cc09bea66d2c37258c819a4a6d48d8785952773cab06a/geoarrow_rust_io-0.6.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76d124beea3044827fbc21f428e2fd3c1bb9520e339c43ffd1aa2f4bc5a7a203", size = 10307162, upload-time = "2025-11-21T02:10:31.933Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/0b3438534c5c96db4e4a65a33b0b29d374f02dfa127937c6f8213fc34420/geoarrow_rust_io-0.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00f2de6d1d531236a8fedd8aba7b8b8cfcc8499a4c9e4f2f958175e72617d970", size = 11290399, upload-time = "2025-11-21T02:10:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f4/2b1621b1c9775bb0f82834806df553431c17cd788c49bce84a24ce7f5324/geoarrow_rust_io-0.6.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca201a88976e2cb3e6fbe3e26dc20f4882de30c0611ba2ebd117be60a4f30cc4", size = 13297437, upload-time = "2025-11-21T02:10:43.76Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/f406b73d1d149c24f02350c4d4671fa8c901341872ca2841aca1a5bf7296/geoarrow_rust_io-0.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:080da478ee833c9888c65f48da09f6ef5952f29fd1c4848a0781ecb8ea03a1a9", size = 10499789, upload-time = "2025-11-21T02:11:07.744Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5a/f6801ec91da5cbc16f37604054d5419c9c69e9e9c2ea753aaf8dec72527f/geoarrow_rust_io-0.6.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b0d837a8b94c5f7fd7e52dfa44b8a6b088ccf8e07836d6f935c74528a700a596", size = 10399015, upload-time = "2025-11-21T02:10:18.767Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/1868fd3e90d33040555e34b5293b406acc3f12d0bbf9e9a99d7bfd270dba/geoarrow_rust_io-0.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:b2d4211f75893416b6ed26b07d7b94e04360055e902d9158abd65e3859a530f1", size = 8988931, upload-time = "2025-11-21T02:11:37.387Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9f/7eab9987bdcd96e6a567e6f3d06a1374dada00f1446471fb6ec15b103a55/geoarrow_rust_io-0.6.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5034be7ec038116116fdb1b2f133ee9b44d721209aeb4af9a0fc0557a0b74626", size = 9717838, upload-time = "2025-12-03T19:02:35.488Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5b/e04aa6d8852cefcc0644353dc00ed3b1ed7f27e16c57bb2a84ba437127cf/geoarrow_rust_io-0.6.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7bdae04ed50f9a9a4672e7dffeac6cac11fdc106b02afc1af39b78e71d38e0a4", size = 9295370, upload-time = "2025-12-03T19:02:29.737Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/ef2386c1fc7ac9b607c07cfdc33e6f0dd4f84b15a7c9738d823413a81afc/geoarrow_rust_io-0.6.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac673d84b7e85f400d500d5e21e35632b9e91542c45d489506d986ec3d1c2586", size = 10296902, upload-time = "2025-12-03T19:02:06.874Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/559acc3db5408b346d5d0bf7104943ff03ea1fdb5484b5cfb35b3c3e111c/geoarrow_rust_io-0.6.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34c6d50383a17391f29407a314c7600573440cef718acf3fa3974cc53c79ee4d", size = 11291753, upload-time = "2025-12-03T19:02:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2e/6149fe6141a49a554a355b3cdf09d65511e26e101aa16b784af302cd33fb/geoarrow_rust_io-0.6.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d11004b95a4ec75c733ab57ddc57ff2e31992c32f958bc8c016deb58688992c", size = 13283008, upload-time = "2025-12-03T19:02:12.619Z" }, + { url = "https://files.pythonhosted.org/packages/41/14/1ec1ba4df851b477d802285e8b770f65e6774f0d6272e4e8548c8758892c/geoarrow_rust_io-0.6.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a10e67d95a134dbb5f657fe3436ea645c6760a4ffef44df211f7d9b8fb687e6", size = 10499137, upload-time = "2025-12-03T19:02:24.514Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/7ad618415790671664e76596c000e812e0bd39e8f347f4eb7b8e3f519a55/geoarrow_rust_io-0.6.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:61ccbb528bbe4834849c501e5990a4a6f4b87976ca6a22df7859f16760c79590", size = 10394123, upload-time = "2025-12-03T19:02:01.248Z" }, + { url = "https://files.pythonhosted.org/packages/43/4b/4520af8c694ca0932f995c91d604837741522bd02b66414fdff4521abc98/geoarrow_rust_io-0.6.1-cp314-cp314-win_amd64.whl", hash = "sha256:aa46f6beda6c267f420ea390f071fadd0161094c1db8d71ad54002c006fe7f21", size = 8989484, upload-time = "2025-12-03T19:02:40.081Z" }, + { url = "https://files.pythonhosted.org/packages/69/87/efadbf1bb9d359f55791f7198cf9aa87f0272be6a2d373f5844f5e59cd1e/geoarrow_rust_io-0.6.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:46e3e41b726b250b44a829ab41489e5008280acb8af8e68001230babf04bafd8", size = 9780411, upload-time = "2025-11-21T02:11:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/95/73/5e108b286b219d3a46042cfa0830e0f075f4addd01f83f7c851a933919ae/geoarrow_rust_io-0.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bb95364b726c34c23fb93ebc9c08b8fa1d52062a4a9c1ac614ff8761a339ba7a", size = 9316307, upload-time = "2025-11-21T02:11:21.195Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/89c387d6d4d303feef328fc9c63df76cea52963e2046f2c092b434fb04a9/geoarrow_rust_io-0.6.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:691a67ef3a5214fb704d1a19d33a9ddf173483c3943056fb965101c19b0edd28", size = 10309182, upload-time = "2025-11-21T02:10:34.063Z" }, + { url = "https://files.pythonhosted.org/packages/ff/08/34ed2d76ebfb34ed6bf3312defad16b2b5246e40d59e46443a6fe19e85dd/geoarrow_rust_io-0.6.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c82e9cbae6759798a8e4a87adb13ea617090a5498f384fc56c44775653d7f0", size = 11291230, upload-time = "2025-11-21T02:10:57.771Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f5/9c25512c1f31101125555367e55ff28f72f449c8f56ff06c5be9e3feb9e5/geoarrow_rust_io-0.6.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9c2b609addc7a810eab5cd573243710d95afe8486f829edd05b311d51bbb5af", size = 13300664, upload-time = "2025-11-21T02:10:46.082Z" }, + { url = "https://files.pythonhosted.org/packages/f5/aa/14be165b439d3a3ffc6ced96f971b02df255e86b82c7e1f9f340d35689c3/geoarrow_rust_io-0.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6abdc80e130f472f55598543a4bb9ba522d6502a5d80017a952027a9e9c1d1ce", size = 10486589, upload-time = "2025-11-21T02:11:09.681Z" }, + { url = "https://files.pythonhosted.org/packages/5a/df/1c36bae723561785ce47e463f6366a3c52994795a168d7c4ed5e457e9a37/geoarrow_rust_io-0.6.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c4638a89d61629110dde474b3d410ee2e71c89d2035ab2f2557857e7eee4ea30", size = 10395106, upload-time = "2025-11-21T02:10:20.832Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/4e9cffad7647c07a5cd1cce68c97102dd011652168e3e09a2dedc1253a5e/geoarrow_rust_io-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:4811e96b1777fcf12ac2416872407b1e4717f9a59fe5b80ce02b1e9a087d1b5e", size = 8988735, upload-time = "2025-11-21T02:11:39.164Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/32059400bb853eafe5d37d8c4ae9e48cd9c43820287e435cc1566f42208e/geoarrow_rust_io-0.6.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef94f84ba4efb42d63588241733e1b62bbdb4edeac5513baeb7bfb07db4f204a", size = 10303111, upload-time = "2025-11-21T02:10:36.067Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a2/7db0a685eafa41e9565a3c4e441f41d2630c084f616d2669c5fe8f5805ef/geoarrow_rust_io-0.6.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:872dd92c52b2df342d34ac42d1b710c91c58e9dd93f5c88098816f9cd9dc8a84", size = 11299498, upload-time = "2025-11-21T02:11:00.19Z" }, + { url = "https://files.pythonhosted.org/packages/13/b4/1bfbfbe828ca51b4f314d9f70514c2ff19923714aa7d51ef1b0ec8600aed/geoarrow_rust_io-0.6.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:235a7ea94faa95a4699f6577765a5e5a88bee079828c3d9015d9d5c6c240459c", size = 13299230, upload-time = "2025-11-21T02:10:48.12Z" }, + { url = "https://files.pythonhosted.org/packages/69/a0/8ff1c2143757e4e9f499992a837d9990db5f4379cdd4a1573a1f7c22e1ff/geoarrow_rust_io-0.6.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f74a6c0137e6fc8c5fde329c0ed85fd4cfc349fe85b2250b7aef974547427d57", size = 10499411, upload-time = "2025-11-21T02:11:11.783Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/6196a7b6c63c0875474a2c2319f2a2d92bb4acd4a8d260e1e10726ccff2b/geoarrow_rust_io-0.6.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:209ddc68c06a2f8577deaf4d744eac21696872f21d367a3ec0b15dc7cf824d5b", size = 10404698, upload-time = "2025-11-21T02:10:23.556Z" }, ] [[package]] @@ -690,9 +777,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/4a/0bd53b36ff0323d10d5f24ebd67af2de10a1117f5cf4d7add90df92756f1/google-pasta-0.2.0.tar.gz", hash = "sha256:c9f2c8dfc8f96d0d5808299920721be30c9eec37f2389f28904f454565c8a16e", size = 40430 } +sdist = { url = "https://files.pythonhosted.org/packages/35/4a/0bd53b36ff0323d10d5f24ebd67af2de10a1117f5cf4d7add90df92756f1/google-pasta-0.2.0.tar.gz", hash = "sha256:c9f2c8dfc8f96d0d5808299920721be30c9eec37f2389f28904f454565c8a16e", size = 40430, upload-time = "2020-03-13T18:57:50.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/de/c648ef6835192e6e2cc03f40b19eeda4382c49b5bafb43d88b931c4c74ac/google_pasta-0.2.0-py3-none-any.whl", hash = "sha256:b32482794a366b5366a32c92a9a9201b107821889935a02b3e51f6b432ea84ed", size = 57471 }, + { url = "https://files.pythonhosted.org/packages/a3/de/c648ef6835192e6e2cc03f40b19eeda4382c49b5bafb43d88b931c4c74ac/google_pasta-0.2.0-py3-none-any.whl", hash = "sha256:b32482794a366b5366a32c92a9a9201b107821889935a02b3e51f6b432ea84ed", size = 57471, upload-time = "2020-03-13T18:57:48.872Z" }, ] [[package]] @@ -702,43 +789,50 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/f7/8963848164c7604efb3a3e6ee457fdb3a469653e19002bd24742473254f8/grpcio-1.75.1.tar.gz", hash = "sha256:3e81d89ece99b9ace23a6916880baca613c03a799925afb2857887efa8b1b3d2", size = 12731327 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/57/89fd829fb00a6d0bee3fbcb2c8a7aa0252d908949b6ab58bfae99d39d77e/grpcio-1.75.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:1712b5890b22547dd29f3215c5788d8fc759ce6dd0b85a6ba6e2731f2d04c088", size = 5705534 }, - { url = "https://files.pythonhosted.org/packages/9a/3d/affe2fb897804c98d56361138e73786af8f4dd876b9d9851cfe6342b53c8/grpcio-1.75.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:683cfc70be0c1383449097cba637317e4737a357cfc185d887fd984206380403", size = 6289953 }, - { url = "https://files.pythonhosted.org/packages/87/aa/0f40b7f47a0ff10d7e482bc3af22dac767c7ff27205915f08962d5ca87a2/grpcio-1.75.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:491444c081a54dcd5e6ada57314321ae526377f498d4aa09d975c3241c5b9e1c", size = 6949785 }, - { url = "https://files.pythonhosted.org/packages/a5/45/b04407e44050781821c84f26df71b3f7bc469923f92f9f8bc27f1406dbcc/grpcio-1.75.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce08d4e112d0d38487c2b631ec8723deac9bc404e9c7b1011426af50a79999e4", size = 6465708 }, - { url = "https://files.pythonhosted.org/packages/09/3e/4ae3ec0a4d20dcaafbb6e597defcde06399ccdc5b342f607323f3b47f0a3/grpcio-1.75.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5a2acda37fc926ccc4547977ac3e56b1df48fe200de968e8c8421f6e3093df6c", size = 7100912 }, - { url = "https://files.pythonhosted.org/packages/34/3f/a9085dab5c313bb0cb853f222d095e2477b9b8490a03634cdd8d19daa5c3/grpcio-1.75.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:745c5fe6bf05df6a04bf2d11552c7d867a2690759e7ab6b05c318a772739bd75", size = 8042497 }, - { url = "https://files.pythonhosted.org/packages/c3/87/ea54eba931ab9ed3f999ba95f5d8d01a20221b664725bab2fe93e3dee848/grpcio-1.75.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:259526a7159d39e2db40d566fe3e8f8e034d0fb2db5bf9c00e09aace655a4c2b", size = 7493284 }, - { url = "https://files.pythonhosted.org/packages/0c/3c/35ca9747473a306bfad0cee04504953f7098527cd112a4ab55c55af9e7bd/grpcio-1.75.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:573855ca2e58e35032aff30bfbd1ee103fbcf4472e4b28d4010757700918e326", size = 5709761 }, - { url = "https://files.pythonhosted.org/packages/81/40/bc07aee2911f0d426fa53fe636216100c31a8ea65a400894f280274cb023/grpcio-1.75.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b1ea1bbe77ecbc1be00af2769f4ae4a88ce93be57a4f3eebd91087898ed749f9", size = 6296084 }, - { url = "https://files.pythonhosted.org/packages/b8/d1/10c067f6c67396cbf46448b80f27583b5e8c4b46cdfbe18a2a02c2c2f290/grpcio-1.75.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e5b425aee54cc5e3e3c58f00731e8a33f5567965d478d516d35ef99fd648ab68", size = 6950403 }, - { url = "https://files.pythonhosted.org/packages/3f/42/5f628abe360b84dfe8dd8f32be6b0606dc31dc04d3358eef27db791ea4d5/grpcio-1.75.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0049a7bf547dafaeeb1db17079ce79596c298bfe308fc084d023c8907a845b9a", size = 6470166 }, - { url = "https://files.pythonhosted.org/packages/c3/93/a24035080251324019882ee2265cfde642d6476c0cf8eb207fc693fcebdc/grpcio-1.75.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b8ea230c7f77c0a1a3208a04a1eda164633fb0767b4cefd65a01079b65e5b1f", size = 7107828 }, - { url = "https://files.pythonhosted.org/packages/e4/f8/d18b984c1c9ba0318e3628dbbeb6af77a5007f02abc378c845070f2d3edd/grpcio-1.75.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:36990d629c3c9fb41e546414e5af52d0a7af37ce7113d9682c46d7e2919e4cca", size = 8045421 }, - { url = "https://files.pythonhosted.org/packages/7e/b6/4bf9aacff45deca5eac5562547ed212556b831064da77971a4e632917da3/grpcio-1.75.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b10ad908118d38c2453ade7ff790e5bce36580c3742919007a2a78e3a1e521ca", size = 7503290 }, - { url = "https://files.pythonhosted.org/packages/3a/81/42be79e73a50aaa20af66731c2defeb0e8c9008d9935a64dd8ea8e8c44eb/grpcio-1.75.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:7b888b33cd14085d86176b1628ad2fcbff94cfbbe7809465097aa0132e58b018", size = 5668314 }, - { url = "https://files.pythonhosted.org/packages/14/85/21c71d674f03345ab183c634ecd889d3330177e27baea8d5d247a89b6442/grpcio-1.75.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb658f703468d7fbb5dcc4037c65391b7dc34f808ac46ed9136c24fc5eeb041d", size = 6246335 }, - { url = "https://files.pythonhosted.org/packages/fd/db/3beb661bc56a385ae4fa6b0e70f6b91ac99d47afb726fe76aaff87ebb116/grpcio-1.75.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4b7177a1cdb3c51b02b0c0a256b0a72fdab719600a693e0e9037949efffb200b", size = 6916309 }, - { url = "https://files.pythonhosted.org/packages/1e/9c/eda9fe57f2b84343d44c1b66cf3831c973ba29b078b16a27d4587a1fdd47/grpcio-1.75.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d4fa6ccc3ec2e68a04f7b883d354d7fea22a34c44ce535a2f0c0049cf626ddf", size = 6435419 }, - { url = "https://files.pythonhosted.org/packages/c3/b8/090c98983e0a9d602e3f919a6e2d4e470a8b489452905f9a0fa472cac059/grpcio-1.75.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d86880ecaeb5b2f0a8afa63824de93adb8ebe4e49d0e51442532f4e08add7d6", size = 7064893 }, - { url = "https://files.pythonhosted.org/packages/ec/c0/6d53d4dbbd00f8bd81571f5478d8a95528b716e0eddb4217cc7cb45aae5f/grpcio-1.75.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a8041d2f9e8a742aeae96f4b047ee44e73619f4f9d24565e84d5446c623673b6", size = 8011922 }, - { url = "https://files.pythonhosted.org/packages/f2/7c/48455b2d0c5949678d6982c3e31ea4d89df4e16131b03f7d5c590811cbe9/grpcio-1.75.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3652516048bf4c314ce12be37423c79829f46efffb390ad64149a10c6071e8de", size = 7466181 }, - { url = "https://files.pythonhosted.org/packages/46/74/bac4ab9f7722164afdf263ae31ba97b8174c667153510322a5eba4194c32/grpcio-1.75.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:3bed22e750d91d53d9e31e0af35a7b0b51367e974e14a4ff229db5b207647884", size = 5672779 }, - { url = "https://files.pythonhosted.org/packages/cf/e4/d1954dce2972e32384db6a30273275e8c8ea5a44b80347f9055589333b3f/grpcio-1.75.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5bf4001d3293e3414d0cf99ff9b1139106e57c3a66dfff0c5f60b2a6286ec133", size = 6248838 }, - { url = "https://files.pythonhosted.org/packages/06/43/073363bf63826ba8077c335d797a8d026f129dc0912b69c42feaf8f0cd26/grpcio-1.75.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f82ff474103e26351dacfe8d50214e7c9322960d8d07ba7fa1d05ff981c8b2d", size = 6922663 }, - { url = "https://files.pythonhosted.org/packages/c2/6f/076ac0df6c359117676cacfa8a377e2abcecec6a6599a15a672d331f6680/grpcio-1.75.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0ee119f4f88d9f75414217823d21d75bfe0e6ed40135b0cbbfc6376bc9f7757d", size = 6436149 }, - { url = "https://files.pythonhosted.org/packages/6b/27/1d08824f1d573fcb1fa35ede40d6020e68a04391709939e1c6f4193b445f/grpcio-1.75.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:664eecc3abe6d916fa6cf8dd6b778e62fb264a70f3430a3180995bf2da935446", size = 7067989 }, - { url = "https://files.pythonhosted.org/packages/c6/98/98594cf97b8713feb06a8cb04eeef60b4757e3e2fb91aa0d9161da769843/grpcio-1.75.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c32193fa08b2fbebf08fe08e84f8a0aad32d87c3ad42999c65e9449871b1c66e", size = 8010717 }, - { url = "https://files.pythonhosted.org/packages/8c/7e/bb80b1bba03c12158f9254762cdf5cced4a9bc2e8ed51ed335915a5a06ef/grpcio-1.75.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5cebe13088b9254f6e615bcf1da9131d46cfa4e88039454aca9cb65f639bd3bc", size = 7463822 }, - { url = "https://files.pythonhosted.org/packages/f2/1b/9a0a5cecd24302b9fdbcd55d15ed6267e5f3d5b898ff9ac8cbe17ee76129/grpcio-1.75.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:c05da79068dd96723793bffc8d0e64c45f316248417515f28d22204d9dae51c7", size = 5673319 }, - { url = "https://files.pythonhosted.org/packages/09/7a/26da709e42c4565c3d7bf999a9569da96243ce34a8271a968dee810a7cf1/grpcio-1.75.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4484f4b7287bdaa7a5b3980f3c7224c3c622669405d20f69549f5fb956ad0421", size = 6254706 }, - { url = "https://files.pythonhosted.org/packages/f1/08/dcb26a319d3725f199c97e671d904d84ee5680de57d74c566a991cfab632/grpcio-1.75.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2720c239c1180eee69f7883c1d4c83fc1a495a2535b5fa322887c70bf02b16e8", size = 6922501 }, - { url = "https://files.pythonhosted.org/packages/78/66/044d412c98408a5e23cb348845979a2d17a2e2b6c3c34c1ec91b920f49d0/grpcio-1.75.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:07a554fa31c668cf0e7a188678ceeca3cb8fead29bbe455352e712ec33ca701c", size = 6437492 }, - { url = "https://files.pythonhosted.org/packages/4e/9d/5e3e362815152aa1afd8b26ea613effa005962f9da0eec6e0e4527e7a7d1/grpcio-1.75.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3e71a2105210366bfc398eef7f57a664df99194f3520edb88b9c3a7e46ee0d64", size = 7081061 }, - { url = "https://files.pythonhosted.org/packages/1e/1a/46615682a19e100f46e31ddba9ebc297c5a5ab9ddb47b35443ffadb8776c/grpcio-1.75.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8679aa8a5b67976776d3c6b0521e99d1c34db8a312a12bcfd78a7085cb9b604e", size = 8010849 }, - { url = "https://files.pythonhosted.org/packages/67/8e/3204b94ac30b0f675ab1c06540ab5578660dc8b690db71854d3116f20d00/grpcio-1.75.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aad1c774f4ebf0696a7f148a56d39a3432550612597331792528895258966dc0", size = 7464478 }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/f7/8963848164c7604efb3a3e6ee457fdb3a469653e19002bd24742473254f8/grpcio-1.75.1.tar.gz", hash = "sha256:3e81d89ece99b9ace23a6916880baca613c03a799925afb2857887efa8b1b3d2", size = 12731327, upload-time = "2025-09-26T09:03:36.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/57/89fd829fb00a6d0bee3fbcb2c8a7aa0252d908949b6ab58bfae99d39d77e/grpcio-1.75.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:1712b5890b22547dd29f3215c5788d8fc759ce6dd0b85a6ba6e2731f2d04c088", size = 5705534, upload-time = "2025-09-26T09:00:52.225Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3d/affe2fb897804c98d56361138e73786af8f4dd876b9d9851cfe6342b53c8/grpcio-1.75.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:683cfc70be0c1383449097cba637317e4737a357cfc185d887fd984206380403", size = 6289953, upload-time = "2025-09-26T09:01:03.699Z" }, + { url = "https://files.pythonhosted.org/packages/87/aa/0f40b7f47a0ff10d7e482bc3af22dac767c7ff27205915f08962d5ca87a2/grpcio-1.75.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:491444c081a54dcd5e6ada57314321ae526377f498d4aa09d975c3241c5b9e1c", size = 6949785, upload-time = "2025-09-26T09:01:07.504Z" }, + { url = "https://files.pythonhosted.org/packages/a5/45/b04407e44050781821c84f26df71b3f7bc469923f92f9f8bc27f1406dbcc/grpcio-1.75.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce08d4e112d0d38487c2b631ec8723deac9bc404e9c7b1011426af50a79999e4", size = 6465708, upload-time = "2025-09-26T09:01:11.028Z" }, + { url = "https://files.pythonhosted.org/packages/09/3e/4ae3ec0a4d20dcaafbb6e597defcde06399ccdc5b342f607323f3b47f0a3/grpcio-1.75.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5a2acda37fc926ccc4547977ac3e56b1df48fe200de968e8c8421f6e3093df6c", size = 7100912, upload-time = "2025-09-26T09:01:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/34/3f/a9085dab5c313bb0cb853f222d095e2477b9b8490a03634cdd8d19daa5c3/grpcio-1.75.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:745c5fe6bf05df6a04bf2d11552c7d867a2690759e7ab6b05c318a772739bd75", size = 8042497, upload-time = "2025-09-26T09:01:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/ea54eba931ab9ed3f999ba95f5d8d01a20221b664725bab2fe93e3dee848/grpcio-1.75.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:259526a7159d39e2db40d566fe3e8f8e034d0fb2db5bf9c00e09aace655a4c2b", size = 7493284, upload-time = "2025-09-26T09:01:20.896Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3c/35ca9747473a306bfad0cee04504953f7098527cd112a4ab55c55af9e7bd/grpcio-1.75.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:573855ca2e58e35032aff30bfbd1ee103fbcf4472e4b28d4010757700918e326", size = 5709761, upload-time = "2025-09-26T09:01:28.528Z" }, + { url = "https://files.pythonhosted.org/packages/81/40/bc07aee2911f0d426fa53fe636216100c31a8ea65a400894f280274cb023/grpcio-1.75.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b1ea1bbe77ecbc1be00af2769f4ae4a88ce93be57a4f3eebd91087898ed749f9", size = 6296084, upload-time = "2025-09-26T09:01:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d1/10c067f6c67396cbf46448b80f27583b5e8c4b46cdfbe18a2a02c2c2f290/grpcio-1.75.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e5b425aee54cc5e3e3c58f00731e8a33f5567965d478d516d35ef99fd648ab68", size = 6950403, upload-time = "2025-09-26T09:01:36.736Z" }, + { url = "https://files.pythonhosted.org/packages/3f/42/5f628abe360b84dfe8dd8f32be6b0606dc31dc04d3358eef27db791ea4d5/grpcio-1.75.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0049a7bf547dafaeeb1db17079ce79596c298bfe308fc084d023c8907a845b9a", size = 6470166, upload-time = "2025-09-26T09:01:39.474Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/a24035080251324019882ee2265cfde642d6476c0cf8eb207fc693fcebdc/grpcio-1.75.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b8ea230c7f77c0a1a3208a04a1eda164633fb0767b4cefd65a01079b65e5b1f", size = 7107828, upload-time = "2025-09-26T09:01:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/d18b984c1c9ba0318e3628dbbeb6af77a5007f02abc378c845070f2d3edd/grpcio-1.75.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:36990d629c3c9fb41e546414e5af52d0a7af37ce7113d9682c46d7e2919e4cca", size = 8045421, upload-time = "2025-09-26T09:01:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b6/4bf9aacff45deca5eac5562547ed212556b831064da77971a4e632917da3/grpcio-1.75.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b10ad908118d38c2453ade7ff790e5bce36580c3742919007a2a78e3a1e521ca", size = 7503290, upload-time = "2025-09-26T09:01:49.28Z" }, + { url = "https://files.pythonhosted.org/packages/3a/81/42be79e73a50aaa20af66731c2defeb0e8c9008d9935a64dd8ea8e8c44eb/grpcio-1.75.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:7b888b33cd14085d86176b1628ad2fcbff94cfbbe7809465097aa0132e58b018", size = 5668314, upload-time = "2025-09-26T09:01:55.424Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/21c71d674f03345ab183c634ecd889d3330177e27baea8d5d247a89b6442/grpcio-1.75.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb658f703468d7fbb5dcc4037c65391b7dc34f808ac46ed9136c24fc5eeb041d", size = 6246335, upload-time = "2025-09-26T09:02:00.76Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/3beb661bc56a385ae4fa6b0e70f6b91ac99d47afb726fe76aaff87ebb116/grpcio-1.75.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4b7177a1cdb3c51b02b0c0a256b0a72fdab719600a693e0e9037949efffb200b", size = 6916309, upload-time = "2025-09-26T09:02:02.894Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9c/eda9fe57f2b84343d44c1b66cf3831c973ba29b078b16a27d4587a1fdd47/grpcio-1.75.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d4fa6ccc3ec2e68a04f7b883d354d7fea22a34c44ce535a2f0c0049cf626ddf", size = 6435419, upload-time = "2025-09-26T09:02:05.055Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b8/090c98983e0a9d602e3f919a6e2d4e470a8b489452905f9a0fa472cac059/grpcio-1.75.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d86880ecaeb5b2f0a8afa63824de93adb8ebe4e49d0e51442532f4e08add7d6", size = 7064893, upload-time = "2025-09-26T09:02:07.275Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c0/6d53d4dbbd00f8bd81571f5478d8a95528b716e0eddb4217cc7cb45aae5f/grpcio-1.75.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a8041d2f9e8a742aeae96f4b047ee44e73619f4f9d24565e84d5446c623673b6", size = 8011922, upload-time = "2025-09-26T09:02:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7c/48455b2d0c5949678d6982c3e31ea4d89df4e16131b03f7d5c590811cbe9/grpcio-1.75.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3652516048bf4c314ce12be37423c79829f46efffb390ad64149a10c6071e8de", size = 7466181, upload-time = "2025-09-26T09:02:12.279Z" }, + { url = "https://files.pythonhosted.org/packages/46/74/bac4ab9f7722164afdf263ae31ba97b8174c667153510322a5eba4194c32/grpcio-1.75.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:3bed22e750d91d53d9e31e0af35a7b0b51367e974e14a4ff229db5b207647884", size = 5672779, upload-time = "2025-09-26T09:02:19.11Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/d1954dce2972e32384db6a30273275e8c8ea5a44b80347f9055589333b3f/grpcio-1.75.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5bf4001d3293e3414d0cf99ff9b1139106e57c3a66dfff0c5f60b2a6286ec133", size = 6248838, upload-time = "2025-09-26T09:02:26.426Z" }, + { url = "https://files.pythonhosted.org/packages/06/43/073363bf63826ba8077c335d797a8d026f129dc0912b69c42feaf8f0cd26/grpcio-1.75.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f82ff474103e26351dacfe8d50214e7c9322960d8d07ba7fa1d05ff981c8b2d", size = 6922663, upload-time = "2025-09-26T09:02:28.724Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6f/076ac0df6c359117676cacfa8a377e2abcecec6a6599a15a672d331f6680/grpcio-1.75.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0ee119f4f88d9f75414217823d21d75bfe0e6ed40135b0cbbfc6376bc9f7757d", size = 6436149, upload-time = "2025-09-26T09:02:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/6b/27/1d08824f1d573fcb1fa35ede40d6020e68a04391709939e1c6f4193b445f/grpcio-1.75.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:664eecc3abe6d916fa6cf8dd6b778e62fb264a70f3430a3180995bf2da935446", size = 7067989, upload-time = "2025-09-26T09:02:33.233Z" }, + { url = "https://files.pythonhosted.org/packages/c6/98/98594cf97b8713feb06a8cb04eeef60b4757e3e2fb91aa0d9161da769843/grpcio-1.75.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c32193fa08b2fbebf08fe08e84f8a0aad32d87c3ad42999c65e9449871b1c66e", size = 8010717, upload-time = "2025-09-26T09:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7e/bb80b1bba03c12158f9254762cdf5cced4a9bc2e8ed51ed335915a5a06ef/grpcio-1.75.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5cebe13088b9254f6e615bcf1da9131d46cfa4e88039454aca9cb65f639bd3bc", size = 7463822, upload-time = "2025-09-26T09:02:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1b/9a0a5cecd24302b9fdbcd55d15ed6267e5f3d5b898ff9ac8cbe17ee76129/grpcio-1.75.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:c05da79068dd96723793bffc8d0e64c45f316248417515f28d22204d9dae51c7", size = 5673319, upload-time = "2025-09-26T09:02:44.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/7a/26da709e42c4565c3d7bf999a9569da96243ce34a8271a968dee810a7cf1/grpcio-1.75.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4484f4b7287bdaa7a5b3980f3c7224c3c622669405d20f69549f5fb956ad0421", size = 6254706, upload-time = "2025-09-26T09:02:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/f1/08/dcb26a319d3725f199c97e671d904d84ee5680de57d74c566a991cfab632/grpcio-1.75.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2720c239c1180eee69f7883c1d4c83fc1a495a2535b5fa322887c70bf02b16e8", size = 6922501, upload-time = "2025-09-26T09:02:52.711Z" }, + { url = "https://files.pythonhosted.org/packages/78/66/044d412c98408a5e23cb348845979a2d17a2e2b6c3c34c1ec91b920f49d0/grpcio-1.75.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:07a554fa31c668cf0e7a188678ceeca3cb8fead29bbe455352e712ec33ca701c", size = 6437492, upload-time = "2025-09-26T09:02:55.542Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9d/5e3e362815152aa1afd8b26ea613effa005962f9da0eec6e0e4527e7a7d1/grpcio-1.75.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3e71a2105210366bfc398eef7f57a664df99194f3520edb88b9c3a7e46ee0d64", size = 7081061, upload-time = "2025-09-26T09:02:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1a/46615682a19e100f46e31ddba9ebc297c5a5ab9ddb47b35443ffadb8776c/grpcio-1.75.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8679aa8a5b67976776d3c6b0521e99d1c34db8a312a12bcfd78a7085cb9b604e", size = 8010849, upload-time = "2025-09-26T09:03:00.548Z" }, + { url = "https://files.pythonhosted.org/packages/67/8e/3204b94ac30b0f675ab1c06540ab5578660dc8b690db71854d3116f20d00/grpcio-1.75.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aad1c774f4ebf0696a7f148a56d39a3432550612597331792528895258966dc0", size = 7464478, upload-time = "2025-09-26T09:03:03.096Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e2/33efd823a879dc7b60c10192df1900ee5c200f8e782663a41a3b2aecd143/grpcio-1.75.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:c09fba33327c3ac11b5c33dbdd8218eef8990d78f83b1656d628831812a8c0fb", size = 5706679, upload-time = "2025-09-26T09:03:10.218Z" }, + { url = "https://files.pythonhosted.org/packages/77/90/b80e75f8cce758425b2772742eed4e9db765a965d902ba4b7f239b2513de/grpcio-1.75.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c12121e509b9f8b0914d10054d24120237d19e870b1cd82acbb8a9b9ddd198a3", size = 6291926, upload-time = "2025-09-26T09:03:16.282Z" }, + { url = "https://files.pythonhosted.org/packages/40/5f/e6033d8f99063350e20873a46225468b73045b9ef2c8cba73d66a87c3fd5/grpcio-1.75.1-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:73577a93e692b3474b1bfe84285d098de36705dbd838bb4d6a056d326e4dc880", size = 6950040, upload-time = "2025-09-26T09:03:18.874Z" }, + { url = "https://files.pythonhosted.org/packages/01/12/34076c079b45af5aed40f037fffe388d7fbe90dd539ed01e4744c926d227/grpcio-1.75.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e19e7dfa0d7ca7dea22be464339e18ac608fd75d88c56770c646cdabe54bc724", size = 6465780, upload-time = "2025-09-26T09:03:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c5/ee6fd69a9f6e7288d04da010ad7480a0566d2aac81097ff4dafbc5ffa9b6/grpcio-1.75.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4e1c28f51c1cf67eccdfc1065e8e866c9ed622f09773ca60947089c117f848a1", size = 7098308, upload-time = "2025-09-26T09:03:23.875Z" }, + { url = "https://files.pythonhosted.org/packages/78/32/f2be13f13035361768923159fe20470a7d22db2c7c692b952e21284f56e5/grpcio-1.75.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:030a6164bc2ca726052778c0cf8e3249617a34e368354f9e6107c27ad4af8c28", size = 8042268, upload-time = "2025-09-26T09:03:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2d/1bb0572f0a2eaab100b4635c6c2cd0d37e3cda5554037e3f90b1bc428d56/grpcio-1.75.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:67697efef5a98d46d5db7b1720fa4043536f8b8e5072a5d61cfca762f287e939", size = 7491470, upload-time = "2025-09-26T09:03:28.906Z" }, ] [[package]] @@ -746,34 +840,37 @@ name = "h5py" version = "3.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/57/dfb3c5c3f1bf5f5ef2e59a22dec4ff1f3d7408b55bfcefcfb0ea69ef21c6/h5py-3.14.0.tar.gz", hash = "sha256:2372116b2e0d5d3e5e705b7f663f7c8d96fa79a4052d250484ef91d24d6a08f4", size = 424323 } +sdist = { url = "https://files.pythonhosted.org/packages/5d/57/dfb3c5c3f1bf5f5ef2e59a22dec4ff1f3d7408b55bfcefcfb0ea69ef21c6/h5py-3.14.0.tar.gz", hash = "sha256:2372116b2e0d5d3e5e705b7f663f7c8d96fa79a4052d250484ef91d24d6a08f4", size = 424323, upload-time = "2025-06-06T14:06:15.01Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/cd/3dd38cdb7cc9266dc4d85f27f0261680cb62f553f1523167ad7454e32b11/h5py-3.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:016e89d3be4c44f8d5e115fab60548e518ecd9efe9fa5c5324505a90773e6f03", size = 4324677 }, - { url = "https://files.pythonhosted.org/packages/b1/45/e1a754dc7cd465ba35e438e28557119221ac89b20aaebef48282654e3dc7/h5py-3.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1223b902ef0b5d90bcc8a4778218d6d6cd0f5561861611eda59fa6c52b922f4d", size = 4557272 }, - { url = "https://files.pythonhosted.org/packages/08/0c/5e6aaf221557314bc15ba0e0da92e40b24af97ab162076c8ae009320a42b/h5py-3.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c497600c0496548810047257e36360ff551df8b59156d3a4181072eed47d8ad", size = 4298002 }, - { url = "https://files.pythonhosted.org/packages/21/d4/d461649cafd5137088fb7f8e78fdc6621bb0c4ff2c090a389f68e8edc136/h5py-3.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:723a40ee6505bd354bfd26385f2dae7bbfa87655f4e61bab175a49d72ebfc06b", size = 4516618 }, - { url = "https://files.pythonhosted.org/packages/3f/19/c8bfe8543bfdd7ccfafd46d8cfd96fce53d6c33e9c7921f375530ee1d39a/h5py-3.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554ef0ced3571366d4d383427c00c966c360e178b5fb5ee5bb31a435c424db0c", size = 4708455 }, - { url = "https://files.pythonhosted.org/packages/86/f9/f00de11c82c88bfc1ef22633557bfba9e271e0cb3189ad704183fc4a2644/h5py-3.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cbd41f4e3761f150aa5b662df991868ca533872c95467216f2bec5fcad84882", size = 4929422 }, - { url = "https://files.pythonhosted.org/packages/0d/ce/3a21d87896bc7e3e9255e0ad5583ae31ae9e6b4b00e0bcb2a67e2b6acdbc/h5py-3.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8cbaf6910fa3983c46172666b0b8da7b7bd90d764399ca983236f2400436eeb", size = 4700675 }, - { url = "https://files.pythonhosted.org/packages/e7/ec/86f59025306dcc6deee5fda54d980d077075b8d9889aac80f158bd585f1b/h5py-3.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d90e6445ab7c146d7f7981b11895d70bc1dd91278a4f9f9028bc0c95e4a53f13", size = 4921632 }, + { url = "https://files.pythonhosted.org/packages/fa/cd/3dd38cdb7cc9266dc4d85f27f0261680cb62f553f1523167ad7454e32b11/h5py-3.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:016e89d3be4c44f8d5e115fab60548e518ecd9efe9fa5c5324505a90773e6f03", size = 4324677, upload-time = "2025-06-06T14:04:23.438Z" }, + { url = "https://files.pythonhosted.org/packages/b1/45/e1a754dc7cd465ba35e438e28557119221ac89b20aaebef48282654e3dc7/h5py-3.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1223b902ef0b5d90bcc8a4778218d6d6cd0f5561861611eda59fa6c52b922f4d", size = 4557272, upload-time = "2025-06-06T14:04:28.863Z" }, + { url = "https://files.pythonhosted.org/packages/08/0c/5e6aaf221557314bc15ba0e0da92e40b24af97ab162076c8ae009320a42b/h5py-3.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c497600c0496548810047257e36360ff551df8b59156d3a4181072eed47d8ad", size = 4298002, upload-time = "2025-06-06T14:04:47.106Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/d461649cafd5137088fb7f8e78fdc6621bb0c4ff2c090a389f68e8edc136/h5py-3.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:723a40ee6505bd354bfd26385f2dae7bbfa87655f4e61bab175a49d72ebfc06b", size = 4516618, upload-time = "2025-06-06T14:04:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/c8bfe8543bfdd7ccfafd46d8cfd96fce53d6c33e9c7921f375530ee1d39a/h5py-3.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554ef0ced3571366d4d383427c00c966c360e178b5fb5ee5bb31a435c424db0c", size = 4708455, upload-time = "2025-06-06T14:05:11.528Z" }, + { url = "https://files.pythonhosted.org/packages/86/f9/f00de11c82c88bfc1ef22633557bfba9e271e0cb3189ad704183fc4a2644/h5py-3.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cbd41f4e3761f150aa5b662df991868ca533872c95467216f2bec5fcad84882", size = 4929422, upload-time = "2025-06-06T14:05:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ce/3a21d87896bc7e3e9255e0ad5583ae31ae9e6b4b00e0bcb2a67e2b6acdbc/h5py-3.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8cbaf6910fa3983c46172666b0b8da7b7bd90d764399ca983236f2400436eeb", size = 4700675, upload-time = "2025-06-06T14:05:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ec/86f59025306dcc6deee5fda54d980d077075b8d9889aac80f158bd585f1b/h5py-3.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d90e6445ab7c146d7f7981b11895d70bc1dd91278a4f9f9028bc0c95e4a53f13", size = 4921632, upload-time = "2025-06-06T14:05:43.464Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/b423b57696514e05aa7bb06150ef96667d0e0006cc6de7ab52c71734ab51/h5py-3.14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:573c33ad056ac7c1ab6d567b6db9df3ffc401045e3f605736218f96c1e0490c6", size = 4326368, upload-time = "2025-06-06T14:06:00.782Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/e088f89f04fdbe57ddf9de377f857158d3daa38cf5d0fb20ef9bd489e313/h5py-3.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccbe17dc187c0c64178f1a10aa274ed3a57d055117588942b8a08793cc448216", size = 4559686, upload-time = "2025-06-06T14:06:07.416Z" }, ] [[package]] name = "hf-xet" version = "1.1.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/31/feeddfce1748c4a233ec1aa5b7396161c07ae1aa9b7bdbc9a72c3c7dd768/hf_xet-1.1.10.tar.gz", hash = "sha256:408aef343800a2102374a883f283ff29068055c111f003ff840733d3b715bb97", size = 487910 } +sdist = { url = "https://files.pythonhosted.org/packages/74/31/feeddfce1748c4a233ec1aa5b7396161c07ae1aa9b7bdbc9a72c3c7dd768/hf_xet-1.1.10.tar.gz", hash = "sha256:408aef343800a2102374a883f283ff29068055c111f003ff840733d3b715bb97", size = 487910, upload-time = "2025-09-12T20:10:27.12Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/a2/343e6d05de96908366bdc0081f2d8607d61200be2ac802769c4284cc65bd/hf_xet-1.1.10-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:686083aca1a6669bc85c21c0563551cbcdaa5cf7876a91f3d074a030b577231d", size = 2761466 }, - { url = "https://files.pythonhosted.org/packages/31/f9/6215f948ac8f17566ee27af6430ea72045e0418ce757260248b483f4183b/hf_xet-1.1.10-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:71081925383b66b24eedff3013f8e6bbd41215c3338be4b94ba75fd75b21513b", size = 2623807 }, - { url = "https://files.pythonhosted.org/packages/15/07/86397573efefff941e100367bbda0b21496ffcdb34db7ab51912994c32a2/hf_xet-1.1.10-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b6bceb6361c80c1cc42b5a7b4e3efd90e64630bcf11224dcac50ef30a47e435", size = 3186960 }, - { url = "https://files.pythonhosted.org/packages/01/a7/0b2e242b918cc30e1f91980f3c4b026ff2eedaf1e2ad96933bca164b2869/hf_xet-1.1.10-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eae7c1fc8a664e54753ffc235e11427ca61f4b0477d757cc4eb9ae374b69f09c", size = 3087167 }, - { url = "https://files.pythonhosted.org/packages/4a/25/3e32ab61cc7145b11eee9d745988e2f0f4fafda81b25980eebf97d8cff15/hf_xet-1.1.10-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0a0005fd08f002180f7a12d4e13b22be277725bc23ed0529f8add5c7a6309c06", size = 3248612 }, - { url = "https://files.pythonhosted.org/packages/2c/3d/ab7109e607ed321afaa690f557a9ada6d6d164ec852fd6bf9979665dc3d6/hf_xet-1.1.10-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f900481cf6e362a6c549c61ff77468bd59d6dd082f3170a36acfef2eb6a6793f", size = 3353360 }, - { url = "https://files.pythonhosted.org/packages/ee/0e/471f0a21db36e71a2f1752767ad77e92d8cde24e974e03d662931b1305ec/hf_xet-1.1.10-cp37-abi3-win_amd64.whl", hash = "sha256:5f54b19cc347c13235ae7ee98b330c26dd65ef1df47e5316ffb1e87713ca7045", size = 2804691 }, + { url = "https://files.pythonhosted.org/packages/f7/a2/343e6d05de96908366bdc0081f2d8607d61200be2ac802769c4284cc65bd/hf_xet-1.1.10-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:686083aca1a6669bc85c21c0563551cbcdaa5cf7876a91f3d074a030b577231d", size = 2761466, upload-time = "2025-09-12T20:10:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/31/f9/6215f948ac8f17566ee27af6430ea72045e0418ce757260248b483f4183b/hf_xet-1.1.10-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:71081925383b66b24eedff3013f8e6bbd41215c3338be4b94ba75fd75b21513b", size = 2623807, upload-time = "2025-09-12T20:10:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/86397573efefff941e100367bbda0b21496ffcdb34db7ab51912994c32a2/hf_xet-1.1.10-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b6bceb6361c80c1cc42b5a7b4e3efd90e64630bcf11224dcac50ef30a47e435", size = 3186960, upload-time = "2025-09-12T20:10:19.336Z" }, + { url = "https://files.pythonhosted.org/packages/01/a7/0b2e242b918cc30e1f91980f3c4b026ff2eedaf1e2ad96933bca164b2869/hf_xet-1.1.10-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eae7c1fc8a664e54753ffc235e11427ca61f4b0477d757cc4eb9ae374b69f09c", size = 3087167, upload-time = "2025-09-12T20:10:17.255Z" }, + { url = "https://files.pythonhosted.org/packages/4a/25/3e32ab61cc7145b11eee9d745988e2f0f4fafda81b25980eebf97d8cff15/hf_xet-1.1.10-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0a0005fd08f002180f7a12d4e13b22be277725bc23ed0529f8add5c7a6309c06", size = 3248612, upload-time = "2025-09-12T20:10:24.093Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3d/ab7109e607ed321afaa690f557a9ada6d6d164ec852fd6bf9979665dc3d6/hf_xet-1.1.10-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f900481cf6e362a6c549c61ff77468bd59d6dd082f3170a36acfef2eb6a6793f", size = 3353360, upload-time = "2025-09-12T20:10:25.563Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0e/471f0a21db36e71a2f1752767ad77e92d8cde24e974e03d662931b1305ec/hf_xet-1.1.10-cp37-abi3-win_amd64.whl", hash = "sha256:5f54b19cc347c13235ae7ee98b330c26dd65ef1df47e5316ffb1e87713ca7045", size = 2804691, upload-time = "2025-09-12T20:10:28.433Z" }, ] [[package]] @@ -790,27 +887,39 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/7e/a0a97de7c73671863ca6b3f61fa12518caf35db37825e43d63a70956738c/huggingface_hub-0.35.3.tar.gz", hash = "sha256:350932eaa5cc6a4747efae85126ee220e4ef1b54e29d31c3b45c5612ddf0b32a", size = 461798 } +sdist = { url = "https://files.pythonhosted.org/packages/10/7e/a0a97de7c73671863ca6b3f61fa12518caf35db37825e43d63a70956738c/huggingface_hub-0.35.3.tar.gz", hash = "sha256:350932eaa5cc6a4747efae85126ee220e4ef1b54e29d31c3b45c5612ddf0b32a", size = 461798, upload-time = "2025-09-29T14:29:58.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl", hash = "sha256:0e3a01829c19d86d03793e4577816fe3bdfc1602ac62c7fb220d593d351224ba", size = 564262 }, + { url = "https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl", hash = "sha256:0e3a01829c19d86d03793e4577816fe3bdfc1602ac62c7fb220d593d351224ba", size = 564262, upload-time = "2025-09-29T14:29:55.813Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] @@ -820,38 +929,67 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "jmespath" version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, +] + +[[package]] +name = "keras" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "absl-py", marker = "python_full_version < '3.10'" }, + { name = "h5py", marker = "python_full_version < '3.10'" }, + { name = "ml-dtypes", marker = "python_full_version < '3.10'" }, + { name = "namex", marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "optree", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "rich", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/fe/2946daf8477ae38a4b480c8889c72ede4f36eb28f9e1a27fc355cd633c3d/keras-3.10.0.tar.gz", hash = "sha256:6e9100bf66eaf6de4b7f288d34ef9bb8b5dcdd62f42c64cfd910226bb34ad2d2", size = 1040781, upload-time = "2025-05-19T22:58:30.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/e6/4179c461a5fc43e3736880f64dbdc9b1a5349649f0ae32ded927c0e3a227/keras-3.10.0-py3-none-any.whl", hash = "sha256:c095a6bf90cd50defadf73d4859ff794fad76b775357ef7bd1dbf96388dae7d3", size = 1380082, upload-time = "2025-05-19T22:58:28.938Z" }, ] [[package]] name = "keras" version = "3.11.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] dependencies = [ - { name = "absl-py" }, - { name = "h5py" }, - { name = "ml-dtypes" }, - { name = "namex" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "absl-py", marker = "python_full_version >= '3.10'" }, + { name = "h5py", marker = "python_full_version >= '3.10'" }, + { name = "ml-dtypes", marker = "python_full_version >= '3.10'" }, + { name = "namex", marker = "python_full_version >= '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "optree" }, - { name = "packaging" }, - { name = "rich" }, + { name = "optree", marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/89/646425fe9a46f9053430e1271f817c36041c6f33469950a3caafc3d2591e/keras-3.11.3.tar.gz", hash = "sha256:efda616835c31b7d916d72303ef9adec1257320bc9fd4b2b0138840fc65fb5b7", size = 1065906 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/89/646425fe9a46f9053430e1271f817c36041c6f33469950a3caafc3d2591e/keras-3.11.3.tar.gz", hash = "sha256:efda616835c31b7d916d72303ef9adec1257320bc9fd4b2b0138840fc65fb5b7", size = 1065906, upload-time = "2025-08-21T22:08:57.643Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/5b/4c778cc921ce4b864b238f63f8e3ff6e954ab19b80c9fa680593ad8093d4/keras-3.11.3-py3-none-any.whl", hash = "sha256:f484f050e05ee400455b05ec8c36ed35edc34de94256b6073f56cfe68f65491f", size = 1408438 }, + { url = "https://files.pythonhosted.org/packages/94/5b/4c778cc921ce4b864b238f63f8e3ff6e954ab19b80c9fa680593ad8093d4/keras-3.11.3-py3-none-any.whl", hash = "sha256:f484f050e05ee400455b05ec8c36ed35edc34de94256b6073f56cfe68f65491f", size = 1408438, upload-time = "2025-08-21T22:08:55.858Z" }, ] [[package]] @@ -861,9 +999,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lance-namespace-urllib3-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/c6/aec0d7752e15536564b50cf9a8926f0e5d7780aa3ab8ce8bca46daa55659/lance_namespace-0.5.2.tar.gz", hash = "sha256:566cc33091b5631793ab411f095d46c66391db0a62343cd6b4470265bb04d577", size = 10274 } +sdist = { url = "https://files.pythonhosted.org/packages/2b/c6/aec0d7752e15536564b50cf9a8926f0e5d7780aa3ab8ce8bca46daa55659/lance_namespace-0.5.2.tar.gz", hash = "sha256:566cc33091b5631793ab411f095d46c66391db0a62343cd6b4470265bb04d577", size = 10274, upload-time = "2026-02-20T03:14:31.777Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/3d/737c008d8fb2861e7ce260e2ffab0d5058eae41556181f80f1a1c3b52ef5/lance_namespace-0.5.2-py3-none-any.whl", hash = "sha256:6ccaf5649bf6ee6aa92eed9c535a114b7b4eb08e89f40426f58bc1466cbcffa3", size = 12087 }, + { url = "https://files.pythonhosted.org/packages/d6/3d/737c008d8fb2861e7ce260e2ffab0d5058eae41556181f80f1a1c3b52ef5/lance_namespace-0.5.2-py3-none-any.whl", hash = "sha256:6ccaf5649bf6ee6aa92eed9c535a114b7b4eb08e89f40426f58bc1466cbcffa3", size = 12087, upload-time = "2026-02-20T03:14:35.261Z" }, ] [[package]] @@ -874,138 +1012,175 @@ dependencies = [ { name = "pydantic" }, { name = "python-dateutil" }, { name = "typing-extensions" }, - { name = "urllib3" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/64/51622c93ec8c164483c83b68764e5e76e52286c0137a8247bc6a7fac25f4/lance_namespace_urllib3_client-0.5.2.tar.gz", hash = "sha256:8a3a238006e6eabc01fc9d385ac3de22ba933aef0ae8987558f3c3199c9b3799", size = 172578 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/64/51622c93ec8c164483c83b68764e5e76e52286c0137a8247bc6a7fac25f4/lance_namespace_urllib3_client-0.5.2.tar.gz", hash = "sha256:8a3a238006e6eabc01fc9d385ac3de22ba933aef0ae8987558f3c3199c9b3799", size = 172578, upload-time = "2026-02-20T03:14:33.031Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/10/f86d994498b37f7f35d0b8c2f7626a16fe4cb1949b518c1e5d5052ecf95f/lance_namespace_urllib3_client-0.5.2-py3-none-any.whl", hash = "sha256:83cefb6fd6e5df0b99b5e866ee3d46300d375b75e8af32c27bc16fbf7c1a5978", size = 300351 }, + { url = "https://files.pythonhosted.org/packages/2a/10/f86d994498b37f7f35d0b8c2f7626a16fe4cb1949b518c1e5d5052ecf95f/lance_namespace_urllib3_client-0.5.2-py3-none-any.whl", hash = "sha256:83cefb6fd6e5df0b99b5e866ee3d46300d375b75e8af32c27bc16fbf7c1a5978", size = 300351, upload-time = "2026-02-20T03:14:34.236Z" }, ] [[package]] name = "libclang" version = "18.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/5c/ca35e19a4f142adffa27e3d652196b7362fa612243e2b916845d801454fc/libclang-18.1.1.tar.gz", hash = "sha256:a1214966d08d73d971287fc3ead8dfaf82eb07fb197680d8b3859dbbbbf78250", size = 39612 } +sdist = { url = "https://files.pythonhosted.org/packages/6e/5c/ca35e19a4f142adffa27e3d652196b7362fa612243e2b916845d801454fc/libclang-18.1.1.tar.gz", hash = "sha256:a1214966d08d73d971287fc3ead8dfaf82eb07fb197680d8b3859dbbbbf78250", size = 39612, upload-time = "2024-03-17T16:04:37.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/fc/716c1e62e512ef1c160e7984a73a5fc7df45166f2ff3f254e71c58076f7c/libclang-18.1.1-py2.py3-none-manylinux2010_x86_64.whl", hash = "sha256:c533091d8a3bbf7460a00cb6c1a71da93bffe148f172c7d03b1c31fbf8aa2a0b", size = 24515943 }, - { url = "https://files.pythonhosted.org/packages/3c/3d/f0ac1150280d8d20d059608cf2d5ff61b7c3b7f7bcf9c0f425ab92df769a/libclang-18.1.1-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:54dda940a4a0491a9d1532bf071ea3ef26e6dbaf03b5000ed94dd7174e8f9592", size = 23784972 }, - { url = "https://files.pythonhosted.org/packages/fe/2f/d920822c2b1ce9326a4c78c0c2b4aa3fde610c7ee9f631b600acb5376c26/libclang-18.1.1-py2.py3-none-manylinux2014_armv7l.whl", hash = "sha256:cf4a99b05376513717ab5d82a0db832c56ccea4fd61a69dbb7bccf2dfb207dbe", size = 20259606 }, - { url = "https://files.pythonhosted.org/packages/2d/c2/de1db8c6d413597076a4259cea409b83459b2db997c003578affdd32bf66/libclang-18.1.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:69f8eb8f65c279e765ffd28aaa7e9e364c776c17618af8bff22a8df58677ff4f", size = 24921494 }, + { url = "https://files.pythonhosted.org/packages/1d/fc/716c1e62e512ef1c160e7984a73a5fc7df45166f2ff3f254e71c58076f7c/libclang-18.1.1-py2.py3-none-manylinux2010_x86_64.whl", hash = "sha256:c533091d8a3bbf7460a00cb6c1a71da93bffe148f172c7d03b1c31fbf8aa2a0b", size = 24515943, upload-time = "2024-03-17T16:03:45.942Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3d/f0ac1150280d8d20d059608cf2d5ff61b7c3b7f7bcf9c0f425ab92df769a/libclang-18.1.1-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:54dda940a4a0491a9d1532bf071ea3ef26e6dbaf03b5000ed94dd7174e8f9592", size = 23784972, upload-time = "2024-03-17T16:12:47.677Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2f/d920822c2b1ce9326a4c78c0c2b4aa3fde610c7ee9f631b600acb5376c26/libclang-18.1.1-py2.py3-none-manylinux2014_armv7l.whl", hash = "sha256:cf4a99b05376513717ab5d82a0db832c56ccea4fd61a69dbb7bccf2dfb207dbe", size = 20259606, upload-time = "2024-03-17T16:17:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c2/de1db8c6d413597076a4259cea409b83459b2db997c003578affdd32bf66/libclang-18.1.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:69f8eb8f65c279e765ffd28aaa7e9e364c776c17618af8bff22a8df58677ff4f", size = 24921494, upload-time = "2024-03-17T16:14:20.132Z" }, ] [[package]] name = "markdown" version = "3.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585 } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441 }, + { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631 }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057 }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050 }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681 }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705 }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524 }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282 }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745 }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571 }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056 }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932 }, - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, + { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -1013,50 +1188,55 @@ name = "ml-dtypes" version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/a7/aad060393123cfb383956dca68402aff3db1e1caffd5764887ed5153f41b/ml_dtypes-0.5.3.tar.gz", hash = "sha256:95ce33057ba4d05df50b1f3cfefab22e351868a843b3b15a46c65836283670c9", size = 692316 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/bb/1f32124ab6d3a279ea39202fe098aea95b2d81ef0ce1d48612b6bf715e82/ml_dtypes-0.5.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0a1d68a7cb53e3f640b2b6a34d12c0542da3dd935e560fdf463c0c77f339fc20", size = 667409 }, - { url = "https://files.pythonhosted.org/packages/1d/ac/e002d12ae19136e25bb41c7d14d7e1a1b08f3c0e99a44455ff6339796507/ml_dtypes-0.5.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cd5a6c711b5350f3cbc2ac28def81cd1c580075ccb7955e61e9d8f4bfd40d24", size = 4960702 }, - { url = "https://files.pythonhosted.org/packages/dd/12/79e9954e6b3255a4b1becb191a922d6e2e94d03d16a06341ae9261963ae8/ml_dtypes-0.5.3-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdcf26c2dbc926b8a35ec8cbfad7eff1a8bd8239e12478caca83a1fc2c400dc2", size = 4933471 }, - { url = "https://files.pythonhosted.org/packages/d5/aa/d1eff619e83cd1ddf6b561d8240063d978e5d887d1861ba09ef01778ec3a/ml_dtypes-0.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:aecbd7c5272c82e54d5b99d8435fd10915d1bc704b7df15e4d9ca8dc3902be61", size = 206330 }, - { url = "https://files.pythonhosted.org/packages/af/f1/720cb1409b5d0c05cff9040c0e9fba73fa4c67897d33babf905d5d46a070/ml_dtypes-0.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4a177b882667c69422402df6ed5c3428ce07ac2c1f844d8a1314944651439458", size = 667412 }, - { url = "https://files.pythonhosted.org/packages/6a/d5/05861ede5d299f6599f86e6bc1291714e2116d96df003cfe23cc54bcc568/ml_dtypes-0.5.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9849ce7267444c0a717c80c6900997de4f36e2815ce34ac560a3edb2d9a64cd2", size = 4964606 }, - { url = "https://files.pythonhosted.org/packages/db/dc/72992b68de367741bfab8df3b3fe7c29f982b7279d341aa5bf3e7ef737ea/ml_dtypes-0.5.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f5ae0309d9f888fd825c2e9d0241102fadaca81d888f26f845bc8c13c1e4ee", size = 4938435 }, - { url = "https://files.pythonhosted.org/packages/81/1c/d27a930bca31fb07d975a2d7eaf3404f9388114463b9f15032813c98f893/ml_dtypes-0.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:58e39349d820b5702bb6f94ea0cb2dc8ec62ee81c0267d9622067d8333596a46", size = 206334 }, - { url = "https://files.pythonhosted.org/packages/1a/d8/6922499effa616012cb8dc445280f66d100a7ff39b35c864cfca019b3f89/ml_dtypes-0.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:66c2756ae6cfd7f5224e355c893cfd617fa2f747b8bbd8996152cbdebad9a184", size = 157584 }, - { url = "https://files.pythonhosted.org/packages/0d/eb/bc07c88a6ab002b4635e44585d80fa0b350603f11a2097c9d1bfacc03357/ml_dtypes-0.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:156418abeeda48ea4797db6776db3c5bdab9ac7be197c1233771e0880c304057", size = 663864 }, - { url = "https://files.pythonhosted.org/packages/cf/89/11af9b0f21b99e6386b6581ab40fb38d03225f9de5f55cf52097047e2826/ml_dtypes-0.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1db60c154989af253f6c4a34e8a540c2c9dce4d770784d426945e09908fbb177", size = 4951313 }, - { url = "https://files.pythonhosted.org/packages/d8/a9/b98b86426c24900b0c754aad006dce2863df7ce0bb2bcc2c02f9cc7e8489/ml_dtypes-0.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b255acada256d1fa8c35ed07b5f6d18bc21d1556f842fbc2d5718aea2cd9e55", size = 4928805 }, - { url = "https://files.pythonhosted.org/packages/50/c1/85e6be4fc09c6175f36fb05a45917837f30af9a5146a5151cb3a3f0f9e09/ml_dtypes-0.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:da65e5fd3eea434ccb8984c3624bc234ddcc0d9f4c81864af611aaebcc08a50e", size = 208182 }, - { url = "https://files.pythonhosted.org/packages/9e/17/cf5326d6867be057f232d0610de1458f70a8ce7b6290e4b4a277ea62b4cd/ml_dtypes-0.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:8bb9cd1ce63096567f5f42851f5843b5a0ea11511e50039a7649619abfb4ba6d", size = 161560 }, - { url = "https://files.pythonhosted.org/packages/2d/87/1bcc98a66de7b2455dfb292f271452cac9edc4e870796e0d87033524d790/ml_dtypes-0.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5103856a225465371fe119f2fef737402b705b810bd95ad5f348e6e1a6ae21af", size = 663781 }, - { url = "https://files.pythonhosted.org/packages/fd/2c/bd2a79ba7c759ee192b5601b675b180a3fd6ccf48ffa27fe1782d280f1a7/ml_dtypes-0.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cae435a68861660af81fa3c5af16b70ca11a17275c5b662d9c6f58294e0f113", size = 4956217 }, - { url = "https://files.pythonhosted.org/packages/14/f3/091ba84e5395d7fe5b30c081a44dec881cd84b408db1763ee50768b2ab63/ml_dtypes-0.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6936283b56d74fbec431ca57ce58a90a908fdbd14d4e2d22eea6d72bb208a7b7", size = 4933109 }, - { url = "https://files.pythonhosted.org/packages/bc/24/054036dbe32c43295382c90a1363241684c4d6aaa1ecc3df26bd0c8d5053/ml_dtypes-0.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:d0f730a17cf4f343b2c7ad50cee3bd19e969e793d2be6ed911f43086460096e4", size = 208187 }, - { url = "https://files.pythonhosted.org/packages/a6/3d/7dc3ec6794a4a9004c765e0c341e32355840b698f73fd2daff46f128afc1/ml_dtypes-0.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2db74788fc01914a3c7f7da0763427280adfc9cd377e9604b6b64eb8097284bd", size = 161559 }, - { url = "https://files.pythonhosted.org/packages/12/91/e6c7a0d67a152b9330445f9f0cf8ae6eee9b83f990b8c57fe74631e42a90/ml_dtypes-0.5.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93c36a08a6d158db44f2eb9ce3258e53f24a9a4a695325a689494f0fdbc71770", size = 689321 }, - { url = "https://files.pythonhosted.org/packages/9e/6c/b7b94b84a104a5be1883305b87d4c6bd6ae781504474b4cca067cb2340ec/ml_dtypes-0.5.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e44a3761f64bc009d71ddb6d6c71008ba21b53ab6ee588dadab65e2fa79eafc", size = 5274495 }, - { url = "https://files.pythonhosted.org/packages/5b/38/6266604dffb43378055394ea110570cf261a49876fc48f548dfe876f34cc/ml_dtypes-0.5.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdf40d2aaabd3913dec11840f0d0ebb1b93134f99af6a0a4fd88ffe924928ab4", size = 5285422 }, - { url = "https://files.pythonhosted.org/packages/7c/88/8612ff177d043a474b9408f0382605d881eeb4125ba89d4d4b3286573a83/ml_dtypes-0.5.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:aec640bd94c4c85c0d11e2733bd13cbb10438fb004852996ec0efbc6cacdaf70", size = 661182 }, - { url = "https://files.pythonhosted.org/packages/6f/2b/0569a5e88b29240d373e835107c94ae9256fb2191d3156b43b2601859eff/ml_dtypes-0.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bda32ce212baa724e03c68771e5c69f39e584ea426bfe1a701cb01508ffc7035", size = 4956187 }, - { url = "https://files.pythonhosted.org/packages/51/66/273c2a06ae44562b104b61e6b14444da00061fd87652506579d7eb2c40b1/ml_dtypes-0.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c205cac07d24a29840c163d6469f61069ce4b065518519216297fc2f261f8db9", size = 4930911 }, - { url = "https://files.pythonhosted.org/packages/93/ab/606be3e87dc0821bd360c8c1ee46108025c31a4f96942b63907bb441b87d/ml_dtypes-0.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:cd7c0bb22d4ff86d65ad61b5dd246812e8993fbc95b558553624c33e8b6903ea", size = 216664 }, - { url = "https://files.pythonhosted.org/packages/30/a2/e900690ca47d01dffffd66375c5de8c4f8ced0f1ef809ccd3b25b3e6b8fa/ml_dtypes-0.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:9d55ea7f7baf2aed61bf1872116cefc9d0c3693b45cae3916897ee27ef4b835e", size = 160203 }, - { url = "https://files.pythonhosted.org/packages/53/21/783dfb51f40d2660afeb9bccf3612b99f6a803d980d2a09132b0f9d216ab/ml_dtypes-0.5.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:e12e29764a0e66a7a31e9b8bf1de5cc0423ea72979f45909acd4292de834ccd3", size = 689324 }, - { url = "https://files.pythonhosted.org/packages/09/f7/a82d249c711abf411ac027b7163f285487f5e615c3e0716c61033ce996ab/ml_dtypes-0.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19f6c3a4f635c2fc9e2aa7d91416bd7a3d649b48350c51f7f715a09370a90d93", size = 5275917 }, - { url = "https://files.pythonhosted.org/packages/7f/3c/541c4b30815ab90ebfbb51df15d0b4254f2f9f1e2b4907ab229300d5e6f2/ml_dtypes-0.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ab039ffb40f3dc0aeeeba84fd6c3452781b5e15bef72e2d10bcb33e4bbffc39", size = 5285284 }, +sdist = { url = "https://files.pythonhosted.org/packages/78/a7/aad060393123cfb383956dca68402aff3db1e1caffd5764887ed5153f41b/ml_dtypes-0.5.3.tar.gz", hash = "sha256:95ce33057ba4d05df50b1f3cfefab22e351868a843b3b15a46c65836283670c9", size = 692316, upload-time = "2025-07-29T18:39:19.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/bb/1f32124ab6d3a279ea39202fe098aea95b2d81ef0ce1d48612b6bf715e82/ml_dtypes-0.5.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0a1d68a7cb53e3f640b2b6a34d12c0542da3dd935e560fdf463c0c77f339fc20", size = 667409, upload-time = "2025-07-29T18:38:17.321Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ac/e002d12ae19136e25bb41c7d14d7e1a1b08f3c0e99a44455ff6339796507/ml_dtypes-0.5.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cd5a6c711b5350f3cbc2ac28def81cd1c580075ccb7955e61e9d8f4bfd40d24", size = 4960702, upload-time = "2025-07-29T18:38:19.616Z" }, + { url = "https://files.pythonhosted.org/packages/dd/12/79e9954e6b3255a4b1becb191a922d6e2e94d03d16a06341ae9261963ae8/ml_dtypes-0.5.3-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdcf26c2dbc926b8a35ec8cbfad7eff1a8bd8239e12478caca83a1fc2c400dc2", size = 4933471, upload-time = "2025-07-29T18:38:21.809Z" }, + { url = "https://files.pythonhosted.org/packages/d5/aa/d1eff619e83cd1ddf6b561d8240063d978e5d887d1861ba09ef01778ec3a/ml_dtypes-0.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:aecbd7c5272c82e54d5b99d8435fd10915d1bc704b7df15e4d9ca8dc3902be61", size = 206330, upload-time = "2025-07-29T18:38:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/af/f1/720cb1409b5d0c05cff9040c0e9fba73fa4c67897d33babf905d5d46a070/ml_dtypes-0.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4a177b882667c69422402df6ed5c3428ce07ac2c1f844d8a1314944651439458", size = 667412, upload-time = "2025-07-29T18:38:25.275Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d5/05861ede5d299f6599f86e6bc1291714e2116d96df003cfe23cc54bcc568/ml_dtypes-0.5.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9849ce7267444c0a717c80c6900997de4f36e2815ce34ac560a3edb2d9a64cd2", size = 4964606, upload-time = "2025-07-29T18:38:27.045Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/72992b68de367741bfab8df3b3fe7c29f982b7279d341aa5bf3e7ef737ea/ml_dtypes-0.5.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f5ae0309d9f888fd825c2e9d0241102fadaca81d888f26f845bc8c13c1e4ee", size = 4938435, upload-time = "2025-07-29T18:38:29.193Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/d27a930bca31fb07d975a2d7eaf3404f9388114463b9f15032813c98f893/ml_dtypes-0.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:58e39349d820b5702bb6f94ea0cb2dc8ec62ee81c0267d9622067d8333596a46", size = 206334, upload-time = "2025-07-29T18:38:30.687Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d8/6922499effa616012cb8dc445280f66d100a7ff39b35c864cfca019b3f89/ml_dtypes-0.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:66c2756ae6cfd7f5224e355c893cfd617fa2f747b8bbd8996152cbdebad9a184", size = 157584, upload-time = "2025-07-29T18:38:32.187Z" }, + { url = "https://files.pythonhosted.org/packages/0d/eb/bc07c88a6ab002b4635e44585d80fa0b350603f11a2097c9d1bfacc03357/ml_dtypes-0.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:156418abeeda48ea4797db6776db3c5bdab9ac7be197c1233771e0880c304057", size = 663864, upload-time = "2025-07-29T18:38:33.777Z" }, + { url = "https://files.pythonhosted.org/packages/cf/89/11af9b0f21b99e6386b6581ab40fb38d03225f9de5f55cf52097047e2826/ml_dtypes-0.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1db60c154989af253f6c4a34e8a540c2c9dce4d770784d426945e09908fbb177", size = 4951313, upload-time = "2025-07-29T18:38:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a9/b98b86426c24900b0c754aad006dce2863df7ce0bb2bcc2c02f9cc7e8489/ml_dtypes-0.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b255acada256d1fa8c35ed07b5f6d18bc21d1556f842fbc2d5718aea2cd9e55", size = 4928805, upload-time = "2025-07-29T18:38:38.29Z" }, + { url = "https://files.pythonhosted.org/packages/50/c1/85e6be4fc09c6175f36fb05a45917837f30af9a5146a5151cb3a3f0f9e09/ml_dtypes-0.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:da65e5fd3eea434ccb8984c3624bc234ddcc0d9f4c81864af611aaebcc08a50e", size = 208182, upload-time = "2025-07-29T18:38:39.72Z" }, + { url = "https://files.pythonhosted.org/packages/9e/17/cf5326d6867be057f232d0610de1458f70a8ce7b6290e4b4a277ea62b4cd/ml_dtypes-0.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:8bb9cd1ce63096567f5f42851f5843b5a0ea11511e50039a7649619abfb4ba6d", size = 161560, upload-time = "2025-07-29T18:38:41.072Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/1bcc98a66de7b2455dfb292f271452cac9edc4e870796e0d87033524d790/ml_dtypes-0.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5103856a225465371fe119f2fef737402b705b810bd95ad5f348e6e1a6ae21af", size = 663781, upload-time = "2025-07-29T18:38:42.984Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2c/bd2a79ba7c759ee192b5601b675b180a3fd6ccf48ffa27fe1782d280f1a7/ml_dtypes-0.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cae435a68861660af81fa3c5af16b70ca11a17275c5b662d9c6f58294e0f113", size = 4956217, upload-time = "2025-07-29T18:38:44.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/f3/091ba84e5395d7fe5b30c081a44dec881cd84b408db1763ee50768b2ab63/ml_dtypes-0.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6936283b56d74fbec431ca57ce58a90a908fdbd14d4e2d22eea6d72bb208a7b7", size = 4933109, upload-time = "2025-07-29T18:38:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/bc/24/054036dbe32c43295382c90a1363241684c4d6aaa1ecc3df26bd0c8d5053/ml_dtypes-0.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:d0f730a17cf4f343b2c7ad50cee3bd19e969e793d2be6ed911f43086460096e4", size = 208187, upload-time = "2025-07-29T18:38:48.24Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/7dc3ec6794a4a9004c765e0c341e32355840b698f73fd2daff46f128afc1/ml_dtypes-0.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2db74788fc01914a3c7f7da0763427280adfc9cd377e9604b6b64eb8097284bd", size = 161559, upload-time = "2025-07-29T18:38:50.493Z" }, + { url = "https://files.pythonhosted.org/packages/12/91/e6c7a0d67a152b9330445f9f0cf8ae6eee9b83f990b8c57fe74631e42a90/ml_dtypes-0.5.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93c36a08a6d158db44f2eb9ce3258e53f24a9a4a695325a689494f0fdbc71770", size = 689321, upload-time = "2025-07-29T18:38:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6c/b7b94b84a104a5be1883305b87d4c6bd6ae781504474b4cca067cb2340ec/ml_dtypes-0.5.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e44a3761f64bc009d71ddb6d6c71008ba21b53ab6ee588dadab65e2fa79eafc", size = 5274495, upload-time = "2025-07-29T18:38:53.797Z" }, + { url = "https://files.pythonhosted.org/packages/5b/38/6266604dffb43378055394ea110570cf261a49876fc48f548dfe876f34cc/ml_dtypes-0.5.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdf40d2aaabd3913dec11840f0d0ebb1b93134f99af6a0a4fd88ffe924928ab4", size = 5285422, upload-time = "2025-07-29T18:38:56.603Z" }, + { url = "https://files.pythonhosted.org/packages/7c/88/8612ff177d043a474b9408f0382605d881eeb4125ba89d4d4b3286573a83/ml_dtypes-0.5.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:aec640bd94c4c85c0d11e2733bd13cbb10438fb004852996ec0efbc6cacdaf70", size = 661182, upload-time = "2025-07-29T18:38:58.414Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2b/0569a5e88b29240d373e835107c94ae9256fb2191d3156b43b2601859eff/ml_dtypes-0.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bda32ce212baa724e03c68771e5c69f39e584ea426bfe1a701cb01508ffc7035", size = 4956187, upload-time = "2025-07-29T18:39:00.611Z" }, + { url = "https://files.pythonhosted.org/packages/51/66/273c2a06ae44562b104b61e6b14444da00061fd87652506579d7eb2c40b1/ml_dtypes-0.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c205cac07d24a29840c163d6469f61069ce4b065518519216297fc2f261f8db9", size = 4930911, upload-time = "2025-07-29T18:39:02.405Z" }, + { url = "https://files.pythonhosted.org/packages/93/ab/606be3e87dc0821bd360c8c1ee46108025c31a4f96942b63907bb441b87d/ml_dtypes-0.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:cd7c0bb22d4ff86d65ad61b5dd246812e8993fbc95b558553624c33e8b6903ea", size = 216664, upload-time = "2025-07-29T18:39:03.927Z" }, + { url = "https://files.pythonhosted.org/packages/30/a2/e900690ca47d01dffffd66375c5de8c4f8ced0f1ef809ccd3b25b3e6b8fa/ml_dtypes-0.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:9d55ea7f7baf2aed61bf1872116cefc9d0c3693b45cae3916897ee27ef4b835e", size = 160203, upload-time = "2025-07-29T18:39:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/53/21/783dfb51f40d2660afeb9bccf3612b99f6a803d980d2a09132b0f9d216ab/ml_dtypes-0.5.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:e12e29764a0e66a7a31e9b8bf1de5cc0423ea72979f45909acd4292de834ccd3", size = 689324, upload-time = "2025-07-29T18:39:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/09/f7/a82d249c711abf411ac027b7163f285487f5e615c3e0716c61033ce996ab/ml_dtypes-0.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19f6c3a4f635c2fc9e2aa7d91416bd7a3d649b48350c51f7f715a09370a90d93", size = 5275917, upload-time = "2025-07-29T18:39:09.339Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3c/541c4b30815ab90ebfbb51df15d0b4254f2f9f1e2b4907ab229300d5e6f2/ml_dtypes-0.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ab039ffb40f3dc0aeeeba84fd6c3452781b5e15bef72e2d10bcb33e4bbffc39", size = 5285284, upload-time = "2025-07-29T18:39:11.532Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/c61af51173083bbf2a3b0f1a1a01d50ef1830436880027433d1b75271083/ml_dtypes-0.5.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5ee72568d46b9533ad54f78b1e1f3067c0534c5065120ea8ecc6f210d22748b3", size = 663552, upload-time = "2025-07-29T18:39:13.102Z" }, + { url = "https://files.pythonhosted.org/packages/61/0e/a628f2aefd719745e8a13492375a55cedea77c0cfc917b1ce11bde435c68/ml_dtypes-0.5.3-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01de48de4537dc3c46e684b969a40ec36594e7eeb7c69e9a093e7239f030a28a", size = 4952704, upload-time = "2025-07-29T18:39:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2e/5ba92f1f99d1f5f62bffec614a5b8161e55c3961257c902fa26dbe909baa/ml_dtypes-0.5.3-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b1a6e231b0770f2894910f1dce6d2f31d65884dbf7668f9b08d73623cdca909", size = 4923538, upload-time = "2025-07-29T18:39:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/70/3b/f801c69027866ea6e387224551185fedef62ad8e2e71181ec0d9dda905f7/ml_dtypes-0.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:a4f39b9bf6555fab9bfb536cf5fdd1c1c727e8d22312078702e9ff005354b37f", size = 206567, upload-time = "2025-07-29T18:39:18.047Z" }, ] [[package]] name = "mpmath" version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 }, + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] [[package]] @@ -1066,99 +1246,117 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6b/86f353088c1358e76fd30b0146947fddecee812703b604ee901e85cd2a80/multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f", size = 77054 }, - { url = "https://files.pythonhosted.org/packages/19/5d/c01dc3d3788bb877bd7f5753ea6eb23c1beeca8044902a8f5bfb54430f63/multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb", size = 44914 }, - { url = "https://files.pythonhosted.org/packages/46/44/964dae19ea42f7d3e166474d8205f14bb811020e28bc423d46123ddda763/multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495", size = 44601 }, - { url = "https://files.pythonhosted.org/packages/31/20/0616348a1dfb36cb2ab33fc9521de1f27235a397bf3f59338e583afadd17/multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8", size = 224821 }, - { url = "https://files.pythonhosted.org/packages/14/26/5d8923c69c110ff51861af05bd27ca6783011b96725d59ccae6d9daeb627/multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7", size = 242608 }, - { url = "https://files.pythonhosted.org/packages/5c/cc/e2ad3ba9459aa34fa65cf1f82a5c4a820a2ce615aacfb5143b8817f76504/multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796", size = 222324 }, - { url = "https://files.pythonhosted.org/packages/19/db/4ed0f65701afbc2cb0c140d2d02928bb0fe38dd044af76e58ad7c54fd21f/multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db", size = 253234 }, - { url = "https://files.pythonhosted.org/packages/94/c1/5160c9813269e39ae14b73debb907bfaaa1beee1762da8c4fb95df4764ed/multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0", size = 251613 }, - { url = "https://files.pythonhosted.org/packages/05/a9/48d1bd111fc2f8fb98b2ed7f9a115c55a9355358432a19f53c0b74d8425d/multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877", size = 241649 }, - { url = "https://files.pythonhosted.org/packages/85/2a/f7d743df0019408768af8a70d2037546a2be7b81fbb65f040d76caafd4c5/multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace", size = 239238 }, - { url = "https://files.pythonhosted.org/packages/cb/b8/4f4bb13323c2d647323f7919201493cf48ebe7ded971717bfb0f1a79b6bf/multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6", size = 233517 }, - { url = "https://files.pythonhosted.org/packages/33/29/4293c26029ebfbba4f574febd2ed01b6f619cfa0d2e344217d53eef34192/multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb", size = 243122 }, - { url = "https://files.pythonhosted.org/packages/20/60/a1c53628168aa22447bfde3a8730096ac28086704a0d8c590f3b63388d0c/multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb", size = 248992 }, - { url = "https://files.pythonhosted.org/packages/a3/3b/55443a0c372f33cae5d9ec37a6a973802884fa0ab3586659b197cf8cc5e9/multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987", size = 243708 }, - { url = "https://files.pythonhosted.org/packages/7c/60/a18c6900086769312560b2626b18e8cca22d9e85b1186ba77f4755b11266/multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f", size = 237498 }, - { url = "https://files.pythonhosted.org/packages/11/3d/8bdd8bcaff2951ce2affccca107a404925a2beafedd5aef0b5e4a71120a6/multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f", size = 41415 }, - { url = "https://files.pythonhosted.org/packages/c0/53/cab1ad80356a4cd1b685a254b680167059b433b573e53872fab245e9fc95/multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0", size = 46046 }, - { url = "https://files.pythonhosted.org/packages/cf/9a/874212b6f5c1c2d870d0a7adc5bb4cfe9b0624fa15cdf5cf757c0f5087ae/multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729", size = 43147 }, - { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472 }, - { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634 }, - { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282 }, - { url = "https://files.pythonhosted.org/packages/03/35/436a5da8702b06866189b69f655ffdb8f70796252a8772a77815f1812679/multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded", size = 229696 }, - { url = "https://files.pythonhosted.org/packages/b6/0e/915160be8fecf1fca35f790c08fb74ca684d752fcba62c11daaf3d92c216/multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683", size = 246665 }, - { url = "https://files.pythonhosted.org/packages/08/ee/2f464330acd83f77dcc346f0b1a0eaae10230291450887f96b204b8ac4d3/multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a", size = 225485 }, - { url = "https://files.pythonhosted.org/packages/71/cc/9a117f828b4d7fbaec6adeed2204f211e9caf0a012692a1ee32169f846ae/multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9", size = 257318 }, - { url = "https://files.pythonhosted.org/packages/25/77/62752d3dbd70e27fdd68e86626c1ae6bccfebe2bb1f84ae226363e112f5a/multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50", size = 254689 }, - { url = "https://files.pythonhosted.org/packages/00/6e/fac58b1072a6fc59af5e7acb245e8754d3e1f97f4f808a6559951f72a0d4/multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52", size = 246709 }, - { url = "https://files.pythonhosted.org/packages/01/ef/4698d6842ef5e797c6db7744b0081e36fb5de3d00002cc4c58071097fac3/multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6", size = 243185 }, - { url = "https://files.pythonhosted.org/packages/aa/c9/d82e95ae1d6e4ef396934e9b0e942dfc428775f9554acf04393cce66b157/multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e", size = 237838 }, - { url = "https://files.pythonhosted.org/packages/57/cf/f94af5c36baaa75d44fab9f02e2a6bcfa0cd90acb44d4976a80960759dbc/multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3", size = 246368 }, - { url = "https://files.pythonhosted.org/packages/4a/fe/29f23460c3d995f6a4b678cb2e9730e7277231b981f0b234702f0177818a/multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c", size = 253339 }, - { url = "https://files.pythonhosted.org/packages/29/b6/fd59449204426187b82bf8a75f629310f68c6adc9559dc922d5abe34797b/multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b", size = 246933 }, - { url = "https://files.pythonhosted.org/packages/19/52/d5d6b344f176a5ac3606f7a61fb44dc746e04550e1a13834dff722b8d7d6/multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f", size = 242225 }, - { url = "https://files.pythonhosted.org/packages/ec/d3/5b2281ed89ff4d5318d82478a2a2450fcdfc3300da48ff15c1778280ad26/multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2", size = 41306 }, - { url = "https://files.pythonhosted.org/packages/74/7d/36b045c23a1ab98507aefd44fd8b264ee1dd5e5010543c6fccf82141ccef/multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e", size = 46029 }, - { url = "https://files.pythonhosted.org/packages/0f/5e/553d67d24432c5cd52b49047f2d248821843743ee6d29a704594f656d182/multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf", size = 43017 }, - { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516 }, - { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394 }, - { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591 }, - { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215 }, - { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299 }, - { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357 }, - { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369 }, - { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341 }, - { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100 }, - { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584 }, - { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018 }, - { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477 }, - { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575 }, - { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649 }, - { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505 }, - { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888 }, - { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072 }, - { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222 }, - { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848 }, - { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060 }, - { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269 }, - { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158 }, - { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076 }, - { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694 }, - { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350 }, - { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250 }, - { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900 }, - { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355 }, - { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061 }, - { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675 }, - { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247 }, - { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960 }, - { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078 }, - { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708 }, - { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912 }, - { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076 }, - { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812 }, - { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313 }, - { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777 }, - { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321 }, - { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954 }, - { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612 }, - { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528 }, - { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329 }, - { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928 }, - { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228 }, - { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869 }, - { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446 }, - { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299 }, - { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926 }, - { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383 }, - { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775 }, - { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100 }, - { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501 }, - { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313 }, +sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/6b/86f353088c1358e76fd30b0146947fddecee812703b604ee901e85cd2a80/multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f", size = 77054, upload-time = "2025-08-11T12:06:02.99Z" }, + { url = "https://files.pythonhosted.org/packages/19/5d/c01dc3d3788bb877bd7f5753ea6eb23c1beeca8044902a8f5bfb54430f63/multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb", size = 44914, upload-time = "2025-08-11T12:06:05.264Z" }, + { url = "https://files.pythonhosted.org/packages/46/44/964dae19ea42f7d3e166474d8205f14bb811020e28bc423d46123ddda763/multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495", size = 44601, upload-time = "2025-08-11T12:06:06.627Z" }, + { url = "https://files.pythonhosted.org/packages/31/20/0616348a1dfb36cb2ab33fc9521de1f27235a397bf3f59338e583afadd17/multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8", size = 224821, upload-time = "2025-08-11T12:06:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/14/26/5d8923c69c110ff51861af05bd27ca6783011b96725d59ccae6d9daeb627/multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7", size = 242608, upload-time = "2025-08-11T12:06:09.697Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/e2ad3ba9459aa34fa65cf1f82a5c4a820a2ce615aacfb5143b8817f76504/multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796", size = 222324, upload-time = "2025-08-11T12:06:10.905Z" }, + { url = "https://files.pythonhosted.org/packages/19/db/4ed0f65701afbc2cb0c140d2d02928bb0fe38dd044af76e58ad7c54fd21f/multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db", size = 253234, upload-time = "2025-08-11T12:06:12.658Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/5160c9813269e39ae14b73debb907bfaaa1beee1762da8c4fb95df4764ed/multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0", size = 251613, upload-time = "2025-08-11T12:06:13.97Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/48d1bd111fc2f8fb98b2ed7f9a115c55a9355358432a19f53c0b74d8425d/multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877", size = 241649, upload-time = "2025-08-11T12:06:15.204Z" }, + { url = "https://files.pythonhosted.org/packages/85/2a/f7d743df0019408768af8a70d2037546a2be7b81fbb65f040d76caafd4c5/multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace", size = 239238, upload-time = "2025-08-11T12:06:16.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b8/4f4bb13323c2d647323f7919201493cf48ebe7ded971717bfb0f1a79b6bf/multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6", size = 233517, upload-time = "2025-08-11T12:06:18.107Z" }, + { url = "https://files.pythonhosted.org/packages/33/29/4293c26029ebfbba4f574febd2ed01b6f619cfa0d2e344217d53eef34192/multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb", size = 243122, upload-time = "2025-08-11T12:06:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/20/60/a1c53628168aa22447bfde3a8730096ac28086704a0d8c590f3b63388d0c/multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb", size = 248992, upload-time = "2025-08-11T12:06:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3b/55443a0c372f33cae5d9ec37a6a973802884fa0ab3586659b197cf8cc5e9/multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987", size = 243708, upload-time = "2025-08-11T12:06:21.891Z" }, + { url = "https://files.pythonhosted.org/packages/7c/60/a18c6900086769312560b2626b18e8cca22d9e85b1186ba77f4755b11266/multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f", size = 237498, upload-time = "2025-08-11T12:06:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/11/3d/8bdd8bcaff2951ce2affccca107a404925a2beafedd5aef0b5e4a71120a6/multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f", size = 41415, upload-time = "2025-08-11T12:06:24.77Z" }, + { url = "https://files.pythonhosted.org/packages/c0/53/cab1ad80356a4cd1b685a254b680167059b433b573e53872fab245e9fc95/multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0", size = 46046, upload-time = "2025-08-11T12:06:25.893Z" }, + { url = "https://files.pythonhosted.org/packages/cf/9a/874212b6f5c1c2d870d0a7adc5bb4cfe9b0624fa15cdf5cf757c0f5087ae/multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729", size = 43147, upload-time = "2025-08-11T12:06:27.534Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472, upload-time = "2025-08-11T12:06:29.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634, upload-time = "2025-08-11T12:06:30.374Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282, upload-time = "2025-08-11T12:06:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/436a5da8702b06866189b69f655ffdb8f70796252a8772a77815f1812679/multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded", size = 229696, upload-time = "2025-08-11T12:06:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0e/915160be8fecf1fca35f790c08fb74ca684d752fcba62c11daaf3d92c216/multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683", size = 246665, upload-time = "2025-08-11T12:06:34.448Z" }, + { url = "https://files.pythonhosted.org/packages/08/ee/2f464330acd83f77dcc346f0b1a0eaae10230291450887f96b204b8ac4d3/multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a", size = 225485, upload-time = "2025-08-11T12:06:35.672Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/9a117f828b4d7fbaec6adeed2204f211e9caf0a012692a1ee32169f846ae/multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9", size = 257318, upload-time = "2025-08-11T12:06:36.98Z" }, + { url = "https://files.pythonhosted.org/packages/25/77/62752d3dbd70e27fdd68e86626c1ae6bccfebe2bb1f84ae226363e112f5a/multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50", size = 254689, upload-time = "2025-08-11T12:06:38.233Z" }, + { url = "https://files.pythonhosted.org/packages/00/6e/fac58b1072a6fc59af5e7acb245e8754d3e1f97f4f808a6559951f72a0d4/multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52", size = 246709, upload-time = "2025-08-11T12:06:39.517Z" }, + { url = "https://files.pythonhosted.org/packages/01/ef/4698d6842ef5e797c6db7744b0081e36fb5de3d00002cc4c58071097fac3/multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6", size = 243185, upload-time = "2025-08-11T12:06:40.796Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c9/d82e95ae1d6e4ef396934e9b0e942dfc428775f9554acf04393cce66b157/multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e", size = 237838, upload-time = "2025-08-11T12:06:42.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/f94af5c36baaa75d44fab9f02e2a6bcfa0cd90acb44d4976a80960759dbc/multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3", size = 246368, upload-time = "2025-08-11T12:06:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/4a/fe/29f23460c3d995f6a4b678cb2e9730e7277231b981f0b234702f0177818a/multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c", size = 253339, upload-time = "2025-08-11T12:06:45.597Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/fd59449204426187b82bf8a75f629310f68c6adc9559dc922d5abe34797b/multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b", size = 246933, upload-time = "2025-08-11T12:06:46.841Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/d5d6b344f176a5ac3606f7a61fb44dc746e04550e1a13834dff722b8d7d6/multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f", size = 242225, upload-time = "2025-08-11T12:06:48.588Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/5b2281ed89ff4d5318d82478a2a2450fcdfc3300da48ff15c1778280ad26/multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2", size = 41306, upload-time = "2025-08-11T12:06:49.95Z" }, + { url = "https://files.pythonhosted.org/packages/74/7d/36b045c23a1ab98507aefd44fd8b264ee1dd5e5010543c6fccf82141ccef/multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e", size = 46029, upload-time = "2025-08-11T12:06:51.082Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5e/553d67d24432c5cd52b49047f2d248821843743ee6d29a704594f656d182/multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf", size = 43017, upload-time = "2025-08-11T12:06:52.243Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, + { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, + { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, + { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, + { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/f04c5db316caee9b5b2cbba66270b358c922a959855995bedde87134287c/multidict-6.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:af7618b591bae552b40dbb6f93f5518328a949dac626ee75927bba1ecdeea9f4", size = 76977, upload-time = "2025-08-11T12:08:16.667Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/a6200417d883e510728ab3caec02d3b66ff09e1c85e0aab2ba311abfdf06/multidict-6.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b6819f83aef06f560cb15482d619d0e623ce9bf155115150a85ab11b8342a665", size = 44878, upload-time = "2025-08-11T12:08:18.157Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/815be31ed35571b137d65232816f61513fcd97b2717d6a9d7800b5a0c6e0/multidict-6.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d09384e75788861e046330308e7af54dd306aaf20eb760eb1d0de26b2bea2cb", size = 44546, upload-time = "2025-08-11T12:08:19.694Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f1/21b5bff6a8c3e2aff56956c241941ace6b8820e1abe6b12d3c52868a773d/multidict-6.6.4-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a59c63061f1a07b861c004e53869eb1211ffd1a4acbca330e3322efa6dd02978", size = 223020, upload-time = "2025-08-11T12:08:21.554Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/37083f1dd3439979a0ffeb1906818d978d88b4cc7f4600a9f89b1cb6713c/multidict-6.6.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350f6b0fe1ced61e778037fdc7613f4051c8baf64b1ee19371b42a3acdb016a0", size = 240528, upload-time = "2025-08-11T12:08:23.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f0/f054d123c87784307a27324c829eb55bcfd2e261eb785fcabbd832c8dc4a/multidict-6.6.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c5cbac6b55ad69cb6aa17ee9343dfbba903118fd530348c330211dc7aa756d1", size = 219540, upload-time = "2025-08-11T12:08:24.965Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/8f78ce17b7118149c17f238f28fba2a850b660b860f9b024a34d0191030f/multidict-6.6.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630f70c32b8066ddfd920350bc236225814ad94dfa493fe1910ee17fe4365cbb", size = 251182, upload-time = "2025-08-11T12:08:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/c3/a21466322d69f6594fe22d9379200f99194d21c12a5bbf8c2a39a46b83b6/multidict-6.6.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d4916a81697faec6cb724a273bd5457e4c6c43d82b29f9dc02c5542fd21fc9", size = 249371, upload-time = "2025-08-11T12:08:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8e/2e673124eb05cf8dc82e9265eccde01a36bcbd3193e27799b8377123c976/multidict-6.6.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e42332cf8276bb7645d310cdecca93a16920256a5b01bebf747365f86a1675b", size = 239235, upload-time = "2025-08-11T12:08:29.937Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2d/bdd9f05e7c89e30a4b0e4faf0681a30748f8d1310f68cfdc0e3571e75bd5/multidict-6.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f3be27440f7644ab9a13a6fc86f09cdd90b347c3c5e30c6d6d860de822d7cb53", size = 237410, upload-time = "2025-08-11T12:08:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/3237b83f8ca9a2673bb08fc340c15da005a80f5cc49748b587c8ae83823b/multidict-6.6.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:21f216669109e02ef3e2415ede07f4f8987f00de8cdfa0cc0b3440d42534f9f0", size = 232979, upload-time = "2025-08-11T12:08:33.399Z" }, + { url = "https://files.pythonhosted.org/packages/55/a6/a765decff625ae9bc581aed303cd1837955177dafc558859a69f56f56ba8/multidict-6.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d9890d68c45d1aeac5178ded1d1cccf3bc8d7accf1f976f79bf63099fb16e4bd", size = 240979, upload-time = "2025-08-11T12:08:35.02Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2d/9c75975cb0c66ea33cae1443bb265b2b3cd689bffcbc68872565f401da23/multidict-6.6.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:edfdcae97cdc5d1a89477c436b61f472c4d40971774ac4729c613b4b133163cb", size = 246849, upload-time = "2025-08-11T12:08:37.038Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/d21ac0843c1d8751fb5dcf8a1f436625d39d4577bc27829799d09b419af7/multidict-6.6.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0b2e886624be5773e69cf32bcb8534aecdeb38943520b240fed3d5596a430f2f", size = 241798, upload-time = "2025-08-11T12:08:38.669Z" }, + { url = "https://files.pythonhosted.org/packages/94/3d/1d8911e53092837bd11b1c99d71de3e2a9a26f8911f864554677663242aa/multidict-6.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:be5bf4b3224948032a845d12ab0f69f208293742df96dc14c4ff9b09e508fc17", size = 235315, upload-time = "2025-08-11T12:08:40.266Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/4b758df96376f73e936b1942c6c2dfc17e37ed9d5ff3b01a811496966ca0/multidict-6.6.4-cp39-cp39-win32.whl", hash = "sha256:10a68a9191f284fe9d501fef4efe93226e74df92ce7a24e301371293bd4918ae", size = 41434, upload-time = "2025-08-11T12:08:41.965Z" }, + { url = "https://files.pythonhosted.org/packages/58/16/f1dfa2a0f25f2717a5e9e5fe8fd30613f7fe95e3530cec8d11f5de0b709c/multidict-6.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:ee25f82f53262f9ac93bd7e58e47ea1bdcc3393cef815847e397cba17e284210", size = 46186, upload-time = "2025-08-11T12:08:43.367Z" }, + { url = "https://files.pythonhosted.org/packages/88/7d/a0568bac65438c494cb6950b29f394d875a796a237536ac724879cf710c9/multidict-6.6.4-cp39-cp39-win_arm64.whl", hash = "sha256:f9867e55590e0855bcec60d4f9a092b69476db64573c9fe17e92b0c50614c16a", size = 43115, upload-time = "2025-08-11T12:08:45.126Z" }, + { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, ] [[package]] @@ -1168,24 +1366,38 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dill" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/ae/04f39c5d0d0def03247c2893d6f2b83c136bf3320a2154d7b8858f2ba72d/multiprocess-0.70.16.tar.gz", hash = "sha256:161af703d4652a0e1410be6abccecde4a7ddffd19341be0a7011b94aeb171ac1", size = 1772603 } +sdist = { url = "https://files.pythonhosted.org/packages/b5/ae/04f39c5d0d0def03247c2893d6f2b83c136bf3320a2154d7b8858f2ba72d/multiprocess-0.70.16.tar.gz", hash = "sha256:161af703d4652a0e1410be6abccecde4a7ddffd19341be0a7011b94aeb171ac1", size = 1772603, upload-time = "2024-01-28T18:52:34.85Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/76/6e712a2623d146d314f17598df5de7224c85c0060ef63fd95cc15a25b3fa/multiprocess-0.70.16-pp310-pypy310_pp73-macosx_10_13_x86_64.whl", hash = "sha256:476887be10e2f59ff183c006af746cb6f1fd0eadcfd4ef49e605cbe2659920ee", size = 134980 }, - { url = "https://files.pythonhosted.org/packages/0f/ab/1e6e8009e380e22254ff539ebe117861e5bdb3bff1fc977920972237c6c7/multiprocess-0.70.16-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d951bed82c8f73929ac82c61f01a7b5ce8f3e5ef40f5b52553b4f547ce2b08ec", size = 134982 }, - { url = "https://files.pythonhosted.org/packages/bc/f7/7ec7fddc92e50714ea3745631f79bd9c96424cb2702632521028e57d3a36/multiprocess-0.70.16-py310-none-any.whl", hash = "sha256:c4a9944c67bd49f823687463660a2d6daae94c289adff97e0f9d696ba6371d02", size = 134824 }, - { url = "https://files.pythonhosted.org/packages/50/15/b56e50e8debaf439f44befec5b2af11db85f6e0f344c3113ae0be0593a91/multiprocess-0.70.16-py311-none-any.whl", hash = "sha256:af4cabb0dac72abfb1e794fa7855c325fd2b55a10a44628a3c1ad3311c04127a", size = 143519 }, - { url = "https://files.pythonhosted.org/packages/0a/7d/a988f258104dcd2ccf1ed40fdc97e26c4ac351eeaf81d76e266c52d84e2f/multiprocess-0.70.16-py312-none-any.whl", hash = "sha256:fc0544c531920dde3b00c29863377f87e1632601092ea2daca74e4beb40faa2e", size = 146741 }, - { url = "https://files.pythonhosted.org/packages/ea/89/38df130f2c799090c978b366cfdf5b96d08de5b29a4a293df7f7429fa50b/multiprocess-0.70.16-py38-none-any.whl", hash = "sha256:a71d82033454891091a226dfc319d0cfa8019a4e888ef9ca910372a446de4435", size = 132628 }, - { url = "https://files.pythonhosted.org/packages/da/d9/f7f9379981e39b8c2511c9e0326d212accacb82f12fbfdc1aa2ce2a7b2b6/multiprocess-0.70.16-py39-none-any.whl", hash = "sha256:a0bafd3ae1b732eac64be2e72038231c1ba97724b60b09400d68f229fcc2fbf3", size = 133351 }, + { url = "https://files.pythonhosted.org/packages/ef/76/6e712a2623d146d314f17598df5de7224c85c0060ef63fd95cc15a25b3fa/multiprocess-0.70.16-pp310-pypy310_pp73-macosx_10_13_x86_64.whl", hash = "sha256:476887be10e2f59ff183c006af746cb6f1fd0eadcfd4ef49e605cbe2659920ee", size = 134980, upload-time = "2024-01-28T18:52:15.731Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ab/1e6e8009e380e22254ff539ebe117861e5bdb3bff1fc977920972237c6c7/multiprocess-0.70.16-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d951bed82c8f73929ac82c61f01a7b5ce8f3e5ef40f5b52553b4f547ce2b08ec", size = 134982, upload-time = "2024-01-28T18:52:17.783Z" }, + { url = "https://files.pythonhosted.org/packages/d8/94/8638a89f93c80df329116e6781a060506c7e91e1f4370dc831e9d17a041d/multiprocess-0.70.16-pp39-pypy39_pp73-macosx_10_13_x86_64.whl", hash = "sha256:0dfd078c306e08d46d7a8d06fb120313d87aa43af60d66da43ffff40b44d2f41", size = 133497, upload-time = "2024-01-28T18:52:22.644Z" }, + { url = "https://files.pythonhosted.org/packages/89/21/222066f6bb8d8af287923ae3bd26cf4699a9ce020228ac273caca1de8250/multiprocess-0.70.16-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e7b9d0f307cd9bd50851afaac0dba2cb6c44449efff697df7c7645f7d3f2be3a", size = 133498, upload-time = "2024-01-28T18:52:24.576Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f7/7ec7fddc92e50714ea3745631f79bd9c96424cb2702632521028e57d3a36/multiprocess-0.70.16-py310-none-any.whl", hash = "sha256:c4a9944c67bd49f823687463660a2d6daae94c289adff97e0f9d696ba6371d02", size = 134824, upload-time = "2024-01-28T18:52:26.062Z" }, + { url = "https://files.pythonhosted.org/packages/50/15/b56e50e8debaf439f44befec5b2af11db85f6e0f344c3113ae0be0593a91/multiprocess-0.70.16-py311-none-any.whl", hash = "sha256:af4cabb0dac72abfb1e794fa7855c325fd2b55a10a44628a3c1ad3311c04127a", size = 143519, upload-time = "2024-01-28T18:52:28.115Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7d/a988f258104dcd2ccf1ed40fdc97e26c4ac351eeaf81d76e266c52d84e2f/multiprocess-0.70.16-py312-none-any.whl", hash = "sha256:fc0544c531920dde3b00c29863377f87e1632601092ea2daca74e4beb40faa2e", size = 146741, upload-time = "2024-01-28T18:52:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/38df130f2c799090c978b366cfdf5b96d08de5b29a4a293df7f7429fa50b/multiprocess-0.70.16-py38-none-any.whl", hash = "sha256:a71d82033454891091a226dfc319d0cfa8019a4e888ef9ca910372a446de4435", size = 132628, upload-time = "2024-01-28T18:52:30.853Z" }, + { url = "https://files.pythonhosted.org/packages/da/d9/f7f9379981e39b8c2511c9e0326d212accacb82f12fbfdc1aa2ce2a7b2b6/multiprocess-0.70.16-py39-none-any.whl", hash = "sha256:a0bafd3ae1b732eac64be2e72038231c1ba97724b60b09400d68f229fcc2fbf3", size = 133351, upload-time = "2024-01-28T18:52:31.981Z" }, ] [[package]] name = "namex" version = "0.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/c0/ee95b28f029c73f8d49d8f52edaed02a1d4a9acb8b69355737fdb1faa191/namex-0.1.0.tar.gz", hash = "sha256:117f03ccd302cc48e3f5c58a296838f6b89c83455ab8683a1e85f2a430aa4306", size = 6649 } +sdist = { url = "https://files.pythonhosted.org/packages/0c/c0/ee95b28f029c73f8d49d8f52edaed02a1d4a9acb8b69355737fdb1faa191/namex-0.1.0.tar.gz", hash = "sha256:117f03ccd302cc48e3f5c58a296838f6b89c83455ab8683a1e85f2a430aa4306", size = 6649, upload-time = "2025-05-26T23:17:38.918Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/bc/465daf1de06409cdd4532082806770ee0d8d7df434da79c76564d0f69741/namex-0.1.0-py3-none-any.whl", hash = "sha256:e2012a474502f1e2251267062aae3114611f07df4224b6e06334c57b0f2ce87c", size = 5905 }, + { url = "https://files.pythonhosted.org/packages/b2/bc/465daf1de06409cdd4532082806770ee0d8d7df434da79c76564d0f69741/namex-0.1.0-py3-none-any.whl", hash = "sha256:e2012a474502f1e2251267062aae3114611f07df4224b6e06334c57b0f2ce87c", size = 5905, upload-time = "2025-05-26T23:17:37.695Z" }, +] + +[[package]] +name = "networkx" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/80/a84676339aaae2f1cfdf9f418701dd634aef9cc76f708ef55c36ff39c3ca/networkx-3.2.1.tar.gz", hash = "sha256:9f1bb5cf3409bf324e0a722c20bdb4c20ee39bf1c30ce8ae499c8502b0b5e0c6", size = 2073928, upload-time = "2023-10-28T08:41:39.364Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/f0/8fbc882ca80cf077f1b246c0e3c3465f7f415439bdea6b899f6b19f61f70/networkx-3.2.1-py3-none-any.whl", hash = "sha256:f18c69adc97877c42332c170849c96cefa91881c99a7cb3e95b7c659ebdc1ec2", size = 1647772, upload-time = "2023-10-28T08:41:36.945Z" }, ] [[package]] @@ -1193,11 +1405,11 @@ name = "networkx" version = "3.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", + "python_full_version == '3.10.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368 } +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263 }, + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, ] [[package]] @@ -1210,18 +1422,73 @@ resolution-markers = [ "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406 }, + { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, ] [[package]] @@ -1229,64 +1496,64 @@ name = "numpy" version = "2.2.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245 }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048 }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542 }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301 }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320 }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050 }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034 }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185 }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149 }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620 }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963 }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743 }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616 }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579 }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005 }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570 }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548 }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521 }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866 }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455 }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348 }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362 }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103 }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382 }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462 }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618 }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511 }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783 }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506 }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190 }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828 }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006 }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765 }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736 }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719 }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072 }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213 }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632 }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532 }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885 }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467 }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144 }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217 }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014 }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935 }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122 }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143 }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260 }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225 }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374 }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391 }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754 }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476 }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666 }, + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, ] [[package]] @@ -1299,81 +1566,81 @@ resolution-markers = [ "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/45/e80d203ef6b267aa29b22714fb558930b27960a0c5ce3c19c999232bb3eb/numpy-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ffc4f5caba7dfcbe944ed674b7eef683c7e94874046454bb79ed7ee0236f59d", size = 21259253 }, - { url = "https://files.pythonhosted.org/packages/52/18/cf2c648fccf339e59302e00e5f2bc87725a3ce1992f30f3f78c9044d7c43/numpy-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7e946c7170858a0295f79a60214424caac2ffdb0063d4d79cb681f9aa0aa569", size = 14450980 }, - { url = "https://files.pythonhosted.org/packages/93/fb/9af1082bec870188c42a1c239839915b74a5099c392389ff04215dcee812/numpy-2.3.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cd4260f64bc794c3390a63bf0728220dd1a68170c169088a1e0dfa2fde1be12f", size = 5379709 }, - { url = "https://files.pythonhosted.org/packages/75/0f/bfd7abca52bcbf9a4a65abc83fe18ef01ccdeb37bfb28bbd6ad613447c79/numpy-2.3.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:f0ddb4b96a87b6728df9362135e764eac3cfa674499943ebc44ce96c478ab125", size = 6913923 }, - { url = "https://files.pythonhosted.org/packages/79/55/d69adad255e87ab7afda1caf93ca997859092afeb697703e2f010f7c2e55/numpy-2.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afd07d377f478344ec6ca2b8d4ca08ae8bd44706763d1efb56397de606393f48", size = 14589591 }, - { url = "https://files.pythonhosted.org/packages/10/a2/010b0e27ddeacab7839957d7a8f00e91206e0c2c47abbb5f35a2630e5387/numpy-2.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc92a5dedcc53857249ca51ef29f5e5f2f8c513e22cfb90faeb20343b8c6f7a6", size = 16938714 }, - { url = "https://files.pythonhosted.org/packages/1c/6b/12ce8ede632c7126eb2762b9e15e18e204b81725b81f35176eac14dc5b82/numpy-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7af05ed4dc19f308e1d9fc759f36f21921eb7bbfc82843eeec6b2a2863a0aefa", size = 16370592 }, - { url = "https://files.pythonhosted.org/packages/b4/35/aba8568b2593067bb6a8fe4c52babb23b4c3b9c80e1b49dff03a09925e4a/numpy-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:433bf137e338677cebdd5beac0199ac84712ad9d630b74eceeb759eaa45ddf30", size = 18884474 }, - { url = "https://files.pythonhosted.org/packages/45/fa/7f43ba10c77575e8be7b0138d107e4f44ca4a1ef322cd16980ea3e8b8222/numpy-2.3.3-cp311-cp311-win32.whl", hash = "sha256:eb63d443d7b4ffd1e873f8155260d7f58e7e4b095961b01c91062935c2491e57", size = 6599794 }, - { url = "https://files.pythonhosted.org/packages/0a/a2/a4f78cb2241fe5664a22a10332f2be886dcdea8784c9f6a01c272da9b426/numpy-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:ec9d249840f6a565f58d8f913bccac2444235025bbb13e9a4681783572ee3caa", size = 13088104 }, - { url = "https://files.pythonhosted.org/packages/79/64/e424e975adbd38282ebcd4891661965b78783de893b381cbc4832fb9beb2/numpy-2.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:74c2a948d02f88c11a3c075d9733f1ae67d97c6bdb97f2bb542f980458b257e7", size = 10460772 }, - { url = "https://files.pythonhosted.org/packages/51/5d/bb7fc075b762c96329147799e1bcc9176ab07ca6375ea976c475482ad5b3/numpy-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cfdd09f9c84a1a934cde1eec2267f0a43a7cd44b2cca4ff95b7c0d14d144b0bf", size = 20957014 }, - { url = "https://files.pythonhosted.org/packages/6b/0e/c6211bb92af26517acd52125a237a92afe9c3124c6a68d3b9f81b62a0568/numpy-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb32e3cf0f762aee47ad1ddc6672988f7f27045b0783c887190545baba73aa25", size = 14185220 }, - { url = "https://files.pythonhosted.org/packages/22/f2/07bb754eb2ede9073f4054f7c0286b0d9d2e23982e090a80d478b26d35ca/numpy-2.3.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:396b254daeb0a57b1fe0ecb5e3cff6fa79a380fa97c8f7781a6d08cd429418fe", size = 5113918 }, - { url = "https://files.pythonhosted.org/packages/81/0a/afa51697e9fb74642f231ea36aca80fa17c8fb89f7a82abd5174023c3960/numpy-2.3.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:067e3d7159a5d8f8a0b46ee11148fc35ca9b21f61e3c49fbd0a027450e65a33b", size = 6647922 }, - { url = "https://files.pythonhosted.org/packages/5d/f5/122d9cdb3f51c520d150fef6e87df9279e33d19a9611a87c0d2cf78a89f4/numpy-2.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c02d0629d25d426585fb2e45a66154081b9fa677bc92a881ff1d216bc9919a8", size = 14281991 }, - { url = "https://files.pythonhosted.org/packages/51/64/7de3c91e821a2debf77c92962ea3fe6ac2bc45d0778c1cbe15d4fce2fd94/numpy-2.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9192da52b9745f7f0766531dcfa978b7763916f158bb63bdb8a1eca0068ab20", size = 16641643 }, - { url = "https://files.pythonhosted.org/packages/30/e4/961a5fa681502cd0d68907818b69f67542695b74e3ceaa513918103b7e80/numpy-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd7de500a5b66319db419dc3c345244404a164beae0d0937283b907d8152e6ea", size = 16056787 }, - { url = "https://files.pythonhosted.org/packages/99/26/92c912b966e47fbbdf2ad556cb17e3a3088e2e1292b9833be1dfa5361a1a/numpy-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93d4962d8f82af58f0b2eb85daaf1b3ca23fe0a85d0be8f1f2b7bb46034e56d7", size = 18579598 }, - { url = "https://files.pythonhosted.org/packages/17/b6/fc8f82cb3520768718834f310c37d96380d9dc61bfdaf05fe5c0b7653e01/numpy-2.3.3-cp312-cp312-win32.whl", hash = "sha256:5534ed6b92f9b7dca6c0a19d6df12d41c68b991cef051d108f6dbff3babc4ebf", size = 6320800 }, - { url = "https://files.pythonhosted.org/packages/32/ee/de999f2625b80d043d6d2d628c07d0d5555a677a3cf78fdf868d409b8766/numpy-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:497d7cad08e7092dba36e3d296fe4c97708c93daf26643a1ae4b03f6294d30eb", size = 12786615 }, - { url = "https://files.pythonhosted.org/packages/49/6e/b479032f8a43559c383acb20816644f5f91c88f633d9271ee84f3b3a996c/numpy-2.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:ca0309a18d4dfea6fc6262a66d06c26cfe4640c3926ceec90e57791a82b6eee5", size = 10195936 }, - { url = "https://files.pythonhosted.org/packages/7d/b9/984c2b1ee61a8b803bf63582b4ac4242cf76e2dbd663efeafcb620cc0ccb/numpy-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5415fb78995644253370985342cd03572ef8620b934da27d77377a2285955bf", size = 20949588 }, - { url = "https://files.pythonhosted.org/packages/a6/e4/07970e3bed0b1384d22af1e9912527ecbeb47d3b26e9b6a3bced068b3bea/numpy-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d00de139a3324e26ed5b95870ce63be7ec7352171bc69a4cf1f157a48e3eb6b7", size = 14177802 }, - { url = "https://files.pythonhosted.org/packages/35/c7/477a83887f9de61f1203bad89cf208b7c19cc9fef0cebef65d5a1a0619f2/numpy-2.3.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9dc13c6a5829610cc07422bc74d3ac083bd8323f14e2827d992f9e52e22cd6a6", size = 5106537 }, - { url = "https://files.pythonhosted.org/packages/52/47/93b953bd5866a6f6986344d045a207d3f1cfbad99db29f534ea9cee5108c/numpy-2.3.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d79715d95f1894771eb4e60fb23f065663b2298f7d22945d66877aadf33d00c7", size = 6640743 }, - { url = "https://files.pythonhosted.org/packages/23/83/377f84aaeb800b64c0ef4de58b08769e782edcefa4fea712910b6f0afd3c/numpy-2.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952cfd0748514ea7c3afc729a0fc639e61655ce4c55ab9acfab14bda4f402b4c", size = 14278881 }, - { url = "https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93", size = 16636301 }, - { url = "https://files.pythonhosted.org/packages/a2/59/1287924242eb4fa3f9b3a2c30400f2e17eb2707020d1c5e3086fe7330717/numpy-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b001bae8cea1c7dfdb2ae2b017ed0a6f2102d7a70059df1e338e307a4c78a8ae", size = 16053645 }, - { url = "https://files.pythonhosted.org/packages/e6/93/b3d47ed882027c35e94ac2320c37e452a549f582a5e801f2d34b56973c97/numpy-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e9aced64054739037d42fb84c54dd38b81ee238816c948c8f3ed134665dcd86", size = 18578179 }, - { url = "https://files.pythonhosted.org/packages/20/d9/487a2bccbf7cc9d4bfc5f0f197761a5ef27ba870f1e3bbb9afc4bbe3fcc2/numpy-2.3.3-cp313-cp313-win32.whl", hash = "sha256:9591e1221db3f37751e6442850429b3aabf7026d3b05542d102944ca7f00c8a8", size = 6312250 }, - { url = "https://files.pythonhosted.org/packages/1b/b5/263ebbbbcede85028f30047eab3d58028d7ebe389d6493fc95ae66c636ab/numpy-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f0dadeb302887f07431910f67a14d57209ed91130be0adea2f9793f1a4f817cf", size = 12783269 }, - { url = "https://files.pythonhosted.org/packages/fa/75/67b8ca554bbeaaeb3fac2e8bce46967a5a06544c9108ec0cf5cece559b6c/numpy-2.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:3c7cf302ac6e0b76a64c4aecf1a09e51abd9b01fc7feee80f6c43e3ab1b1dbc5", size = 10195314 }, - { url = "https://files.pythonhosted.org/packages/11/d0/0d1ddec56b162042ddfafeeb293bac672de9b0cfd688383590090963720a/numpy-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eda59e44957d272846bb407aad19f89dc6f58fecf3504bd144f4c5cf81a7eacc", size = 21048025 }, - { url = "https://files.pythonhosted.org/packages/36/9e/1996ca6b6d00415b6acbdd3c42f7f03ea256e2c3f158f80bd7436a8a19f3/numpy-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:823d04112bc85ef5c4fda73ba24e6096c8f869931405a80aa8b0e604510a26bc", size = 14301053 }, - { url = "https://files.pythonhosted.org/packages/05/24/43da09aa764c68694b76e84b3d3f0c44cb7c18cdc1ba80e48b0ac1d2cd39/numpy-2.3.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:40051003e03db4041aa325da2a0971ba41cf65714e65d296397cc0e32de6018b", size = 5229444 }, - { url = "https://files.pythonhosted.org/packages/bc/14/50ffb0f22f7218ef8af28dd089f79f68289a7a05a208db9a2c5dcbe123c1/numpy-2.3.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ee9086235dd6ab7ae75aba5662f582a81ced49f0f1c6de4260a78d8f2d91a19", size = 6738039 }, - { url = "https://files.pythonhosted.org/packages/55/52/af46ac0795e09657d45a7f4db961917314377edecf66db0e39fa7ab5c3d3/numpy-2.3.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94fcaa68757c3e2e668ddadeaa86ab05499a70725811e582b6a9858dd472fb30", size = 14352314 }, - { url = "https://files.pythonhosted.org/packages/a7/b1/dc226b4c90eb9f07a3fff95c2f0db3268e2e54e5cce97c4ac91518aee71b/numpy-2.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da1a74b90e7483d6ce5244053399a614b1d6b7bc30a60d2f570e5071f8959d3e", size = 16701722 }, - { url = "https://files.pythonhosted.org/packages/9d/9d/9d8d358f2eb5eced14dba99f110d83b5cd9a4460895230f3b396ad19a323/numpy-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2990adf06d1ecee3b3dcbb4977dfab6e9f09807598d647f04d385d29e7a3c3d3", size = 16132755 }, - { url = "https://files.pythonhosted.org/packages/b6/27/b3922660c45513f9377b3fb42240bec63f203c71416093476ec9aa0719dc/numpy-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ed635ff692483b8e3f0fcaa8e7eb8a75ee71aa6d975388224f70821421800cea", size = 18651560 }, - { url = "https://files.pythonhosted.org/packages/5b/8e/3ab61a730bdbbc201bb245a71102aa609f0008b9ed15255500a99cd7f780/numpy-2.3.3-cp313-cp313t-win32.whl", hash = "sha256:a333b4ed33d8dc2b373cc955ca57babc00cd6f9009991d9edc5ddbc1bac36bcd", size = 6442776 }, - { url = "https://files.pythonhosted.org/packages/1c/3a/e22b766b11f6030dc2decdeff5c2fb1610768055603f9f3be88b6d192fb2/numpy-2.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4384a169c4d8f97195980815d6fcad04933a7e1ab3b530921c3fef7a1c63426d", size = 12927281 }, - { url = "https://files.pythonhosted.org/packages/7b/42/c2e2bc48c5e9b2a83423f99733950fbefd86f165b468a3d85d52b30bf782/numpy-2.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:75370986cc0bc66f4ce5110ad35aae6d182cc4ce6433c40ad151f53690130bf1", size = 10265275 }, - { url = "https://files.pythonhosted.org/packages/6b/01/342ad585ad82419b99bcf7cebe99e61da6bedb89e213c5fd71acc467faee/numpy-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cd052f1fa6a78dee696b58a914b7229ecfa41f0a6d96dc663c1220a55e137593", size = 20951527 }, - { url = "https://files.pythonhosted.org/packages/ef/d8/204e0d73fc1b7a9ee80ab1fe1983dd33a4d64a4e30a05364b0208e9a241a/numpy-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:414a97499480067d305fcac9716c29cf4d0d76db6ebf0bf3cbce666677f12652", size = 14186159 }, - { url = "https://files.pythonhosted.org/packages/22/af/f11c916d08f3a18fb8ba81ab72b5b74a6e42ead4c2846d270eb19845bf74/numpy-2.3.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:50a5fe69f135f88a2be9b6ca0481a68a136f6febe1916e4920e12f1a34e708a7", size = 5114624 }, - { url = "https://files.pythonhosted.org/packages/fb/11/0ed919c8381ac9d2ffacd63fd1f0c34d27e99cab650f0eb6f110e6ae4858/numpy-2.3.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:b912f2ed2b67a129e6a601e9d93d4fa37bef67e54cac442a2f588a54afe5c67a", size = 6642627 }, - { url = "https://files.pythonhosted.org/packages/ee/83/deb5f77cb0f7ba6cb52b91ed388b47f8f3c2e9930d4665c600408d9b90b9/numpy-2.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e318ee0596d76d4cb3d78535dc005fa60e5ea348cd131a51e99d0bdbe0b54fe", size = 14296926 }, - { url = "https://files.pythonhosted.org/packages/77/cc/70e59dcb84f2b005d4f306310ff0a892518cc0c8000a33d0e6faf7ca8d80/numpy-2.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce020080e4a52426202bdb6f7691c65bb55e49f261f31a8f506c9f6bc7450421", size = 16638958 }, - { url = "https://files.pythonhosted.org/packages/b6/5a/b2ab6c18b4257e099587d5b7f903317bd7115333ad8d4ec4874278eafa61/numpy-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6687dc183aa55dae4a705b35f9c0f8cb178bcaa2f029b241ac5356221d5c021", size = 16071920 }, - { url = "https://files.pythonhosted.org/packages/b8/f1/8b3fdc44324a259298520dd82147ff648979bed085feeacc1250ef1656c0/numpy-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f3b1080782469fdc1718c4ed1d22549b5fb12af0d57d35e992158a772a37cf", size = 18577076 }, - { url = "https://files.pythonhosted.org/packages/f0/a1/b87a284fb15a42e9274e7fcea0dad259d12ddbf07c1595b26883151ca3b4/numpy-2.3.3-cp314-cp314-win32.whl", hash = "sha256:cb248499b0bc3be66ebd6578b83e5acacf1d6cb2a77f2248ce0e40fbec5a76d0", size = 6366952 }, - { url = "https://files.pythonhosted.org/packages/70/5f/1816f4d08f3b8f66576d8433a66f8fa35a5acfb3bbd0bf6c31183b003f3d/numpy-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:691808c2b26b0f002a032c73255d0bd89751425f379f7bcd22d140db593a96e8", size = 12919322 }, - { url = "https://files.pythonhosted.org/packages/8c/de/072420342e46a8ea41c324a555fa90fcc11637583fb8df722936aed1736d/numpy-2.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:9ad12e976ca7b10f1774b03615a2a4bab8addce37ecc77394d8e986927dc0dfe", size = 10478630 }, - { url = "https://files.pythonhosted.org/packages/d5/df/ee2f1c0a9de7347f14da5dd3cd3c3b034d1b8607ccb6883d7dd5c035d631/numpy-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9cc48e09feb11e1db00b320e9d30a4151f7369afb96bd0e48d942d09da3a0d00", size = 21047987 }, - { url = "https://files.pythonhosted.org/packages/d6/92/9453bdc5a4e9e69cf4358463f25e8260e2ffc126d52e10038b9077815989/numpy-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:901bf6123879b7f251d3631967fd574690734236075082078e0571977c6a8e6a", size = 14301076 }, - { url = "https://files.pythonhosted.org/packages/13/77/1447b9eb500f028bb44253105bd67534af60499588a5149a94f18f2ca917/numpy-2.3.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f025652034199c301049296b59fa7d52c7e625017cae4c75d8662e377bf487d", size = 5229491 }, - { url = "https://files.pythonhosted.org/packages/3d/f9/d72221b6ca205f9736cb4b2ce3b002f6e45cd67cd6a6d1c8af11a2f0b649/numpy-2.3.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:533ca5f6d325c80b6007d4d7fb1984c303553534191024ec6a524a4c92a5935a", size = 6737913 }, - { url = "https://files.pythonhosted.org/packages/3c/5f/d12834711962ad9c46af72f79bb31e73e416ee49d17f4c797f72c96b6ca5/numpy-2.3.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edd58682a399824633b66885d699d7de982800053acf20be1eaa46d92009c54", size = 14352811 }, - { url = "https://files.pythonhosted.org/packages/a1/0d/fdbec6629d97fd1bebed56cd742884e4eead593611bbe1abc3eb40d304b2/numpy-2.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:367ad5d8fbec5d9296d18478804a530f1191e24ab4d75ab408346ae88045d25e", size = 16702689 }, - { url = "https://files.pythonhosted.org/packages/9b/09/0a35196dc5575adde1eb97ddfbc3e1687a814f905377621d18ca9bc2b7dd/numpy-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f6ac61a217437946a1fa48d24c47c91a0c4f725237871117dea264982128097", size = 16133855 }, - { url = "https://files.pythonhosted.org/packages/7a/ca/c9de3ea397d576f1b6753eaa906d4cdef1bf97589a6d9825a349b4729cc2/numpy-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:179a42101b845a816d464b6fe9a845dfaf308fdfc7925387195570789bb2c970", size = 18652520 }, - { url = "https://files.pythonhosted.org/packages/fd/c2/e5ed830e08cd0196351db55db82f65bc0ab05da6ef2b72a836dcf1936d2f/numpy-2.3.3-cp314-cp314t-win32.whl", hash = "sha256:1250c5d3d2562ec4174bce2e3a1523041595f9b651065e4a4473f5f48a6bc8a5", size = 6515371 }, - { url = "https://files.pythonhosted.org/packages/47/c7/b0f6b5b67f6788a0725f744496badbb604d226bf233ba716683ebb47b570/numpy-2.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b37a0b2e5935409daebe82c1e42274d30d9dd355852529eab91dab8dcca7419f", size = 13112576 }, - { url = "https://files.pythonhosted.org/packages/06/b9/33bba5ff6fb679aa0b1f8a07e853f002a6b04b9394db3069a1270a7784ca/numpy-2.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:78c9f6560dc7e6b3990e32df7ea1a50bbd0e2a111e05209963f5ddcab7073b0b", size = 10545953 }, - { url = "https://files.pythonhosted.org/packages/b8/f2/7e0a37cfced2644c9563c529f29fa28acbd0960dde32ece683aafa6f4949/numpy-2.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1e02c7159791cd481e1e6d5ddd766b62a4d5acf8df4d4d1afe35ee9c5c33a41e", size = 21131019 }, - { url = "https://files.pythonhosted.org/packages/1a/7e/3291f505297ed63831135a6cc0f474da0c868a1f31b0dd9a9f03a7a0d2ed/numpy-2.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:dca2d0fc80b3893ae72197b39f69d55a3cd8b17ea1b50aa4c62de82419936150", size = 14376288 }, - { url = "https://files.pythonhosted.org/packages/bf/4b/ae02e985bdeee73d7b5abdefeb98aef1207e96d4c0621ee0cf228ddfac3c/numpy-2.3.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:99683cbe0658f8271b333a1b1b4bb3173750ad59c0c61f5bbdc5b318918fffe3", size = 5305425 }, - { url = "https://files.pythonhosted.org/packages/8b/eb/9df215d6d7250db32007941500dc51c48190be25f2401d5b2b564e467247/numpy-2.3.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d9d537a39cc9de668e5cd0e25affb17aec17b577c6b3ae8a3d866b479fbe88d0", size = 6819053 }, - { url = "https://files.pythonhosted.org/packages/57/62/208293d7d6b2a8998a4a1f23ac758648c3c32182d4ce4346062018362e29/numpy-2.3.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8596ba2f8af5f93b01d97563832686d20206d303024777f6dfc2e7c7c3f1850e", size = 14420354 }, - { url = "https://files.pythonhosted.org/packages/ed/0c/8e86e0ff7072e14a71b4c6af63175e40d1e7e933ce9b9e9f765a95b4e0c3/numpy-2.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1ec5615b05369925bd1125f27df33f3b6c8bc10d788d5999ecd8769a1fa04db", size = 16760413 }, - { url = "https://files.pythonhosted.org/packages/af/11/0cc63f9f321ccf63886ac203336777140011fb669e739da36d8db3c53b98/numpy-2.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2e267c7da5bf7309670523896df97f93f6e469fb931161f483cd6882b3b1a5dc", size = 12971844 }, +sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/45/e80d203ef6b267aa29b22714fb558930b27960a0c5ce3c19c999232bb3eb/numpy-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ffc4f5caba7dfcbe944ed674b7eef683c7e94874046454bb79ed7ee0236f59d", size = 21259253, upload-time = "2025-09-09T15:56:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/52/18/cf2c648fccf339e59302e00e5f2bc87725a3ce1992f30f3f78c9044d7c43/numpy-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7e946c7170858a0295f79a60214424caac2ffdb0063d4d79cb681f9aa0aa569", size = 14450980, upload-time = "2025-09-09T15:56:05.926Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/9af1082bec870188c42a1c239839915b74a5099c392389ff04215dcee812/numpy-2.3.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cd4260f64bc794c3390a63bf0728220dd1a68170c169088a1e0dfa2fde1be12f", size = 5379709, upload-time = "2025-09-09T15:56:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/bfd7abca52bcbf9a4a65abc83fe18ef01ccdeb37bfb28bbd6ad613447c79/numpy-2.3.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:f0ddb4b96a87b6728df9362135e764eac3cfa674499943ebc44ce96c478ab125", size = 6913923, upload-time = "2025-09-09T15:56:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/79/55/d69adad255e87ab7afda1caf93ca997859092afeb697703e2f010f7c2e55/numpy-2.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afd07d377f478344ec6ca2b8d4ca08ae8bd44706763d1efb56397de606393f48", size = 14589591, upload-time = "2025-09-09T15:56:11.234Z" }, + { url = "https://files.pythonhosted.org/packages/10/a2/010b0e27ddeacab7839957d7a8f00e91206e0c2c47abbb5f35a2630e5387/numpy-2.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc92a5dedcc53857249ca51ef29f5e5f2f8c513e22cfb90faeb20343b8c6f7a6", size = 16938714, upload-time = "2025-09-09T15:56:14.637Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6b/12ce8ede632c7126eb2762b9e15e18e204b81725b81f35176eac14dc5b82/numpy-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7af05ed4dc19f308e1d9fc759f36f21921eb7bbfc82843eeec6b2a2863a0aefa", size = 16370592, upload-time = "2025-09-09T15:56:17.285Z" }, + { url = "https://files.pythonhosted.org/packages/b4/35/aba8568b2593067bb6a8fe4c52babb23b4c3b9c80e1b49dff03a09925e4a/numpy-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:433bf137e338677cebdd5beac0199ac84712ad9d630b74eceeb759eaa45ddf30", size = 18884474, upload-time = "2025-09-09T15:56:20.943Z" }, + { url = "https://files.pythonhosted.org/packages/45/fa/7f43ba10c77575e8be7b0138d107e4f44ca4a1ef322cd16980ea3e8b8222/numpy-2.3.3-cp311-cp311-win32.whl", hash = "sha256:eb63d443d7b4ffd1e873f8155260d7f58e7e4b095961b01c91062935c2491e57", size = 6599794, upload-time = "2025-09-09T15:56:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a2/a4f78cb2241fe5664a22a10332f2be886dcdea8784c9f6a01c272da9b426/numpy-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:ec9d249840f6a565f58d8f913bccac2444235025bbb13e9a4681783572ee3caa", size = 13088104, upload-time = "2025-09-09T15:56:25.476Z" }, + { url = "https://files.pythonhosted.org/packages/79/64/e424e975adbd38282ebcd4891661965b78783de893b381cbc4832fb9beb2/numpy-2.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:74c2a948d02f88c11a3c075d9733f1ae67d97c6bdb97f2bb542f980458b257e7", size = 10460772, upload-time = "2025-09-09T15:56:27.679Z" }, + { url = "https://files.pythonhosted.org/packages/51/5d/bb7fc075b762c96329147799e1bcc9176ab07ca6375ea976c475482ad5b3/numpy-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cfdd09f9c84a1a934cde1eec2267f0a43a7cd44b2cca4ff95b7c0d14d144b0bf", size = 20957014, upload-time = "2025-09-09T15:56:29.966Z" }, + { url = "https://files.pythonhosted.org/packages/6b/0e/c6211bb92af26517acd52125a237a92afe9c3124c6a68d3b9f81b62a0568/numpy-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb32e3cf0f762aee47ad1ddc6672988f7f27045b0783c887190545baba73aa25", size = 14185220, upload-time = "2025-09-09T15:56:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f2/07bb754eb2ede9073f4054f7c0286b0d9d2e23982e090a80d478b26d35ca/numpy-2.3.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:396b254daeb0a57b1fe0ecb5e3cff6fa79a380fa97c8f7781a6d08cd429418fe", size = 5113918, upload-time = "2025-09-09T15:56:34.175Z" }, + { url = "https://files.pythonhosted.org/packages/81/0a/afa51697e9fb74642f231ea36aca80fa17c8fb89f7a82abd5174023c3960/numpy-2.3.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:067e3d7159a5d8f8a0b46ee11148fc35ca9b21f61e3c49fbd0a027450e65a33b", size = 6647922, upload-time = "2025-09-09T15:56:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f5/122d9cdb3f51c520d150fef6e87df9279e33d19a9611a87c0d2cf78a89f4/numpy-2.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c02d0629d25d426585fb2e45a66154081b9fa677bc92a881ff1d216bc9919a8", size = 14281991, upload-time = "2025-09-09T15:56:40.548Z" }, + { url = "https://files.pythonhosted.org/packages/51/64/7de3c91e821a2debf77c92962ea3fe6ac2bc45d0778c1cbe15d4fce2fd94/numpy-2.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9192da52b9745f7f0766531dcfa978b7763916f158bb63bdb8a1eca0068ab20", size = 16641643, upload-time = "2025-09-09T15:56:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/30/e4/961a5fa681502cd0d68907818b69f67542695b74e3ceaa513918103b7e80/numpy-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd7de500a5b66319db419dc3c345244404a164beae0d0937283b907d8152e6ea", size = 16056787, upload-time = "2025-09-09T15:56:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/99/26/92c912b966e47fbbdf2ad556cb17e3a3088e2e1292b9833be1dfa5361a1a/numpy-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93d4962d8f82af58f0b2eb85daaf1b3ca23fe0a85d0be8f1f2b7bb46034e56d7", size = 18579598, upload-time = "2025-09-09T15:56:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/17/b6/fc8f82cb3520768718834f310c37d96380d9dc61bfdaf05fe5c0b7653e01/numpy-2.3.3-cp312-cp312-win32.whl", hash = "sha256:5534ed6b92f9b7dca6c0a19d6df12d41c68b991cef051d108f6dbff3babc4ebf", size = 6320800, upload-time = "2025-09-09T15:56:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/32/ee/de999f2625b80d043d6d2d628c07d0d5555a677a3cf78fdf868d409b8766/numpy-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:497d7cad08e7092dba36e3d296fe4c97708c93daf26643a1ae4b03f6294d30eb", size = 12786615, upload-time = "2025-09-09T15:56:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/49/6e/b479032f8a43559c383acb20816644f5f91c88f633d9271ee84f3b3a996c/numpy-2.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:ca0309a18d4dfea6fc6262a66d06c26cfe4640c3926ceec90e57791a82b6eee5", size = 10195936, upload-time = "2025-09-09T15:56:56.541Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b9/984c2b1ee61a8b803bf63582b4ac4242cf76e2dbd663efeafcb620cc0ccb/numpy-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5415fb78995644253370985342cd03572ef8620b934da27d77377a2285955bf", size = 20949588, upload-time = "2025-09-09T15:56:59.087Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e4/07970e3bed0b1384d22af1e9912527ecbeb47d3b26e9b6a3bced068b3bea/numpy-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d00de139a3324e26ed5b95870ce63be7ec7352171bc69a4cf1f157a48e3eb6b7", size = 14177802, upload-time = "2025-09-09T15:57:01.73Z" }, + { url = "https://files.pythonhosted.org/packages/35/c7/477a83887f9de61f1203bad89cf208b7c19cc9fef0cebef65d5a1a0619f2/numpy-2.3.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9dc13c6a5829610cc07422bc74d3ac083bd8323f14e2827d992f9e52e22cd6a6", size = 5106537, upload-time = "2025-09-09T15:57:03.765Z" }, + { url = "https://files.pythonhosted.org/packages/52/47/93b953bd5866a6f6986344d045a207d3f1cfbad99db29f534ea9cee5108c/numpy-2.3.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d79715d95f1894771eb4e60fb23f065663b2298f7d22945d66877aadf33d00c7", size = 6640743, upload-time = "2025-09-09T15:57:07.921Z" }, + { url = "https://files.pythonhosted.org/packages/23/83/377f84aaeb800b64c0ef4de58b08769e782edcefa4fea712910b6f0afd3c/numpy-2.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952cfd0748514ea7c3afc729a0fc639e61655ce4c55ab9acfab14bda4f402b4c", size = 14278881, upload-time = "2025-09-09T15:57:11.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93", size = 16636301, upload-time = "2025-09-09T15:57:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/1287924242eb4fa3f9b3a2c30400f2e17eb2707020d1c5e3086fe7330717/numpy-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b001bae8cea1c7dfdb2ae2b017ed0a6f2102d7a70059df1e338e307a4c78a8ae", size = 16053645, upload-time = "2025-09-09T15:57:16.534Z" }, + { url = "https://files.pythonhosted.org/packages/e6/93/b3d47ed882027c35e94ac2320c37e452a549f582a5e801f2d34b56973c97/numpy-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e9aced64054739037d42fb84c54dd38b81ee238816c948c8f3ed134665dcd86", size = 18578179, upload-time = "2025-09-09T15:57:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/487a2bccbf7cc9d4bfc5f0f197761a5ef27ba870f1e3bbb9afc4bbe3fcc2/numpy-2.3.3-cp313-cp313-win32.whl", hash = "sha256:9591e1221db3f37751e6442850429b3aabf7026d3b05542d102944ca7f00c8a8", size = 6312250, upload-time = "2025-09-09T15:57:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b5/263ebbbbcede85028f30047eab3d58028d7ebe389d6493fc95ae66c636ab/numpy-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f0dadeb302887f07431910f67a14d57209ed91130be0adea2f9793f1a4f817cf", size = 12783269, upload-time = "2025-09-09T15:57:23.034Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/67b8ca554bbeaaeb3fac2e8bce46967a5a06544c9108ec0cf5cece559b6c/numpy-2.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:3c7cf302ac6e0b76a64c4aecf1a09e51abd9b01fc7feee80f6c43e3ab1b1dbc5", size = 10195314, upload-time = "2025-09-09T15:57:25.045Z" }, + { url = "https://files.pythonhosted.org/packages/11/d0/0d1ddec56b162042ddfafeeb293bac672de9b0cfd688383590090963720a/numpy-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eda59e44957d272846bb407aad19f89dc6f58fecf3504bd144f4c5cf81a7eacc", size = 21048025, upload-time = "2025-09-09T15:57:27.257Z" }, + { url = "https://files.pythonhosted.org/packages/36/9e/1996ca6b6d00415b6acbdd3c42f7f03ea256e2c3f158f80bd7436a8a19f3/numpy-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:823d04112bc85ef5c4fda73ba24e6096c8f869931405a80aa8b0e604510a26bc", size = 14301053, upload-time = "2025-09-09T15:57:30.077Z" }, + { url = "https://files.pythonhosted.org/packages/05/24/43da09aa764c68694b76e84b3d3f0c44cb7c18cdc1ba80e48b0ac1d2cd39/numpy-2.3.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:40051003e03db4041aa325da2a0971ba41cf65714e65d296397cc0e32de6018b", size = 5229444, upload-time = "2025-09-09T15:57:32.733Z" }, + { url = "https://files.pythonhosted.org/packages/bc/14/50ffb0f22f7218ef8af28dd089f79f68289a7a05a208db9a2c5dcbe123c1/numpy-2.3.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ee9086235dd6ab7ae75aba5662f582a81ced49f0f1c6de4260a78d8f2d91a19", size = 6738039, upload-time = "2025-09-09T15:57:34.328Z" }, + { url = "https://files.pythonhosted.org/packages/55/52/af46ac0795e09657d45a7f4db961917314377edecf66db0e39fa7ab5c3d3/numpy-2.3.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94fcaa68757c3e2e668ddadeaa86ab05499a70725811e582b6a9858dd472fb30", size = 14352314, upload-time = "2025-09-09T15:57:36.255Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b1/dc226b4c90eb9f07a3fff95c2f0db3268e2e54e5cce97c4ac91518aee71b/numpy-2.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da1a74b90e7483d6ce5244053399a614b1d6b7bc30a60d2f570e5071f8959d3e", size = 16701722, upload-time = "2025-09-09T15:57:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9d/9d8d358f2eb5eced14dba99f110d83b5cd9a4460895230f3b396ad19a323/numpy-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2990adf06d1ecee3b3dcbb4977dfab6e9f09807598d647f04d385d29e7a3c3d3", size = 16132755, upload-time = "2025-09-09T15:57:41.16Z" }, + { url = "https://files.pythonhosted.org/packages/b6/27/b3922660c45513f9377b3fb42240bec63f203c71416093476ec9aa0719dc/numpy-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ed635ff692483b8e3f0fcaa8e7eb8a75ee71aa6d975388224f70821421800cea", size = 18651560, upload-time = "2025-09-09T15:57:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8e/3ab61a730bdbbc201bb245a71102aa609f0008b9ed15255500a99cd7f780/numpy-2.3.3-cp313-cp313t-win32.whl", hash = "sha256:a333b4ed33d8dc2b373cc955ca57babc00cd6f9009991d9edc5ddbc1bac36bcd", size = 6442776, upload-time = "2025-09-09T15:57:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3a/e22b766b11f6030dc2decdeff5c2fb1610768055603f9f3be88b6d192fb2/numpy-2.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4384a169c4d8f97195980815d6fcad04933a7e1ab3b530921c3fef7a1c63426d", size = 12927281, upload-time = "2025-09-09T15:57:47.492Z" }, + { url = "https://files.pythonhosted.org/packages/7b/42/c2e2bc48c5e9b2a83423f99733950fbefd86f165b468a3d85d52b30bf782/numpy-2.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:75370986cc0bc66f4ce5110ad35aae6d182cc4ce6433c40ad151f53690130bf1", size = 10265275, upload-time = "2025-09-09T15:57:49.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/01/342ad585ad82419b99bcf7cebe99e61da6bedb89e213c5fd71acc467faee/numpy-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cd052f1fa6a78dee696b58a914b7229ecfa41f0a6d96dc663c1220a55e137593", size = 20951527, upload-time = "2025-09-09T15:57:52.006Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/204e0d73fc1b7a9ee80ab1fe1983dd33a4d64a4e30a05364b0208e9a241a/numpy-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:414a97499480067d305fcac9716c29cf4d0d76db6ebf0bf3cbce666677f12652", size = 14186159, upload-time = "2025-09-09T15:57:54.407Z" }, + { url = "https://files.pythonhosted.org/packages/22/af/f11c916d08f3a18fb8ba81ab72b5b74a6e42ead4c2846d270eb19845bf74/numpy-2.3.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:50a5fe69f135f88a2be9b6ca0481a68a136f6febe1916e4920e12f1a34e708a7", size = 5114624, upload-time = "2025-09-09T15:57:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/fb/11/0ed919c8381ac9d2ffacd63fd1f0c34d27e99cab650f0eb6f110e6ae4858/numpy-2.3.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:b912f2ed2b67a129e6a601e9d93d4fa37bef67e54cac442a2f588a54afe5c67a", size = 6642627, upload-time = "2025-09-09T15:57:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/deb5f77cb0f7ba6cb52b91ed388b47f8f3c2e9930d4665c600408d9b90b9/numpy-2.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e318ee0596d76d4cb3d78535dc005fa60e5ea348cd131a51e99d0bdbe0b54fe", size = 14296926, upload-time = "2025-09-09T15:58:00.035Z" }, + { url = "https://files.pythonhosted.org/packages/77/cc/70e59dcb84f2b005d4f306310ff0a892518cc0c8000a33d0e6faf7ca8d80/numpy-2.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce020080e4a52426202bdb6f7691c65bb55e49f261f31a8f506c9f6bc7450421", size = 16638958, upload-time = "2025-09-09T15:58:02.738Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5a/b2ab6c18b4257e099587d5b7f903317bd7115333ad8d4ec4874278eafa61/numpy-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6687dc183aa55dae4a705b35f9c0f8cb178bcaa2f029b241ac5356221d5c021", size = 16071920, upload-time = "2025-09-09T15:58:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f1/8b3fdc44324a259298520dd82147ff648979bed085feeacc1250ef1656c0/numpy-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f3b1080782469fdc1718c4ed1d22549b5fb12af0d57d35e992158a772a37cf", size = 18577076, upload-time = "2025-09-09T15:58:07.745Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a1/b87a284fb15a42e9274e7fcea0dad259d12ddbf07c1595b26883151ca3b4/numpy-2.3.3-cp314-cp314-win32.whl", hash = "sha256:cb248499b0bc3be66ebd6578b83e5acacf1d6cb2a77f2248ce0e40fbec5a76d0", size = 6366952, upload-time = "2025-09-09T15:58:10.096Z" }, + { url = "https://files.pythonhosted.org/packages/70/5f/1816f4d08f3b8f66576d8433a66f8fa35a5acfb3bbd0bf6c31183b003f3d/numpy-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:691808c2b26b0f002a032c73255d0bd89751425f379f7bcd22d140db593a96e8", size = 12919322, upload-time = "2025-09-09T15:58:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/072420342e46a8ea41c324a555fa90fcc11637583fb8df722936aed1736d/numpy-2.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:9ad12e976ca7b10f1774b03615a2a4bab8addce37ecc77394d8e986927dc0dfe", size = 10478630, upload-time = "2025-09-09T15:58:14.64Z" }, + { url = "https://files.pythonhosted.org/packages/d5/df/ee2f1c0a9de7347f14da5dd3cd3c3b034d1b8607ccb6883d7dd5c035d631/numpy-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9cc48e09feb11e1db00b320e9d30a4151f7369afb96bd0e48d942d09da3a0d00", size = 21047987, upload-time = "2025-09-09T15:58:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/9453bdc5a4e9e69cf4358463f25e8260e2ffc126d52e10038b9077815989/numpy-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:901bf6123879b7f251d3631967fd574690734236075082078e0571977c6a8e6a", size = 14301076, upload-time = "2025-09-09T15:58:20.343Z" }, + { url = "https://files.pythonhosted.org/packages/13/77/1447b9eb500f028bb44253105bd67534af60499588a5149a94f18f2ca917/numpy-2.3.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f025652034199c301049296b59fa7d52c7e625017cae4c75d8662e377bf487d", size = 5229491, upload-time = "2025-09-09T15:58:22.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f9/d72221b6ca205f9736cb4b2ce3b002f6e45cd67cd6a6d1c8af11a2f0b649/numpy-2.3.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:533ca5f6d325c80b6007d4d7fb1984c303553534191024ec6a524a4c92a5935a", size = 6737913, upload-time = "2025-09-09T15:58:24.569Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/d12834711962ad9c46af72f79bb31e73e416ee49d17f4c797f72c96b6ca5/numpy-2.3.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edd58682a399824633b66885d699d7de982800053acf20be1eaa46d92009c54", size = 14352811, upload-time = "2025-09-09T15:58:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0d/fdbec6629d97fd1bebed56cd742884e4eead593611bbe1abc3eb40d304b2/numpy-2.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:367ad5d8fbec5d9296d18478804a530f1191e24ab4d75ab408346ae88045d25e", size = 16702689, upload-time = "2025-09-09T15:58:28.831Z" }, + { url = "https://files.pythonhosted.org/packages/9b/09/0a35196dc5575adde1eb97ddfbc3e1687a814f905377621d18ca9bc2b7dd/numpy-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f6ac61a217437946a1fa48d24c47c91a0c4f725237871117dea264982128097", size = 16133855, upload-time = "2025-09-09T15:58:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ca/c9de3ea397d576f1b6753eaa906d4cdef1bf97589a6d9825a349b4729cc2/numpy-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:179a42101b845a816d464b6fe9a845dfaf308fdfc7925387195570789bb2c970", size = 18652520, upload-time = "2025-09-09T15:58:33.762Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c2/e5ed830e08cd0196351db55db82f65bc0ab05da6ef2b72a836dcf1936d2f/numpy-2.3.3-cp314-cp314t-win32.whl", hash = "sha256:1250c5d3d2562ec4174bce2e3a1523041595f9b651065e4a4473f5f48a6bc8a5", size = 6515371, upload-time = "2025-09-09T15:58:36.04Z" }, + { url = "https://files.pythonhosted.org/packages/47/c7/b0f6b5b67f6788a0725f744496badbb604d226bf233ba716683ebb47b570/numpy-2.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b37a0b2e5935409daebe82c1e42274d30d9dd355852529eab91dab8dcca7419f", size = 13112576, upload-time = "2025-09-09T15:58:37.927Z" }, + { url = "https://files.pythonhosted.org/packages/06/b9/33bba5ff6fb679aa0b1f8a07e853f002a6b04b9394db3069a1270a7784ca/numpy-2.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:78c9f6560dc7e6b3990e32df7ea1a50bbd0e2a111e05209963f5ddcab7073b0b", size = 10545953, upload-time = "2025-09-09T15:58:40.576Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f2/7e0a37cfced2644c9563c529f29fa28acbd0960dde32ece683aafa6f4949/numpy-2.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1e02c7159791cd481e1e6d5ddd766b62a4d5acf8df4d4d1afe35ee9c5c33a41e", size = 21131019, upload-time = "2025-09-09T15:58:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/3291f505297ed63831135a6cc0f474da0c868a1f31b0dd9a9f03a7a0d2ed/numpy-2.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:dca2d0fc80b3893ae72197b39f69d55a3cd8b17ea1b50aa4c62de82419936150", size = 14376288, upload-time = "2025-09-09T15:58:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4b/ae02e985bdeee73d7b5abdefeb98aef1207e96d4c0621ee0cf228ddfac3c/numpy-2.3.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:99683cbe0658f8271b333a1b1b4bb3173750ad59c0c61f5bbdc5b318918fffe3", size = 5305425, upload-time = "2025-09-09T15:58:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/8b/eb/9df215d6d7250db32007941500dc51c48190be25f2401d5b2b564e467247/numpy-2.3.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d9d537a39cc9de668e5cd0e25affb17aec17b577c6b3ae8a3d866b479fbe88d0", size = 6819053, upload-time = "2025-09-09T15:58:50.401Z" }, + { url = "https://files.pythonhosted.org/packages/57/62/208293d7d6b2a8998a4a1f23ac758648c3c32182d4ce4346062018362e29/numpy-2.3.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8596ba2f8af5f93b01d97563832686d20206d303024777f6dfc2e7c7c3f1850e", size = 14420354, upload-time = "2025-09-09T15:58:52.704Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0c/8e86e0ff7072e14a71b4c6af63175e40d1e7e933ce9b9e9f765a95b4e0c3/numpy-2.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1ec5615b05369925bd1125f27df33f3b6c8bc10d788d5999ecd8769a1fa04db", size = 16760413, upload-time = "2025-09-09T15:58:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/af/11/0cc63f9f321ccf63886ac203336777140011fb669e739da36d8db3c53b98/numpy-2.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2e267c7da5bf7309670523896df97f93f6e469fb931161f483cd6882b3b1a5dc", size = 12971844, upload-time = "2025-09-09T15:58:57.359Z" }, ] [[package]] @@ -1381,7 +1648,7 @@ name = "nvidia-cublas-cu12" version = "12.8.4.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921 }, + { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, ] [[package]] @@ -1389,7 +1656,7 @@ name = "nvidia-cuda-cupti-cu12" version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621 }, + { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, ] [[package]] @@ -1397,7 +1664,7 @@ name = "nvidia-cuda-nvrtc-cu12" version = "12.8.93" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029 }, + { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, ] [[package]] @@ -1405,7 +1672,7 @@ name = "nvidia-cuda-runtime-cu12" version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765 }, + { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, ] [[package]] @@ -1416,7 +1683,7 @@ dependencies = [ { name = "nvidia-cublas-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467 }, + { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, ] [[package]] @@ -1427,7 +1694,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695 }, + { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, ] [[package]] @@ -1435,7 +1702,7 @@ name = "nvidia-cufile-cu12" version = "1.13.1.3" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834 }, + { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, ] [[package]] @@ -1443,7 +1710,7 @@ name = "nvidia-curand-cu12" version = "10.3.9.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976 }, + { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, ] [[package]] @@ -1456,7 +1723,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905 }, + { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, ] [[package]] @@ -1467,7 +1734,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466 }, + { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, ] [[package]] @@ -1475,7 +1742,7 @@ name = "nvidia-cusparselt-cu12" version = "0.7.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691 }, + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, ] [[package]] @@ -1483,7 +1750,7 @@ name = "nvidia-nccl-cu12" version = "2.27.3" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/5b/4e4fff7bad39adf89f735f2bc87248c81db71205b62bcc0d5ca5b606b3c3/nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adf27ccf4238253e0b826bce3ff5fa532d65fc42322c8bfdfaf28024c0fbe039", size = 322364134 }, + { url = "https://files.pythonhosted.org/packages/5c/5b/4e4fff7bad39adf89f735f2bc87248c81db71205b62bcc0d5ca5b606b3c3/nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adf27ccf4238253e0b826bce3ff5fa532d65fc42322c8bfdfaf28024c0fbe039", size = 322364134, upload-time = "2025-06-03T21:58:04.013Z" }, ] [[package]] @@ -1491,7 +1758,7 @@ name = "nvidia-nvjitlink-cu12" version = "12.8.93" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836 }, + { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, ] [[package]] @@ -1499,16 +1766,16 @@ name = "nvidia-nvtx-cu12" version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954 }, + { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, ] [[package]] name = "opt-einsum" version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/b9/2ac072041e899a52f20cf9510850ff58295003aa75525e58343591b0cbfb/opt_einsum-3.4.0.tar.gz", hash = "sha256:96ca72f1b886d148241348783498194c577fa30a8faac108586b14f1ba4473ac", size = 63004 } +sdist = { url = "https://files.pythonhosted.org/packages/8c/b9/2ac072041e899a52f20cf9510850ff58295003aa75525e58343591b0cbfb/opt_einsum-3.4.0.tar.gz", hash = "sha256:96ca72f1b886d148241348783498194c577fa30a8faac108586b14f1ba4473ac", size = 63004, upload-time = "2024-09-26T14:33:24.483Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl", hash = "sha256:69bb92469f86a1565195ece4ac0323943e83477171b91d24c35afe028a90d7cd", size = 71932 }, + { url = "https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl", hash = "sha256:69bb92469f86a1565195ece4ac0323943e83477171b91d24c35afe028a90d7cd", size = 71932, upload-time = "2024-09-26T14:33:23.039Z" }, ] [[package]] @@ -1518,56 +1785,63 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/c7/0853e0c59b135dff770615d2713b547b6b3b5cde7c10995b4a5825244612/optree-0.17.0.tar.gz", hash = "sha256:5335a5ec44479920620d72324c66563bd705ab2a698605dd4b6ee67dbcad7ecd", size = 163111 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/f9/6ca076fd4c6f16be031afdc711a2676c1ff15bd1717ee2e699179b1a29bc/optree-0.17.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98990201f352dba253af1a995c1453818db5f08de4cae7355d85aa6023676a52", size = 350398 }, - { url = "https://files.pythonhosted.org/packages/95/4c/81344cbdcf8ea8525a21c9d65892d7529010ee2146c53423b2e9a84441ba/optree-0.17.0-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:e1a40adf6bb78a6a4b4f480879de2cb6b57d46d680a4d9834aa824f41e69c0d9", size = 404834 }, - { url = "https://files.pythonhosted.org/packages/e5/c4/ac1880372a89f5c21514a7965dfa23b1afb2ad683fb9804d366727de9ecf/optree-0.17.0-cp310-cp310-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:78a113436a0a440f900b2799584f3cc2b2eea1b245d81c3583af42ac003e333c", size = 402116 }, - { url = "https://files.pythonhosted.org/packages/ff/72/ad6be4d6a03805cf3921b492494cb3371ca28060d5ad19d5a36e10c4d67d/optree-0.17.0-cp310-cp310-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e45c16018f4283f028cf839b707b7ac734e8056a31b7198a1577161fcbe146d", size = 398491 }, - { url = "https://files.pythonhosted.org/packages/d9/c1/6827fb504351f9a3935699b0eb31c8a6af59d775ee78289a25e0ba54f732/optree-0.17.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b698613d821d80cc216a2444ebc3145c8bf671b55a2223058a6574c1483a65f6", size = 387957 }, - { url = "https://files.pythonhosted.org/packages/73/5c/13a2a864b0c0b39c3c193be534a195a3ab2463c7d0443d4a76e749e3ff83/optree-0.17.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3080c564c9760711aa72d1b4d700ce1417f99ad087136f415c4eb8221169e2a3", size = 362797 }, - { url = "https://files.pythonhosted.org/packages/da/f5/ff7dcb5a0108ee89c2be09aed2ebd26a7e1333d8122031aa9d9322b24ee6/optree-0.17.0-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:834a8fb358b608240b3a38706a09b43974675624485fad64c8ee641dae2eb57d", size = 419450 }, - { url = "https://files.pythonhosted.org/packages/1b/e6/48a97aefd18770b55e5ed456d8183891f325cdb6d90592e5f072ed6951f8/optree-0.17.0-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a2bd263e6b5621d000d0f94de1f245414fd5dbce365a24b7b89b1ed0ef56cf9", size = 417557 }, - { url = "https://files.pythonhosted.org/packages/c4/b1/4e280edab8a86be47ec1f9bd9ed4b685d2e15f0950ae62b613b26d12a1da/optree-0.17.0-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9b37daca4ad89339b1f5320cc61ac600dcf976adbb060769d36d5542d6ebfedf", size = 414174 }, - { url = "https://files.pythonhosted.org/packages/db/3b/49a9a1986215dd342525974deeb17c260a83fee8fad147276fd710ac8718/optree-0.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a146a6917f3e28cfdc268ff1770aa696c346482dd3da681c3ff92153d94450ea", size = 402000 }, - { url = "https://files.pythonhosted.org/packages/41/90/e12dea2cb5d8a5e17bbe3011ed4e972b89c027272a816db4897589751cad/optree-0.17.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e13ae51a63d69db445f269a3a4fd1d6edb064a705188d007ea47c9f034788fc5", size = 365869 }, - { url = "https://files.pythonhosted.org/packages/76/ee/21af214663960a479863cd6c03d7a0abc8123ea22a6ea34689c2eed88ccd/optree-0.17.0-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:5958f58423cc7870cb011c8c8f92687397380886e8c9d33adac752147e7bbc3f", size = 424465 }, - { url = "https://files.pythonhosted.org/packages/54/a3/64b184a79373753f4f46a5cd301ea581f71d6dc1a5c103bd2394f0925d40/optree-0.17.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:970ae4e47727b4c5526fc583b87d29190e576f6a2b6c19e8671589b73d256250", size = 420686 }, - { url = "https://files.pythonhosted.org/packages/6c/6d/b6051b0b1ef9a49df96a66e9e62fc02620d2115d1ba659888c94e67fcfc9/optree-0.17.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54177fd3e6e05c08b66329e26d7d44b85f24125f25c6b74c921499a1b31b8f70", size = 421225 }, - { url = "https://files.pythonhosted.org/packages/f6/f1/940bc959aaef9eede8bb1b1127833b0929c6ffa9268ec0f6cb19877e2027/optree-0.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1959cfbc38c228c8195354967cda64887b96219924b7b3759e5ee355582c1ec", size = 408819 }, - { url = "https://files.pythonhosted.org/packages/dd/12/24d4a417fd325ec06cfbce52716ac4f816ef696653b868960ac2ccb28436/optree-0.17.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfeea4aa0fd354d27922aba63ff9d86e4e126c6bf89cfb02849e68515519f1a5", size = 368513 }, - { url = "https://files.pythonhosted.org/packages/30/e2/34e392209933e2c582c67594a7a6b4851bca4015c83b51c7508384b616b4/optree-0.17.0-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6b2ff8999a9b84d00f23a032b6b3f13678894432a335d024e0670b9880f238ca", size = 430378 }, - { url = "https://files.pythonhosted.org/packages/5f/16/0a0d6139022e9a53ecb1212fb6fbc5b60eff824371071ef5f5fa481d8167/optree-0.17.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea8bef525432b38a84e7448348da1a2dc308375bce79c77675cc50a501305851", size = 423294 }, - { url = "https://files.pythonhosted.org/packages/ef/60/2e083dabb6aff6d939d8aab16ba3dbe6eee9429597a13f3fca57b33cdcde/optree-0.17.0-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f95b81aa67538d38316b184a6ff39a3725ee5c8555fba21dcb692f8d7c39302e", size = 424633 }, - { url = "https://files.pythonhosted.org/packages/af/fd/0e4229b5fa3fd9d3c779a606c0f358ffbdfee717f49b3477facd04de2cec/optree-0.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e808a1125169ae90de623456ef2423eb84a8578a74f03fe48b06b8561c2cc31d", size = 414866 }, - { url = "https://files.pythonhosted.org/packages/39/df/b8882f5519c85af146de3a79a08066a56fe634b23052c593fcedc70bfcd7/optree-0.17.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e45a13b35873712e095fe0f7fd6e9c4f98f3bd5af6f5dc33c17b80357bc97fc", size = 386945 }, - { url = "https://files.pythonhosted.org/packages/ca/d7/91f4efb509bda601a1591465c4a5bd55320e4bafe06b294bf80754127b0e/optree-0.17.0-cp313-cp313t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:bfaf04d833dc53e5cfccff3b564e934a49086158472e31d84df31fce6d4f7b1c", size = 444177 }, - { url = "https://files.pythonhosted.org/packages/84/17/a4833006e925c6ed5c45ceb02e65c9e9a260e70da6523858fcf628481847/optree-0.17.0-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b4c1d030ac1c881803f5c8e23d241159ae403fd00cdf57625328f282fc671ebd", size = 439198 }, - { url = "https://files.pythonhosted.org/packages/ef/d1/c08fc60f6dfcb1b86ca1fdc0add08a98412a1596cd45830acbdc309f2cdb/optree-0.17.0-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd7738709970acab5d963896192b63b2718be93bb6c0bcea91895ea157fa2b13", size = 439391 }, - { url = "https://files.pythonhosted.org/packages/05/8f/461e10201003e6ad6bff3c594a29a7e044454aba68c5f795f4c8386ce47c/optree-0.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644bc24b6e93cafccfdeee44157c3d4ae9bb0af3e861300602d716699865b1a", size = 426555 }, - { url = "https://files.pythonhosted.org/packages/3c/21/6480d23b52b2e23b976fe254b9fbdc4b514e90a349b1ee73565b185c69f1/optree-0.17.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd21e0a89806cc3b86aaa578a73897d56085038fe432043534a23b2e559d7691", size = 369929 }, - { url = "https://files.pythonhosted.org/packages/b3/29/69bb26473ff862a1792f5568c977e7a2580e08afe0fdcd7a7b3e1e4d6933/optree-0.17.0-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:9211c61285b8b3e42fd0e803cebd6e2b0987d8b2edffe45b42923debca09a9df", size = 430381 }, - { url = "https://files.pythonhosted.org/packages/c8/8b/2c0a38c0d0c2396d698b97216cd6814d6754d11997b6ac66c57d87d71bae/optree-0.17.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87938255749a45979c4e331627cb33d81aa08b0a09d024368b3e25ff67f0e9f2", size = 424461 }, - { url = "https://files.pythonhosted.org/packages/a7/77/08fda3f97621190d50762225ee8bad87463a8b3a55fba451a999971ff130/optree-0.17.0-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3432858145fd1955a3be12207507466ac40a6911f428bf5d2d6c7f67486530a2", size = 427234 }, - { url = "https://files.pythonhosted.org/packages/ea/b5/b4f19952c36d6448c85a6ef6be5f916dd13548de2b684ab123f04b450850/optree-0.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5afe3e9e2f6da0a0a5c0892f32f675eb88965036b061aa555b74e6c412a05e17", size = 413863 }, - { url = "https://files.pythonhosted.org/packages/88/42/6003f13e66cfbe7f0011bf8509da2479aba93068cdb9d79bf46010255089/optree-0.17.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5739c03a3362be42cb7649e82457c90aa818aa3e82af9681d3100c3346f4a90f", size = 386975 }, - { url = "https://files.pythonhosted.org/packages/d0/53/621642abd76eda5a941b47adc98be81f0052683160be776499d11b4af83d/optree-0.17.0-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:ee07b59a08bd45aedd5252241a98841f1a5082a7b9b73df2dae6a433aa2a91d8", size = 444173 }, - { url = "https://files.pythonhosted.org/packages/5b/d3/8819a2d5105a240d6793d11a61d597db91756ce84da5cee08808c6b8f61f/optree-0.17.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:875c017890a4b5d566af5593cab67fe3c4845544942af57e6bb9dea17e060297", size = 439080 }, - { url = "https://files.pythonhosted.org/packages/c6/ef/9dbd34dfd1ad89feb239ca9925897a14ac94f190379a3bd991afdfd94186/optree-0.17.0-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ffa5686191139f763e13445a169765c83517164bc28e60dbedb19bed2b2655f1", size = 439422 }, - { url = "https://files.pythonhosted.org/packages/86/ca/a7a7549af2951925a692df508902ed2a6a94a51bc846806d2281b1029ef9/optree-0.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:575cf48cc2190acb565bd2b26b6f9b15c4e3b60183e86031215badc9d5441345", size = 426579 }, - { url = "https://files.pythonhosted.org/packages/ed/d7/3036d15c028c447b1bd65dcf8f66cfd775bfa4e52daa74b82fb1d3c88faf/optree-0.17.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adde1427e0982cfc5f56939c26b4ebbd833091a176734c79fb95c78bdf833dff", size = 350952 }, - { url = "https://files.pythonhosted.org/packages/71/45/e710024ef77324e745de48efd64f6270d8c209f14107a48ffef4049ac57a/optree-0.17.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a80b7e5de5dd09b9c8b62d501e29a3850b047565c336c9d004b07ee1c01f4ae1", size = 389568 }, - { url = "https://files.pythonhosted.org/packages/69/c4/94a187ed3ca71194b9da6a276790e1703c7544c8f695ac915214ae8ce934/optree-0.17.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f87f6f39015fc82d7adeee19900d246b89911319726e93cb2dbd4d1a809899bd", size = 363728 }, - { url = "https://files.pythonhosted.org/packages/cd/99/23b7a484da8dfb814107b20ef2c93ef27c04f36aeb83bd976964a5b69e06/optree-0.17.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58b0a83a967d2ef0f343db7182f0ad074eb1166bcaea909ae33909462013f151", size = 404649 }, +sdist = { url = "https://files.pythonhosted.org/packages/56/c7/0853e0c59b135dff770615d2713b547b6b3b5cde7c10995b4a5825244612/optree-0.17.0.tar.gz", hash = "sha256:5335a5ec44479920620d72324c66563bd705ab2a698605dd4b6ee67dbcad7ecd", size = 163111, upload-time = "2025-07-25T11:26:11.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f9/6ca076fd4c6f16be031afdc711a2676c1ff15bd1717ee2e699179b1a29bc/optree-0.17.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98990201f352dba253af1a995c1453818db5f08de4cae7355d85aa6023676a52", size = 350398, upload-time = "2025-07-25T11:24:26.672Z" }, + { url = "https://files.pythonhosted.org/packages/95/4c/81344cbdcf8ea8525a21c9d65892d7529010ee2146c53423b2e9a84441ba/optree-0.17.0-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:e1a40adf6bb78a6a4b4f480879de2cb6b57d46d680a4d9834aa824f41e69c0d9", size = 404834, upload-time = "2025-07-25T11:24:28.988Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c4/ac1880372a89f5c21514a7965dfa23b1afb2ad683fb9804d366727de9ecf/optree-0.17.0-cp310-cp310-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:78a113436a0a440f900b2799584f3cc2b2eea1b245d81c3583af42ac003e333c", size = 402116, upload-time = "2025-07-25T11:24:30.396Z" }, + { url = "https://files.pythonhosted.org/packages/ff/72/ad6be4d6a03805cf3921b492494cb3371ca28060d5ad19d5a36e10c4d67d/optree-0.17.0-cp310-cp310-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e45c16018f4283f028cf839b707b7ac734e8056a31b7198a1577161fcbe146d", size = 398491, upload-time = "2025-07-25T11:24:31.725Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c1/6827fb504351f9a3935699b0eb31c8a6af59d775ee78289a25e0ba54f732/optree-0.17.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b698613d821d80cc216a2444ebc3145c8bf671b55a2223058a6574c1483a65f6", size = 387957, upload-time = "2025-07-25T11:24:32.759Z" }, + { url = "https://files.pythonhosted.org/packages/73/5c/13a2a864b0c0b39c3c193be534a195a3ab2463c7d0443d4a76e749e3ff83/optree-0.17.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3080c564c9760711aa72d1b4d700ce1417f99ad087136f415c4eb8221169e2a3", size = 362797, upload-time = "2025-07-25T11:24:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/da/f5/ff7dcb5a0108ee89c2be09aed2ebd26a7e1333d8122031aa9d9322b24ee6/optree-0.17.0-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:834a8fb358b608240b3a38706a09b43974675624485fad64c8ee641dae2eb57d", size = 419450, upload-time = "2025-07-25T11:24:40.555Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e6/48a97aefd18770b55e5ed456d8183891f325cdb6d90592e5f072ed6951f8/optree-0.17.0-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a2bd263e6b5621d000d0f94de1f245414fd5dbce365a24b7b89b1ed0ef56cf9", size = 417557, upload-time = "2025-07-25T11:24:42.396Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/4e280edab8a86be47ec1f9bd9ed4b685d2e15f0950ae62b613b26d12a1da/optree-0.17.0-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9b37daca4ad89339b1f5320cc61ac600dcf976adbb060769d36d5542d6ebfedf", size = 414174, upload-time = "2025-07-25T11:24:43.51Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/49a9a1986215dd342525974deeb17c260a83fee8fad147276fd710ac8718/optree-0.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a146a6917f3e28cfdc268ff1770aa696c346482dd3da681c3ff92153d94450ea", size = 402000, upload-time = "2025-07-25T11:24:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/e12dea2cb5d8a5e17bbe3011ed4e972b89c027272a816db4897589751cad/optree-0.17.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e13ae51a63d69db445f269a3a4fd1d6edb064a705188d007ea47c9f034788fc5", size = 365869, upload-time = "2025-07-25T11:24:51.807Z" }, + { url = "https://files.pythonhosted.org/packages/76/ee/21af214663960a479863cd6c03d7a0abc8123ea22a6ea34689c2eed88ccd/optree-0.17.0-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:5958f58423cc7870cb011c8c8f92687397380886e8c9d33adac752147e7bbc3f", size = 424465, upload-time = "2025-07-25T11:24:53.124Z" }, + { url = "https://files.pythonhosted.org/packages/54/a3/64b184a79373753f4f46a5cd301ea581f71d6dc1a5c103bd2394f0925d40/optree-0.17.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:970ae4e47727b4c5526fc583b87d29190e576f6a2b6c19e8671589b73d256250", size = 420686, upload-time = "2025-07-25T11:24:54.212Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/b6051b0b1ef9a49df96a66e9e62fc02620d2115d1ba659888c94e67fcfc9/optree-0.17.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54177fd3e6e05c08b66329e26d7d44b85f24125f25c6b74c921499a1b31b8f70", size = 421225, upload-time = "2025-07-25T11:24:55.213Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f1/940bc959aaef9eede8bb1b1127833b0929c6ffa9268ec0f6cb19877e2027/optree-0.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1959cfbc38c228c8195354967cda64887b96219924b7b3759e5ee355582c1ec", size = 408819, upload-time = "2025-07-25T11:24:56.315Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/9706d11b880186e9e9d66d7c21ce249b2ce0212645137cc13fdd18247c26/optree-0.17.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5995a3efce4b00a14049268a81ab0379656a41ddf3c3761e3b88937fca44d48", size = 348177, upload-time = "2025-07-25T11:25:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4b/0415c18816818ac871c9f3d5c7c5f4ceb83baff03ed511c9c94591ace4bc/optree-0.17.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d06e8143d16fe6c0708f3cc2807b5b65f815d60ee2b52f3d79e4022c95563482", size = 354389, upload-time = "2025-07-25T11:25:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/dd/12/24d4a417fd325ec06cfbce52716ac4f816ef696653b868960ac2ccb28436/optree-0.17.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfeea4aa0fd354d27922aba63ff9d86e4e126c6bf89cfb02849e68515519f1a5", size = 368513, upload-time = "2025-07-25T11:25:05.548Z" }, + { url = "https://files.pythonhosted.org/packages/30/e2/34e392209933e2c582c67594a7a6b4851bca4015c83b51c7508384b616b4/optree-0.17.0-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6b2ff8999a9b84d00f23a032b6b3f13678894432a335d024e0670b9880f238ca", size = 430378, upload-time = "2025-07-25T11:25:06.918Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/0a0d6139022e9a53ecb1212fb6fbc5b60eff824371071ef5f5fa481d8167/optree-0.17.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea8bef525432b38a84e7448348da1a2dc308375bce79c77675cc50a501305851", size = 423294, upload-time = "2025-07-25T11:25:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/2e083dabb6aff6d939d8aab16ba3dbe6eee9429597a13f3fca57b33cdcde/optree-0.17.0-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f95b81aa67538d38316b184a6ff39a3725ee5c8555fba21dcb692f8d7c39302e", size = 424633, upload-time = "2025-07-25T11:25:09.141Z" }, + { url = "https://files.pythonhosted.org/packages/af/fd/0e4229b5fa3fd9d3c779a606c0f358ffbdfee717f49b3477facd04de2cec/optree-0.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e808a1125169ae90de623456ef2423eb84a8578a74f03fe48b06b8561c2cc31d", size = 414866, upload-time = "2025-07-25T11:25:10.214Z" }, + { url = "https://files.pythonhosted.org/packages/39/df/b8882f5519c85af146de3a79a08066a56fe634b23052c593fcedc70bfcd7/optree-0.17.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e45a13b35873712e095fe0f7fd6e9c4f98f3bd5af6f5dc33c17b80357bc97fc", size = 386945, upload-time = "2025-07-25T11:25:17.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d7/91f4efb509bda601a1591465c4a5bd55320e4bafe06b294bf80754127b0e/optree-0.17.0-cp313-cp313t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:bfaf04d833dc53e5cfccff3b564e934a49086158472e31d84df31fce6d4f7b1c", size = 444177, upload-time = "2025-07-25T11:25:18.749Z" }, + { url = "https://files.pythonhosted.org/packages/84/17/a4833006e925c6ed5c45ceb02e65c9e9a260e70da6523858fcf628481847/optree-0.17.0-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b4c1d030ac1c881803f5c8e23d241159ae403fd00cdf57625328f282fc671ebd", size = 439198, upload-time = "2025-07-25T11:25:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d1/c08fc60f6dfcb1b86ca1fdc0add08a98412a1596cd45830acbdc309f2cdb/optree-0.17.0-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd7738709970acab5d963896192b63b2718be93bb6c0bcea91895ea157fa2b13", size = 439391, upload-time = "2025-07-25T11:25:20.942Z" }, + { url = "https://files.pythonhosted.org/packages/05/8f/461e10201003e6ad6bff3c594a29a7e044454aba68c5f795f4c8386ce47c/optree-0.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644bc24b6e93cafccfdeee44157c3d4ae9bb0af3e861300602d716699865b1a", size = 426555, upload-time = "2025-07-25T11:25:21.968Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/6480d23b52b2e23b976fe254b9fbdc4b514e90a349b1ee73565b185c69f1/optree-0.17.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd21e0a89806cc3b86aaa578a73897d56085038fe432043534a23b2e559d7691", size = 369929, upload-time = "2025-07-25T11:25:28.897Z" }, + { url = "https://files.pythonhosted.org/packages/b3/29/69bb26473ff862a1792f5568c977e7a2580e08afe0fdcd7a7b3e1e4d6933/optree-0.17.0-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:9211c61285b8b3e42fd0e803cebd6e2b0987d8b2edffe45b42923debca09a9df", size = 430381, upload-time = "2025-07-25T11:25:29.984Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/2c0a38c0d0c2396d698b97216cd6814d6754d11997b6ac66c57d87d71bae/optree-0.17.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87938255749a45979c4e331627cb33d81aa08b0a09d024368b3e25ff67f0e9f2", size = 424461, upload-time = "2025-07-25T11:25:31.116Z" }, + { url = "https://files.pythonhosted.org/packages/a7/77/08fda3f97621190d50762225ee8bad87463a8b3a55fba451a999971ff130/optree-0.17.0-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3432858145fd1955a3be12207507466ac40a6911f428bf5d2d6c7f67486530a2", size = 427234, upload-time = "2025-07-25T11:25:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b5/b4f19952c36d6448c85a6ef6be5f916dd13548de2b684ab123f04b450850/optree-0.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5afe3e9e2f6da0a0a5c0892f32f675eb88965036b061aa555b74e6c412a05e17", size = 413863, upload-time = "2025-07-25T11:25:33.379Z" }, + { url = "https://files.pythonhosted.org/packages/88/42/6003f13e66cfbe7f0011bf8509da2479aba93068cdb9d79bf46010255089/optree-0.17.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5739c03a3362be42cb7649e82457c90aa818aa3e82af9681d3100c3346f4a90f", size = 386975, upload-time = "2025-07-25T11:25:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/d0/53/621642abd76eda5a941b47adc98be81f0052683160be776499d11b4af83d/optree-0.17.0-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:ee07b59a08bd45aedd5252241a98841f1a5082a7b9b73df2dae6a433aa2a91d8", size = 444173, upload-time = "2025-07-25T11:25:41.474Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d3/8819a2d5105a240d6793d11a61d597db91756ce84da5cee08808c6b8f61f/optree-0.17.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:875c017890a4b5d566af5593cab67fe3c4845544942af57e6bb9dea17e060297", size = 439080, upload-time = "2025-07-25T11:25:42.605Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ef/9dbd34dfd1ad89feb239ca9925897a14ac94f190379a3bd991afdfd94186/optree-0.17.0-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ffa5686191139f763e13445a169765c83517164bc28e60dbedb19bed2b2655f1", size = 439422, upload-time = "2025-07-25T11:25:43.672Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/a7a7549af2951925a692df508902ed2a6a94a51bc846806d2281b1029ef9/optree-0.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:575cf48cc2190acb565bd2b26b6f9b15c4e3b60183e86031215badc9d5441345", size = 426579, upload-time = "2025-07-25T11:25:44.765Z" }, + { url = "https://files.pythonhosted.org/packages/1d/29/3bb53de2de3b36a51e46b6d9ada7ee1a3a312ac461cd54292a023adc807c/optree-0.17.0-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:537498cf7bf7a4fe71f7ffd815e72b8672aea0fac82e1513f6b6e35e8569f5aa", size = 350302, upload-time = "2025-07-25T11:25:52.016Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3b/d17a31447ed7ef6f10bd0caf40742b016fcdeaa3abb7568307b04a0f50cf/optree-0.17.0-cp39-cp39-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:3b3bb2326b550ddb048e3454fad40183b7fed74dda4351b016d20362809180af", size = 405358, upload-time = "2025-07-25T11:25:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/db/f3/b9f0a8c98fd0c7f53fa9d9a46d75bb1182aeecd7ecde6f353d3e69ec9618/optree-0.17.0-cp39-cp39-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c0d3d702044e5acbec2cf8349789f6b096057bd00dc8e1e1c97b990347279fda", size = 402694, upload-time = "2025-07-25T11:25:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dd/0d9d7426fd6b5d90ad40e4d93717a955d4257d06574dfe7a1da0d24cb06c/optree-0.17.0-cp39-cp39-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9155e82717be1dda1f3c1244e9cb5b3733d5dd3ba47702730c7816be083a5cb", size = 398857, upload-time = "2025-07-25T11:25:55.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/57/dacec3f8c70f4685bb07fce19cf3361037fde2b596f6f7228e1a4b39677b/optree-0.17.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e825501f55360e8381718623b094579dedc485e57010e01593d72a43b43e68", size = 387849, upload-time = "2025-07-25T11:25:57.046Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d7/3036d15c028c447b1bd65dcf8f66cfd775bfa4e52daa74b82fb1d3c88faf/optree-0.17.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adde1427e0982cfc5f56939c26b4ebbd833091a176734c79fb95c78bdf833dff", size = 350952, upload-time = "2025-07-25T11:26:02.692Z" }, + { url = "https://files.pythonhosted.org/packages/71/45/e710024ef77324e745de48efd64f6270d8c209f14107a48ffef4049ac57a/optree-0.17.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a80b7e5de5dd09b9c8b62d501e29a3850b047565c336c9d004b07ee1c01f4ae1", size = 389568, upload-time = "2025-07-25T11:26:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/94a187ed3ca71194b9da6a276790e1703c7544c8f695ac915214ae8ce934/optree-0.17.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f87f6f39015fc82d7adeee19900d246b89911319726e93cb2dbd4d1a809899bd", size = 363728, upload-time = "2025-07-25T11:26:07.959Z" }, + { url = "https://files.pythonhosted.org/packages/cd/99/23b7a484da8dfb814107b20ef2c93ef27c04f36aeb83bd976964a5b69e06/optree-0.17.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58b0a83a967d2ef0f343db7182f0ad074eb1166bcaea909ae33909462013f151", size = 404649, upload-time = "2025-07-25T11:26:09.463Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] @@ -1575,172 +1849,191 @@ name = "pandas" version = "2.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "python-dateutil" }, { name = "pytz" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763 }, - { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217 }, - { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791 }, - { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373 }, - { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444 }, - { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459 }, - { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086 }, - { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790 }, - { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831 }, - { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267 }, - { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281 }, - { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453 }, - { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361 }, - { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702 }, - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846 }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618 }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212 }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693 }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002 }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971 }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722 }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671 }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807 }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872 }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371 }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333 }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120 }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991 }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227 }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056 }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189 }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912 }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160 }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233 }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635 }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079 }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049 }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638 }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834 }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925 }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071 }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504 }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702 }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535 }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582 }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963 }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175 }, +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, + { url = "https://files.pythonhosted.org/packages/56/b4/52eeb530a99e2a4c55ffcd352772b599ed4473a0f892d127f4147cf0f88e/pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2", size = 11567720, upload-time = "2025-09-29T23:33:06.209Z" }, + { url = "https://files.pythonhosted.org/packages/48/4a/2d8b67632a021bced649ba940455ed441ca854e57d6e7658a6024587b083/pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8", size = 10810302, upload-time = "2025-09-29T23:33:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/d2465010ee0569a245c975dc6967b801887068bc893e908239b1f4b6c1ac/pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff", size = 12154874, upload-time = "2025-09-29T23:33:49.939Z" }, + { url = "https://files.pythonhosted.org/packages/1f/18/aae8c0aa69a386a3255940e9317f793808ea79d0a525a97a903366bb2569/pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29", size = 12790141, upload-time = "2025-09-29T23:34:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/f7/26/617f98de789de00c2a444fbe6301bb19e66556ac78cff933d2c98f62f2b4/pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73", size = 13208697, upload-time = "2025-09-29T23:34:21.835Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fb/25709afa4552042bd0e15717c75e9b4a2294c3dc4f7e6ea50f03c5136600/pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9", size = 13879233, upload-time = "2025-09-29T23:34:35.079Z" }, + { url = "https://files.pythonhosted.org/packages/98/af/7be05277859a7bc399da8ba68b88c96b27b48740b6cf49688899c6eb4176/pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa", size = 11359119, upload-time = "2025-09-29T23:34:46.339Z" }, ] [[package]] name = "pillow" version = "11.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554 }, - { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548 }, - { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742 }, - { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087 }, - { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350 }, - { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840 }, - { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005 }, - { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372 }, - { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090 }, - { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988 }, - { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899 }, - { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531 }, - { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560 }, - { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978 }, - { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168 }, - { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053 }, - { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273 }, - { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043 }, - { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516 }, - { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768 }, - { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055 }, - { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079 }, - { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800 }, - { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296 }, - { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726 }, - { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652 }, - { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787 }, - { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236 }, - { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950 }, - { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358 }, - { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079 }, - { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324 }, - { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067 }, - { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328 }, - { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652 }, - { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443 }, - { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474 }, - { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038 }, - { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407 }, - { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094 }, - { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503 }, - { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574 }, - { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060 }, - { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407 }, - { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841 }, - { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450 }, - { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055 }, - { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110 }, - { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547 }, - { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554 }, - { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132 }, - { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001 }, - { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814 }, - { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124 }, - { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186 }, - { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546 }, - { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102 }, - { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803 }, - { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520 }, - { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116 }, - { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597 }, - { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246 }, - { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336 }, - { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699 }, - { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789 }, - { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386 }, - { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911 }, - { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383 }, - { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385 }, - { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129 }, - { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580 }, - { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860 }, - { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694 }, - { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888 }, - { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330 }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089 }, - { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206 }, - { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370 }, - { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500 }, - { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835 }, - { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556 }, - { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625 }, - { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207 }, - { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939 }, - { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166 }, - { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482 }, - { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596 }, - { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566 }, - { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618 }, - { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248 }, - { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963 }, - { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170 }, - { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505 }, - { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598 }, +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, + { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, + { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/9c089f01677d1264ab8648352dcb7773f37da6ad002542760c80107da816/pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f", size = 5316478, upload-time = "2025-07-01T09:15:52.209Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a9/5749930caf674695867eb56a581e78eb5f524b7583ff10b01b6e5048acb3/pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081", size = 4686522, upload-time = "2025-07-01T09:15:54.162Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/0b85b763eb292b691030795f9f6bb6fcaf8948c39413c81696a01c3577f7/pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4", size = 5853376, upload-time = "2025-07-03T13:11:01.066Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/1a230ec0067243cbd60bc2dad5dc3ab46a8a41e21c15f5c9b52b26873069/pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc", size = 7626020, upload-time = "2025-07-03T13:11:06.479Z" }, + { url = "https://files.pythonhosted.org/packages/63/dd/f296c27ffba447bfad76c6a0c44c1ea97a90cb9472b9304c94a732e8dbfb/pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06", size = 5956732, upload-time = "2025-07-01T09:15:56.111Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a0/98a3630f0b57f77bae67716562513d3032ae70414fcaf02750279c389a9e/pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a", size = 6624404, upload-time = "2025-07-01T09:15:58.245Z" }, + { url = "https://files.pythonhosted.org/packages/de/e6/83dfba5646a290edd9a21964da07674409e410579c341fc5b8f7abd81620/pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978", size = 6067760, upload-time = "2025-07-01T09:16:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/15ab268fe6ee9a2bc7391e2bbb20a98d3974304ab1a406a992dcb297a370/pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d", size = 6700534, upload-time = "2025-07-01T09:16:02.29Z" }, + { url = "https://files.pythonhosted.org/packages/64/79/6d4f638b288300bed727ff29f2a3cb63db054b33518a95f27724915e3fbc/pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71", size = 6277091, upload-time = "2025-07-01T09:16:04.4Z" }, + { url = "https://files.pythonhosted.org/packages/46/05/4106422f45a05716fd34ed21763f8ec182e8ea00af6e9cb05b93a247361a/pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada", size = 6986091, upload-time = "2025-07-01T09:16:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/287fd55c2c12761d0591549d48885187579b7c257bef0c6660755b0b59ae/pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb", size = 2422632, upload-time = "2025-07-01T09:16:08.142Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, + { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -1750,159 +2043,175 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/3e/35fcf5bf51404371bb172b289a5065778dc97adca4416e199c294125eb05/polars-1.34.0.tar.gz", hash = "sha256:5de5f871027db4b11bcf39215a2d6b13b4a80baf8a55c5862d4ebedfd5cd4013", size = 684309 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/3e/35fcf5bf51404371bb172b289a5065778dc97adca4416e199c294125eb05/polars-1.34.0.tar.gz", hash = "sha256:5de5f871027db4b11bcf39215a2d6b13b4a80baf8a55c5862d4ebedfd5cd4013", size = 684309, upload-time = "2025-10-02T18:31:04.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/80/1791ac226bb989bef30fe8fde752b2021b6ec5dfd6e880262596aedf4c05/polars-1.34.0-py3-none-any.whl", hash = "sha256:40d2f357b4d9e447ad28bd2c9923e4318791a7c18eb68f31f1fbf11180f41391", size = 772686 }, + { url = "https://files.pythonhosted.org/packages/6b/80/1791ac226bb989bef30fe8fde752b2021b6ec5dfd6e880262596aedf4c05/polars-1.34.0-py3-none-any.whl", hash = "sha256:40d2f357b4d9e447ad28bd2c9923e4318791a7c18eb68f31f1fbf11180f41391", size = 772686, upload-time = "2025-10-02T18:29:59.492Z" }, ] [package.optional-dependencies] pandas = [ { name = "pandas" }, - { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] pyarrow = [ - { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] [[package]] name = "polars-runtime-32" version = "1.34.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/10/1189afb14cc47ed215ccf7fbd00ed21c48edfd89e51c16f8628a33ae4b1b/polars_runtime_32-1.34.0.tar.gz", hash = "sha256:ebe6f865128a0d833f53a3f6828360761ad86d1698bceb22bef9fd999500dc1c", size = 2634491 } +sdist = { url = "https://files.pythonhosted.org/packages/02/10/1189afb14cc47ed215ccf7fbd00ed21c48edfd89e51c16f8628a33ae4b1b/polars_runtime_32-1.34.0.tar.gz", hash = "sha256:ebe6f865128a0d833f53a3f6828360761ad86d1698bceb22bef9fd999500dc1c", size = 2634491, upload-time = "2025-10-02T18:31:05.502Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/35/bc4f1a9dcef61845e8e4e5d2318470b002b93a3564026f0643f562761ecb/polars_runtime_32-1.34.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2878f9951e91121afe60c25433ef270b9a221e6ebf3de5f6642346b38cab3f03", size = 39655423 }, - { url = "https://files.pythonhosted.org/packages/a6/bb/d655a103e75b7c81c47a3c2d276be0200c0c15cfb6fd47f17932ddcf7519/polars_runtime_32-1.34.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:fbc329c7d34a924228cc5dcdbbd4696d94411a3a5b15ad8bb868634c204e1951", size = 35986049 }, - { url = "https://files.pythonhosted.org/packages/9e/ce/11ca850b7862cb43605e5d86cdf655614376e0a059871cf8305af5406554/polars_runtime_32-1.34.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93fa51d88a2d12ea996a5747aad5647d22a86cce73c80f208e61f487b10bc448", size = 40261269 }, - { url = "https://files.pythonhosted.org/packages/d8/25/77d12018c35489e19f7650b40679714a834effafc25d61e8dcee7c4fafce/polars_runtime_32-1.34.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:79e4d696392c6d8d51f4347f0b167c52eef303c9d87093c0c68e8651198735b7", size = 37049077 }, - { url = "https://files.pythonhosted.org/packages/e2/75/c30049d45ea1365151f86f650ed5354124ff3209f0abe588664c8eb13a31/polars_runtime_32-1.34.0-cp39-abi3-win_amd64.whl", hash = "sha256:2501d6b29d9001ea5ea2fd9b598787e10ddf45d8c4a87c2bead75159e8a15711", size = 40105782 }, - { url = "https://files.pythonhosted.org/packages/a3/31/84efa27aa3478c8670bac1a720c8b1aee5c58c9c657c980e5e5c47fde883/polars_runtime_32-1.34.0-cp39-abi3-win_arm64.whl", hash = "sha256:f9ed1765378dfe0bcd1ac5ec570dd9eab27ea728bbc980cc9a76eebc55586559", size = 35873216 }, + { url = "https://files.pythonhosted.org/packages/97/35/bc4f1a9dcef61845e8e4e5d2318470b002b93a3564026f0643f562761ecb/polars_runtime_32-1.34.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2878f9951e91121afe60c25433ef270b9a221e6ebf3de5f6642346b38cab3f03", size = 39655423, upload-time = "2025-10-02T18:30:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/a6/bb/d655a103e75b7c81c47a3c2d276be0200c0c15cfb6fd47f17932ddcf7519/polars_runtime_32-1.34.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:fbc329c7d34a924228cc5dcdbbd4696d94411a3a5b15ad8bb868634c204e1951", size = 35986049, upload-time = "2025-10-02T18:30:05.848Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ce/11ca850b7862cb43605e5d86cdf655614376e0a059871cf8305af5406554/polars_runtime_32-1.34.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93fa51d88a2d12ea996a5747aad5647d22a86cce73c80f208e61f487b10bc448", size = 40261269, upload-time = "2025-10-02T18:30:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/d8/25/77d12018c35489e19f7650b40679714a834effafc25d61e8dcee7c4fafce/polars_runtime_32-1.34.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:79e4d696392c6d8d51f4347f0b167c52eef303c9d87093c0c68e8651198735b7", size = 37049077, upload-time = "2025-10-02T18:30:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/e2/75/c30049d45ea1365151f86f650ed5354124ff3209f0abe588664c8eb13a31/polars_runtime_32-1.34.0-cp39-abi3-win_amd64.whl", hash = "sha256:2501d6b29d9001ea5ea2fd9b598787e10ddf45d8c4a87c2bead75159e8a15711", size = 40105782, upload-time = "2025-10-02T18:30:14.597Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/84efa27aa3478c8670bac1a720c8b1aee5c58c9c657c980e5e5c47fde883/polars_runtime_32-1.34.0-cp39-abi3-win_arm64.whl", hash = "sha256:f9ed1765378dfe0bcd1ac5ec570dd9eab27ea728bbc980cc9a76eebc55586559", size = 35873216, upload-time = "2025-10-02T18:30:17.439Z" }, ] [[package]] name = "propcache" version = "0.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178 }, - { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133 }, - { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039 }, - { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903 }, - { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362 }, - { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525 }, - { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283 }, - { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872 }, - { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452 }, - { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567 }, - { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015 }, - { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660 }, - { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105 }, - { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980 }, - { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679 }, - { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459 }, - { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207 }, - { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648 }, - { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496 }, - { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288 }, - { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456 }, - { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429 }, - { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472 }, - { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480 }, - { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530 }, - { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230 }, - { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754 }, - { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430 }, - { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884 }, - { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480 }, - { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757 }, - { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500 }, - { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674 }, - { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570 }, - { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094 }, - { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958 }, - { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894 }, - { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672 }, - { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395 }, - { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510 }, - { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949 }, - { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258 }, - { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036 }, - { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684 }, - { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562 }, - { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142 }, - { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711 }, - { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479 }, - { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286 }, - { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425 }, - { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846 }, - { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871 }, - { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720 }, - { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203 }, - { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365 }, - { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016 }, - { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596 }, - { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977 }, - { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220 }, - { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642 }, - { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789 }, - { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880 }, - { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220 }, - { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678 }, - { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560 }, - { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676 }, - { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701 }, - { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934 }, - { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316 }, - { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619 }, - { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896 }, - { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111 }, - { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334 }, - { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026 }, - { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724 }, - { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868 }, - { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322 }, - { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778 }, - { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175 }, - { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857 }, - { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663 }, +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, + { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, + { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, + { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, + { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, + { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, + { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, + { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, + { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/6c/39/8ea9bcfaaff16fd0b0fc901ee522e24c9ec44b4ca0229cfffb8066a06959/propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5", size = 74678, upload-time = "2025-06-09T22:55:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/d3/85/cab84c86966e1d354cf90cdc4ba52f32f99a5bca92a1529d666d957d7686/propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4", size = 43829, upload-time = "2025-06-09T22:55:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/23/f7/9cb719749152d8b26d63801b3220ce2d3931312b2744d2b3a088b0ee9947/propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2", size = 43729, upload-time = "2025-06-09T22:55:43.651Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a2/0b2b5a210ff311260002a315f6f9531b65a36064dfb804655432b2f7d3e3/propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d", size = 204483, upload-time = "2025-06-09T22:55:45.327Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e0/7aff5de0c535f783b0c8be5bdb750c305c1961d69fbb136939926e155d98/propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec", size = 217425, upload-time = "2025-06-09T22:55:46.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/1d/65fa889eb3b2a7d6e4ed3c2b568a9cb8817547a1450b572de7bf24872800/propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701", size = 214723, upload-time = "2025-06-09T22:55:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e2/eecf6989870988dfd731de408a6fa366e853d361a06c2133b5878ce821ad/propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef", size = 200166, upload-time = "2025-06-09T22:55:49.775Z" }, + { url = "https://files.pythonhosted.org/packages/12/06/c32be4950967f18f77489268488c7cdc78cbfc65a8ba8101b15e526b83dc/propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1", size = 194004, upload-time = "2025-06-09T22:55:51.335Z" }, + { url = "https://files.pythonhosted.org/packages/46/6c/17b521a6b3b7cbe277a4064ff0aa9129dd8c89f425a5a9b6b4dd51cc3ff4/propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886", size = 203075, upload-time = "2025-06-09T22:55:52.681Z" }, + { url = "https://files.pythonhosted.org/packages/62/cb/3bdba2b736b3e45bc0e40f4370f745b3e711d439ffbffe3ae416393eece9/propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b", size = 195407, upload-time = "2025-06-09T22:55:54.048Z" }, + { url = "https://files.pythonhosted.org/packages/29/bd/760c5c6a60a4a2c55a421bc34a25ba3919d49dee411ddb9d1493bb51d46e/propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb", size = 196045, upload-time = "2025-06-09T22:55:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/76/58/ced2757a46f55b8c84358d6ab8de4faf57cba831c51e823654da7144b13a/propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea", size = 208432, upload-time = "2025-06-09T22:55:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/d98ea8d5a4d8fe0e372033f5254eddf3254344c0c5dc6c49ab84349e4733/propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb", size = 210100, upload-time = "2025-06-09T22:55:58.498Z" }, + { url = "https://files.pythonhosted.org/packages/56/84/b6d8a7ecf3f62d7dd09d9d10bbf89fad6837970ef868b35b5ffa0d24d9de/propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe", size = 200712, upload-time = "2025-06-09T22:55:59.906Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/889f4903ddfe4a9dc61da71ee58b763758cf2d608fe1decede06e6467f8d/propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1", size = 38187, upload-time = "2025-06-09T22:56:01.212Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/d666795fb9ba1dc139d30de64f3b6fd1ff9c9d3d96ccfdb992cd715ce5d2/propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9", size = 42025, upload-time = "2025-06-09T22:56:02.875Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] [[package]] name = "protobuf" version = "6.32.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/a4/cc17347aa2897568beece2e674674359f911d6fe21b0b8d6268cd42727ac/protobuf-6.32.1.tar.gz", hash = "sha256:ee2469e4a021474ab9baafea6cd070e5bf27c7d29433504ddea1a4ee5850f68d", size = 440635 } +sdist = { url = "https://files.pythonhosted.org/packages/fa/a4/cc17347aa2897568beece2e674674359f911d6fe21b0b8d6268cd42727ac/protobuf-6.32.1.tar.gz", hash = "sha256:ee2469e4a021474ab9baafea6cd070e5bf27c7d29433504ddea1a4ee5850f68d", size = 440635, upload-time = "2025-09-11T21:38:42.935Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/be/8dd0a927c559b37d7a6c8ab79034fd167dcc1f851595f2e641ad62be8643/protobuf-6.32.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:2f5b80a49e1eb7b86d85fcd23fe92df154b9730a725c3b38c4e43b9d77018bf4", size = 322874 }, - { url = "https://files.pythonhosted.org/packages/5c/f6/88d77011b605ef979aace37b7703e4eefad066f7e84d935e5a696515c2dd/protobuf-6.32.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1864818300c297265c83a4982fd3169f97122c299f56a56e2445c3698d34710", size = 322013 }, - { url = "https://files.pythonhosted.org/packages/97/b7/15cc7d93443d6c6a84626ae3258a91f4c6ac8c0edd5df35ea7658f71b79c/protobuf-6.32.1-py3-none-any.whl", hash = "sha256:2601b779fc7d32a866c6b4404f9d42a3f67c5b9f3f15b4db3cccabe06b95c346", size = 169289 }, + { url = "https://files.pythonhosted.org/packages/3f/be/8dd0a927c559b37d7a6c8ab79034fd167dcc1f851595f2e641ad62be8643/protobuf-6.32.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:2f5b80a49e1eb7b86d85fcd23fe92df154b9730a725c3b38c4e43b9d77018bf4", size = 322874, upload-time = "2025-09-11T21:38:35.509Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f6/88d77011b605ef979aace37b7703e4eefad066f7e84d935e5a696515c2dd/protobuf-6.32.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1864818300c297265c83a4982fd3169f97122c299f56a56e2445c3698d34710", size = 322013, upload-time = "2025-09-11T21:38:37.017Z" }, + { url = "https://files.pythonhosted.org/packages/97/b7/15cc7d93443d6c6a84626ae3258a91f4c6ac8c0edd5df35ea7658f71b79c/protobuf-6.32.1-py3-none-any.whl", hash = "sha256:2601b779fc7d32a866c6b4404f9d42a3f67c5b9f3f15b4db3cccabe06b95c346", size = 169289, upload-time = "2025-09-11T21:38:41.234Z" }, ] [[package]] name = "psutil" version = "7.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660, upload-time = "2025-09-17T20:14:52.902Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242 }, - { url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682 }, - { url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994 }, - { url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163 }, - { url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625 }, - { url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812 }, - { url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965 }, - { url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971 }, + { url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242, upload-time = "2025-09-17T20:14:56.126Z" }, + { url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682, upload-time = "2025-09-17T20:14:58.25Z" }, + { url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994, upload-time = "2025-09-17T20:14:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163, upload-time = "2025-09-17T20:15:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625, upload-time = "2025-09-17T20:15:04.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812, upload-time = "2025-09-17T20:15:07.462Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965, upload-time = "2025-09-17T20:15:09.673Z" }, + { url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971, upload-time = "2025-09-17T20:15:12.262Z" }, ] [[package]] name = "py-cpuinfo" version = "9.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716 } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335 }, + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, ] [[package]] @@ -1910,48 +2219,53 @@ name = "pyarrow" version = "21.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/d9/110de31880016e2afc52d8580b397dbe47615defbf09ca8cf55f56c62165/pyarrow-21.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26", size = 31196837 }, - { url = "https://files.pythonhosted.org/packages/df/5f/c1c1997613abf24fceb087e79432d24c19bc6f7259cab57c2c8e5e545fab/pyarrow-21.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79", size = 32659470 }, - { url = "https://files.pythonhosted.org/packages/3e/ed/b1589a777816ee33ba123ba1e4f8f02243a844fed0deec97bde9fb21a5cf/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7be45519b830f7c24b21d630a31d48bcebfd5d4d7f9d3bdb49da9cdf6d764edb", size = 41055619 }, - { url = "https://files.pythonhosted.org/packages/44/28/b6672962639e85dc0ac36f71ab3a8f5f38e01b51343d7aa372a6b56fa3f3/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:26bfd95f6bff443ceae63c65dc7e048670b7e98bc892210acba7e4995d3d4b51", size = 42733488 }, - { url = "https://files.pythonhosted.org/packages/f8/cc/de02c3614874b9089c94eac093f90ca5dfa6d5afe45de3ba847fd950fdf1/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd04ec08f7f8bd113c55868bd3fc442a9db67c27af098c5f814a3091e71cc61a", size = 43329159 }, - { url = "https://files.pythonhosted.org/packages/a6/3e/99473332ac40278f196e105ce30b79ab8affab12f6194802f2593d6b0be2/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9b0b14b49ac10654332a805aedfc0147fb3469cbf8ea951b3d040dab12372594", size = 45050567 }, - { url = "https://files.pythonhosted.org/packages/7b/f5/c372ef60593d713e8bfbb7e0c743501605f0ad00719146dc075faf11172b/pyarrow-21.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d9f8bcb4c3be7738add259738abdeddc363de1b80e3310e04067aa1ca596634", size = 26217959 }, - { url = "https://files.pythonhosted.org/packages/94/dc/80564a3071a57c20b7c32575e4a0120e8a330ef487c319b122942d665960/pyarrow-21.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c077f48aab61738c237802836fc3844f85409a46015635198761b0d6a688f87b", size = 31243234 }, - { url = "https://files.pythonhosted.org/packages/ea/cc/3b51cb2db26fe535d14f74cab4c79b191ed9a8cd4cbba45e2379b5ca2746/pyarrow-21.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:689f448066781856237eca8d1975b98cace19b8dd2ab6145bf49475478bcaa10", size = 32714370 }, - { url = "https://files.pythonhosted.org/packages/24/11/a4431f36d5ad7d83b87146f515c063e4d07ef0b7240876ddb885e6b44f2e/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:479ee41399fcddc46159a551705b89c05f11e8b8cb8e968f7fec64f62d91985e", size = 41135424 }, - { url = "https://files.pythonhosted.org/packages/74/dc/035d54638fc5d2971cbf1e987ccd45f1091c83bcf747281cf6cc25e72c88/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:40ebfcb54a4f11bcde86bc586cbd0272bac0d516cfa539c799c2453768477569", size = 42823810 }, - { url = "https://files.pythonhosted.org/packages/2e/3b/89fced102448a9e3e0d4dded1f37fa3ce4700f02cdb8665457fcc8015f5b/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8d58d8497814274d3d20214fbb24abcad2f7e351474357d552a8d53bce70c70e", size = 43391538 }, - { url = "https://files.pythonhosted.org/packages/fb/bb/ea7f1bd08978d39debd3b23611c293f64a642557e8141c80635d501e6d53/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:585e7224f21124dd57836b1530ac8f2df2afc43c861d7bf3d58a4870c42ae36c", size = 45120056 }, - { url = "https://files.pythonhosted.org/packages/6e/0b/77ea0600009842b30ceebc3337639a7380cd946061b620ac1a2f3cb541e2/pyarrow-21.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:555ca6935b2cbca2c0e932bedd853e9bc523098c39636de9ad4693b5b1df86d6", size = 26220568 }, - { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305 }, - { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264 }, - { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099 }, - { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529 }, - { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883 }, - { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802 }, - { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175 }, - { url = "https://files.pythonhosted.org/packages/16/ca/c7eaa8e62db8fb37ce942b1ea0c6d7abfe3786ca193957afa25e71b81b66/pyarrow-21.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e99310a4ebd4479bcd1964dff9e14af33746300cb014aa4a3781738ac63baf4a", size = 31154306 }, - { url = "https://files.pythonhosted.org/packages/ce/e8/e87d9e3b2489302b3a1aea709aaca4b781c5252fcb812a17ab6275a9a484/pyarrow-21.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d2fe8e7f3ce329a71b7ddd7498b3cfac0eeb200c2789bd840234f0dc271a8efe", size = 32680622 }, - { url = "https://files.pythonhosted.org/packages/84/52/79095d73a742aa0aba370c7942b1b655f598069489ab387fe47261a849e1/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd", size = 41104094 }, - { url = "https://files.pythonhosted.org/packages/89/4b/7782438b551dbb0468892a276b8c789b8bbdb25ea5c5eb27faadd753e037/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:69cbbdf0631396e9925e048cfa5bce4e8c3d3b41562bbd70c685a8eb53a91e61", size = 42825576 }, - { url = "https://files.pythonhosted.org/packages/b3/62/0f29de6e0a1e33518dec92c65be0351d32d7ca351e51ec5f4f837a9aab91/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:731c7022587006b755d0bdb27626a1a3bb004bb56b11fb30d98b6c1b4718579d", size = 43368342 }, - { url = "https://files.pythonhosted.org/packages/90/c7/0fa1f3f29cf75f339768cc698c8ad4ddd2481c1742e9741459911c9ac477/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc56bc708f2d8ac71bd1dcb927e458c93cec10b98eb4120206a4091db7b67b99", size = 45131218 }, - { url = "https://files.pythonhosted.org/packages/01/63/581f2076465e67b23bc5a37d4a2abff8362d389d29d8105832e82c9c811c/pyarrow-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:186aa00bca62139f75b7de8420f745f2af12941595bbbfa7ed3870ff63e25636", size = 26087551 }, - { url = "https://files.pythonhosted.org/packages/c9/ab/357d0d9648bb8241ee7348e564f2479d206ebe6e1c47ac5027c2e31ecd39/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:a7a102574faa3f421141a64c10216e078df467ab9576684d5cd696952546e2da", size = 31290064 }, - { url = "https://files.pythonhosted.org/packages/3f/8a/5685d62a990e4cac2043fc76b4661bf38d06efed55cf45a334b455bd2759/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:1e005378c4a2c6db3ada3ad4c217b381f6c886f0a80d6a316fe586b90f77efd7", size = 32727837 }, - { url = "https://files.pythonhosted.org/packages/fc/de/c0828ee09525c2bafefd3e736a248ebe764d07d0fd762d4f0929dbc516c9/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:65f8e85f79031449ec8706b74504a316805217b35b6099155dd7e227eef0d4b6", size = 41014158 }, - { url = "https://files.pythonhosted.org/packages/6e/26/a2865c420c50b7a3748320b614f3484bfcde8347b2639b2b903b21ce6a72/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3a81486adc665c7eb1a2bde0224cfca6ceaba344a82a971ef059678417880eb8", size = 42667885 }, - { url = "https://files.pythonhosted.org/packages/0a/f9/4ee798dc902533159250fb4321267730bc0a107d8c6889e07c3add4fe3a5/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503", size = 43276625 }, - { url = "https://files.pythonhosted.org/packages/5a/da/e02544d6997037a4b0d22d8e5f66bc9315c3671371a8b18c79ade1cefe14/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6299449adf89df38537837487a4f8d3bd91ec94354fdd2a7d30bc11c48ef6e79", size = 44951890 }, - { url = "https://files.pythonhosted.org/packages/e5/4e/519c1bc1876625fe6b71e9a28287c43ec2f20f73c658b9ae1d485c0c206e/pyarrow-21.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:222c39e2c70113543982c6b34f3077962b44fca38c0bd9e68bb6781534425c10", size = 26371006 }, + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487, upload-time = "2025-07-18T00:57:31.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d9/110de31880016e2afc52d8580b397dbe47615defbf09ca8cf55f56c62165/pyarrow-21.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26", size = 31196837, upload-time = "2025-07-18T00:54:34.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/5f/c1c1997613abf24fceb087e79432d24c19bc6f7259cab57c2c8e5e545fab/pyarrow-21.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79", size = 32659470, upload-time = "2025-07-18T00:54:38.329Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ed/b1589a777816ee33ba123ba1e4f8f02243a844fed0deec97bde9fb21a5cf/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7be45519b830f7c24b21d630a31d48bcebfd5d4d7f9d3bdb49da9cdf6d764edb", size = 41055619, upload-time = "2025-07-18T00:54:42.172Z" }, + { url = "https://files.pythonhosted.org/packages/44/28/b6672962639e85dc0ac36f71ab3a8f5f38e01b51343d7aa372a6b56fa3f3/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:26bfd95f6bff443ceae63c65dc7e048670b7e98bc892210acba7e4995d3d4b51", size = 42733488, upload-time = "2025-07-18T00:54:47.132Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/de02c3614874b9089c94eac093f90ca5dfa6d5afe45de3ba847fd950fdf1/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd04ec08f7f8bd113c55868bd3fc442a9db67c27af098c5f814a3091e71cc61a", size = 43329159, upload-time = "2025-07-18T00:54:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3e/99473332ac40278f196e105ce30b79ab8affab12f6194802f2593d6b0be2/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9b0b14b49ac10654332a805aedfc0147fb3469cbf8ea951b3d040dab12372594", size = 45050567, upload-time = "2025-07-18T00:54:56.679Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f5/c372ef60593d713e8bfbb7e0c743501605f0ad00719146dc075faf11172b/pyarrow-21.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d9f8bcb4c3be7738add259738abdeddc363de1b80e3310e04067aa1ca596634", size = 26217959, upload-time = "2025-07-18T00:55:00.482Z" }, + { url = "https://files.pythonhosted.org/packages/94/dc/80564a3071a57c20b7c32575e4a0120e8a330ef487c319b122942d665960/pyarrow-21.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c077f48aab61738c237802836fc3844f85409a46015635198761b0d6a688f87b", size = 31243234, upload-time = "2025-07-18T00:55:03.812Z" }, + { url = "https://files.pythonhosted.org/packages/ea/cc/3b51cb2db26fe535d14f74cab4c79b191ed9a8cd4cbba45e2379b5ca2746/pyarrow-21.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:689f448066781856237eca8d1975b98cace19b8dd2ab6145bf49475478bcaa10", size = 32714370, upload-time = "2025-07-18T00:55:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/24/11/a4431f36d5ad7d83b87146f515c063e4d07ef0b7240876ddb885e6b44f2e/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:479ee41399fcddc46159a551705b89c05f11e8b8cb8e968f7fec64f62d91985e", size = 41135424, upload-time = "2025-07-18T00:55:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/74/dc/035d54638fc5d2971cbf1e987ccd45f1091c83bcf747281cf6cc25e72c88/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:40ebfcb54a4f11bcde86bc586cbd0272bac0d516cfa539c799c2453768477569", size = 42823810, upload-time = "2025-07-18T00:55:16.301Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3b/89fced102448a9e3e0d4dded1f37fa3ce4700f02cdb8665457fcc8015f5b/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8d58d8497814274d3d20214fbb24abcad2f7e351474357d552a8d53bce70c70e", size = 43391538, upload-time = "2025-07-18T00:55:23.82Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/ea7f1bd08978d39debd3b23611c293f64a642557e8141c80635d501e6d53/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:585e7224f21124dd57836b1530ac8f2df2afc43c861d7bf3d58a4870c42ae36c", size = 45120056, upload-time = "2025-07-18T00:55:28.231Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0b/77ea0600009842b30ceebc3337639a7380cd946061b620ac1a2f3cb541e2/pyarrow-21.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:555ca6935b2cbca2c0e932bedd853e9bc523098c39636de9ad4693b5b1df86d6", size = 26220568, upload-time = "2025-07-18T00:55:32.122Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305, upload-time = "2025-07-18T00:55:35.373Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264, upload-time = "2025-07-18T00:55:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099, upload-time = "2025-07-18T00:55:42.889Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529, upload-time = "2025-07-18T00:55:47.069Z" }, + { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883, upload-time = "2025-07-18T00:55:53.069Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802, upload-time = "2025-07-18T00:55:57.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175, upload-time = "2025-07-18T00:56:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/16/ca/c7eaa8e62db8fb37ce942b1ea0c6d7abfe3786ca193957afa25e71b81b66/pyarrow-21.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e99310a4ebd4479bcd1964dff9e14af33746300cb014aa4a3781738ac63baf4a", size = 31154306, upload-time = "2025-07-18T00:56:04.42Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e8/e87d9e3b2489302b3a1aea709aaca4b781c5252fcb812a17ab6275a9a484/pyarrow-21.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d2fe8e7f3ce329a71b7ddd7498b3cfac0eeb200c2789bd840234f0dc271a8efe", size = 32680622, upload-time = "2025-07-18T00:56:07.505Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/79095d73a742aa0aba370c7942b1b655f598069489ab387fe47261a849e1/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd", size = 41104094, upload-time = "2025-07-18T00:56:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/89/4b/7782438b551dbb0468892a276b8c789b8bbdb25ea5c5eb27faadd753e037/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:69cbbdf0631396e9925e048cfa5bce4e8c3d3b41562bbd70c685a8eb53a91e61", size = 42825576, upload-time = "2025-07-18T00:56:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/b3/62/0f29de6e0a1e33518dec92c65be0351d32d7ca351e51ec5f4f837a9aab91/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:731c7022587006b755d0bdb27626a1a3bb004bb56b11fb30d98b6c1b4718579d", size = 43368342, upload-time = "2025-07-18T00:56:19.531Z" }, + { url = "https://files.pythonhosted.org/packages/90/c7/0fa1f3f29cf75f339768cc698c8ad4ddd2481c1742e9741459911c9ac477/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc56bc708f2d8ac71bd1dcb927e458c93cec10b98eb4120206a4091db7b67b99", size = 45131218, upload-time = "2025-07-18T00:56:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/01/63/581f2076465e67b23bc5a37d4a2abff8362d389d29d8105832e82c9c811c/pyarrow-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:186aa00bca62139f75b7de8420f745f2af12941595bbbfa7ed3870ff63e25636", size = 26087551, upload-time = "2025-07-18T00:56:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ab/357d0d9648bb8241ee7348e564f2479d206ebe6e1c47ac5027c2e31ecd39/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:a7a102574faa3f421141a64c10216e078df467ab9576684d5cd696952546e2da", size = 31290064, upload-time = "2025-07-18T00:56:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8a/5685d62a990e4cac2043fc76b4661bf38d06efed55cf45a334b455bd2759/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:1e005378c4a2c6db3ada3ad4c217b381f6c886f0a80d6a316fe586b90f77efd7", size = 32727837, upload-time = "2025-07-18T00:56:33.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/c0828ee09525c2bafefd3e736a248ebe764d07d0fd762d4f0929dbc516c9/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:65f8e85f79031449ec8706b74504a316805217b35b6099155dd7e227eef0d4b6", size = 41014158, upload-time = "2025-07-18T00:56:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/6e/26/a2865c420c50b7a3748320b614f3484bfcde8347b2639b2b903b21ce6a72/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3a81486adc665c7eb1a2bde0224cfca6ceaba344a82a971ef059678417880eb8", size = 42667885, upload-time = "2025-07-18T00:56:41.483Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f9/4ee798dc902533159250fb4321267730bc0a107d8c6889e07c3add4fe3a5/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503", size = 43276625, upload-time = "2025-07-18T00:56:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/5a/da/e02544d6997037a4b0d22d8e5f66bc9315c3671371a8b18c79ade1cefe14/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6299449adf89df38537837487a4f8d3bd91ec94354fdd2a7d30bc11c48ef6e79", size = 44951890, upload-time = "2025-07-18T00:56:52.568Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4e/519c1bc1876625fe6b71e9a28287c43ec2f20f73c658b9ae1d485c0c206e/pyarrow-21.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:222c39e2c70113543982c6b34f3077962b44fca38c0bd9e68bb6781534425c10", size = 26371006, upload-time = "2025-07-18T00:56:56.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/ce4939f4b316457a083dc5718b3982801e8c33f921b3c98e7a93b7c7491f/pyarrow-21.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:a7f6524e3747e35f80744537c78e7302cd41deee8baa668d56d55f77d9c464b3", size = 31211248, upload-time = "2025-07-18T00:56:59.7Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c2/7a860931420d73985e2f340f06516b21740c15b28d24a0e99a900bb27d2b/pyarrow-21.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:203003786c9fd253ebcafa44b03c06983c9c8d06c3145e37f1b76a1f317aeae1", size = 32676896, upload-time = "2025-07-18T00:57:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/197f989b9a75e59b4ca0db6a13c56f19a0ad8a298c68da9cc28145e0bb97/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3b4d97e297741796fead24867a8dabf86c87e4584ccc03167e4a811f50fdf74d", size = 41067862, upload-time = "2025-07-18T00:57:07.587Z" }, + { url = "https://files.pythonhosted.org/packages/fa/82/6ecfa89487b35aa21accb014b64e0a6b814cc860d5e3170287bf5135c7d8/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:898afce396b80fdda05e3086b4256f8677c671f7b1d27a6976fa011d3fd0a86e", size = 42747508, upload-time = "2025-07-18T00:57:13.917Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b7/ba252f399bbf3addc731e8643c05532cf32e74cebb5e32f8f7409bc243cf/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:067c66ca29aaedae08218569a114e413b26e742171f526e828e1064fcdec13f4", size = 43345293, upload-time = "2025-07-18T00:57:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/a20819795bd702b9486f536a8eeb70a6aa64046fce32071c19ec8230dbaa/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0c4e75d13eb76295a49e0ea056eb18dbd87d81450bfeb8afa19a7e5a75ae2ad7", size = 45060670, upload-time = "2025-07-18T00:57:24.477Z" }, + { url = "https://files.pythonhosted.org/packages/10/15/6b30e77872012bbfe8265d42a01d5b3c17ef0ac0f2fae531ad91b6a6c02e/pyarrow-21.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdc4c17afda4dab2a9c0b79148a43a7f4e1094916b3e18d8975bfd6d6d52241f", size = 26227521, upload-time = "2025-07-18T00:57:29.119Z" }, ] [[package]] @@ -1960,58 +2274,61 @@ version = "23.0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/a8/24e5dc6855f50a62936ceb004e6e9645e4219a8065f304145d7fb8a79d5d/pyarrow-23.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:3fab8f82571844eb3c460f90a75583801d14ca0cc32b1acc8c361650e006fd56", size = 34307390 }, - { url = "https://files.pythonhosted.org/packages/bc/8e/4be5617b4aaae0287f621ad31c6036e5f63118cfca0dc57d42121ff49b51/pyarrow-23.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3f91c038b95f71ddfc865f11d5876c42f343b4495535bd262c7b321b0b94507c", size = 35853761 }, - { url = "https://files.pythonhosted.org/packages/2e/08/3e56a18819462210432ae37d10f5c8eed3828be1d6c751b6e6a2e93c286a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d0744403adabef53c985a7f8a082b502a368510c40d184df349a0a8754533258", size = 44493116 }, - { url = "https://files.pythonhosted.org/packages/f8/82/c40b68001dbec8a3faa4c08cd8c200798ac732d2854537c5449dc859f55a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c33b5bf406284fd0bba436ed6f6c3ebe8e311722b441d89397c54f871c6863a2", size = 47564532 }, - { url = "https://files.pythonhosted.org/packages/20/bc/73f611989116b6f53347581b02177f9f620efdf3cd3f405d0e83cdf53a83/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ddf743e82f69dcd6dbbcb63628895d7161e04e56794ef80550ac6f3315eeb1d5", size = 48183685 }, - { url = "https://files.pythonhosted.org/packages/b0/cc/6c6b3ecdae2a8c3aced99956187e8302fc954cc2cca2a37cf2111dad16ce/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e052a211c5ac9848ae15d5ec875ed0943c0221e2fcfe69eee80b604b4e703222", size = 50605582 }, - { url = "https://files.pythonhosted.org/packages/8d/94/d359e708672878d7638a04a0448edf7c707f9e5606cee11e15aaa5c7535a/pyarrow-23.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5abde149bb3ce524782d838eb67ac095cd3fd6090eba051130589793f1a7f76d", size = 27521148 }, - { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230 }, - { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050 }, - { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918 }, - { url = "https://files.pythonhosted.org/packages/0b/62/96459ef5b67957eac38a90f541d1c28833d1b367f014a482cb63f3b7cd2d/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", size = 47562811 }, - { url = "https://files.pythonhosted.org/packages/7d/94/1170e235add1f5f45a954e26cd0e906e7e74e23392dcb560de471f7366ec/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", size = 48183766 }, - { url = "https://files.pythonhosted.org/packages/0e/2d/39a42af4570377b99774cdb47f63ee6c7da7616bd55b3d5001aa18edfe4f/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", size = 50607669 }, - { url = "https://files.pythonhosted.org/packages/00/ca/db94101c187f3df742133ac837e93b1f269ebdac49427f8310ee40b6a58f/pyarrow-23.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", size = 27527698 }, - { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575 }, - { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540 }, - { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940 }, - { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063 }, - { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045 }, - { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741 }, - { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678 }, - { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066 }, - { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526 }, - { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279 }, - { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798 }, - { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446 }, - { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972 }, - { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749 }, - { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544 }, - { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911 }, - { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337 }, - { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944 }, - { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269 }, - { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794 }, - { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642 }, - { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755 }, - { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826 }, - { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859 }, - { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443 }, - { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991 }, - { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077 }, - { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271 }, - { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692 }, - { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383 }, - { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119 }, - { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199 }, - { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435 }, - { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149 }, - { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807 }, +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/a8/24e5dc6855f50a62936ceb004e6e9645e4219a8065f304145d7fb8a79d5d/pyarrow-23.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:3fab8f82571844eb3c460f90a75583801d14ca0cc32b1acc8c361650e006fd56", size = 34307390, upload-time = "2026-02-16T10:08:08.654Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8e/4be5617b4aaae0287f621ad31c6036e5f63118cfca0dc57d42121ff49b51/pyarrow-23.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3f91c038b95f71ddfc865f11d5876c42f343b4495535bd262c7b321b0b94507c", size = 35853761, upload-time = "2026-02-16T10:08:17.811Z" }, + { url = "https://files.pythonhosted.org/packages/2e/08/3e56a18819462210432ae37d10f5c8eed3828be1d6c751b6e6a2e93c286a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d0744403adabef53c985a7f8a082b502a368510c40d184df349a0a8754533258", size = 44493116, upload-time = "2026-02-16T10:08:25.792Z" }, + { url = "https://files.pythonhosted.org/packages/f8/82/c40b68001dbec8a3faa4c08cd8c200798ac732d2854537c5449dc859f55a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c33b5bf406284fd0bba436ed6f6c3ebe8e311722b441d89397c54f871c6863a2", size = 47564532, upload-time = "2026-02-16T10:08:34.27Z" }, + { url = "https://files.pythonhosted.org/packages/20/bc/73f611989116b6f53347581b02177f9f620efdf3cd3f405d0e83cdf53a83/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ddf743e82f69dcd6dbbcb63628895d7161e04e56794ef80550ac6f3315eeb1d5", size = 48183685, upload-time = "2026-02-16T10:08:42.889Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/6c6b3ecdae2a8c3aced99956187e8302fc954cc2cca2a37cf2111dad16ce/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e052a211c5ac9848ae15d5ec875ed0943c0221e2fcfe69eee80b604b4e703222", size = 50605582, upload-time = "2026-02-16T10:08:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/8d/94/d359e708672878d7638a04a0448edf7c707f9e5606cee11e15aaa5c7535a/pyarrow-23.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5abde149bb3ce524782d838eb67ac095cd3fd6090eba051130589793f1a7f76d", size = 27521148, upload-time = "2026-02-16T10:08:58.077Z" }, + { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230, upload-time = "2026-02-16T10:09:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050, upload-time = "2026-02-16T10:09:11.877Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918, upload-time = "2026-02-16T10:09:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/0b/62/96459ef5b67957eac38a90f541d1c28833d1b367f014a482cb63f3b7cd2d/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", size = 47562811, upload-time = "2026-02-16T10:09:25.792Z" }, + { url = "https://files.pythonhosted.org/packages/7d/94/1170e235add1f5f45a954e26cd0e906e7e74e23392dcb560de471f7366ec/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", size = 48183766, upload-time = "2026-02-16T10:09:34.645Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/39a42af4570377b99774cdb47f63ee6c7da7616bd55b3d5001aa18edfe4f/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", size = 50607669, upload-time = "2026-02-16T10:09:44.153Z" }, + { url = "https://files.pythonhosted.org/packages/00/ca/db94101c187f3df742133ac837e93b1f269ebdac49427f8310ee40b6a58f/pyarrow-23.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", size = 27527698, upload-time = "2026-02-16T10:09:50.263Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, + { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, + { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, + { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, + { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, + { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, + { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, + { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, + { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, + { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, + { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, ] [[package]] @@ -2024,9 +2341,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038 } +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400 }, + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, ] [[package]] @@ -2036,116 +2353,137 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298 }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475 }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815 }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567 }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442 }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956 }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253 }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050 }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178 }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833 }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156 }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378 }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622 }, - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351 }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363 }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615 }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369 }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218 }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951 }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428 }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009 }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" }, + { url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" }, + { url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2153,10 +2491,11 @@ name = "pylance" source = { editable = "." } dependencies = [ { name = "lance-namespace" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] [package.optional-dependencies] @@ -2173,7 +2512,7 @@ geo = [ ] tests = [ { name = "boto3" }, - { name = "datafusion" }, + { name = "datafusion", marker = "python_full_version >= '3.10'" }, { name = "datasets" }, { name = "duckdb" }, { name = "ml-dtypes" }, @@ -2192,7 +2531,7 @@ torch = [ [package.metadata] requires-dist = [ { name = "boto3", marker = "extra == 'tests'" }, - { name = "datafusion", marker = "extra == 'tests'", specifier = ">=52,<53" }, + { name = "datafusion", marker = "python_full_version >= '3.10' and extra == 'tests'", specifier = ">=52,<53" }, { name = "datasets", marker = "extra == 'tests'" }, { name = "duckdb", marker = "extra == 'tests'" }, { name = "geoarrow-rust-core", marker = "extra == 'geo'" }, @@ -2208,56 +2547,97 @@ requires-dist = [ { name = "pyright", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'tests'" }, { name = "pytest-benchmark", marker = "extra == 'benchmarks'" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.4.1" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.11.2" }, { name = "tensorflow", marker = "sys_platform == 'linux' and extra == 'tests'" }, { name = "torch", marker = "extra == 'torch'", specifier = ">=2.0" }, { name = "tqdm", marker = "extra == 'tests'" }, ] +provides-extras = ["benchmarks", "dev", "geo", "tests", "torch"] + +[[package]] +name = "pyproj" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/84/2b39bbf888c753ea48b40d47511548c77aa03445465c35cc4c4e9649b643/pyproj-3.6.1.tar.gz", hash = "sha256:44aa7c704c2b7d8fb3d483bbf75af6cb2350d30a63b144279a09b75fead501bf", size = 225131, upload-time = "2023-09-21T02:07:51.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/32/63cf474f4a8d4804b3bdf7c16b8589f38142e8e2f8319dcea27e0bc21a87/pyproj-3.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ab7aa4d9ff3c3acf60d4b285ccec134167a948df02347585fdd934ebad8811b4", size = 6142763, upload-time = "2023-09-21T02:07:12.844Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/2e7cb9de40492f1bafbf11f4c9072edc394509a40b5e4c52f8139546f039/pyproj-3.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4bc0472302919e59114aa140fd7213c2370d848a7249d09704f10f5b062031fe", size = 4877123, upload-time = "2023-09-21T02:10:37.905Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c5/928d5a26995dbefbebd7507d982141cd9153bc7e4392b334fff722c4af12/pyproj-3.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5279586013b8d6582e22b6f9e30c49796966770389a9d5b85e25a4223286cd3f", size = 6190576, upload-time = "2023-09-21T02:17:08.637Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2b/b60cf73b0720abca313bfffef34e34f7f7dae23852b2853cf0368d49426b/pyproj-3.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fafd1f3eb421694857f254a9bdbacd1eb22fc6c24ca74b136679f376f97d35", size = 8328075, upload-time = "2023-09-21T02:07:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a8/7193f46032636be917bc775506ae987aad72c931b1f691b775ca812a2917/pyproj-3.6.1-cp310-cp310-win32.whl", hash = "sha256:c41e80ddee130450dcb8829af7118f1ab69eaf8169c4bf0ee8d52b72f098dc2f", size = 5635713, upload-time = "2023-09-21T02:07:17.548Z" }, + { url = "https://files.pythonhosted.org/packages/89/8f/27350c8fba71a37cd0d316f100fbd96bf139cc2b5ff1ab0dcbc7ac64010a/pyproj-3.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:db3aedd458e7f7f21d8176f0a1d924f1ae06d725228302b872885a1c34f3119e", size = 6087932, upload-time = "2023-09-21T02:07:19.793Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/a300c1b14b2112e966e9f90b18f9c13b586bdcf417207cee913ae9005da3/pyproj-3.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ebfbdbd0936e178091309f6cd4fcb4decd9eab12aa513cdd9add89efa3ec2882", size = 6147442, upload-time = "2023-09-21T02:07:21.879Z" }, + { url = "https://files.pythonhosted.org/packages/30/bd/b9bd3761f08754e8dbb34c5a647db2099b348ab5da338e90980caf280e37/pyproj-3.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:447db19c7efad70ff161e5e46a54ab9cc2399acebb656b6ccf63e4bc4a04b97a", size = 4880331, upload-time = "2023-09-21T02:10:40.828Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0a/d82aeeb605b5d6870bc72307c3b5e044e632eb7720df8885e144f51a8eac/pyproj-3.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7e13c40183884ec7f94eb8e0f622f08f1d5716150b8d7a134de48c6110fee85", size = 6192425, upload-time = "2023-09-21T02:17:09.049Z" }, + { url = "https://files.pythonhosted.org/packages/64/90/dfe5c00de1ca4dbb82606e79790659d4ed7f0ed8d372bccb3baca2a5abe0/pyproj-3.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65ad699e0c830e2b8565afe42bd58cc972b47d829b2e0e48ad9638386d994915", size = 8571478, upload-time = "2023-09-21T02:07:23.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/ae373629a1723f0db80d7b8c93598b00d9ecb930ed9ebf4f35826a33e97c/pyproj-3.6.1-cp311-cp311-win32.whl", hash = "sha256:8b8acc31fb8702c54625f4d5a2a6543557bec3c28a0ef638778b7ab1d1772132", size = 5634575, upload-time = "2023-09-21T02:07:26.535Z" }, + { url = "https://files.pythonhosted.org/packages/79/95/eb68113c5b5737c342bde1bab92705dabe69c16299c5a122616e50f1fbd6/pyproj-3.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:38a3361941eb72b82bd9a18f60c78b0df8408416f9340521df442cebfc4306e2", size = 6088494, upload-time = "2023-09-21T02:07:28.75Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/93232511a7906a492b1b7dfdfc17f4e95982d76a24ef4f86d18cfe7ae2c9/pyproj-3.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1e9fbaf920f0f9b4ee62aab832be3ae3968f33f24e2e3f7fbb8c6728ef1d9746", size = 6135280, upload-time = "2023-09-21T02:07:30.911Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/b550b1f65cc7e51c9116b220b50aade60c439103432a3fd5b12efbc77e15/pyproj-3.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d227a865356f225591b6732430b1d1781e946893789a609bb34f59d09b8b0f8", size = 4880030, upload-time = "2023-09-21T02:10:43.067Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4b/2f8f6f94643b9fe2083338eff294feda84d916409b5840b7a402d2be93f8/pyproj-3.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83039e5ae04e5afc974f7d25ee0870a80a6bd6b7957c3aca5613ccbe0d3e72bf", size = 6184439, upload-time = "2023-09-21T02:17:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/19/9b/c57569132174786aa3f72275ac306956859a639dad0ce8d95c8411ce8209/pyproj-3.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb059ba3bced6f6725961ba758649261d85ed6ce670d3e3b0a26e81cf1aa8d", size = 8660747, upload-time = "2023-09-21T02:07:32.586Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ab/1c2159ec757677c5a6b8803f6be45c2b550dc42c84ec4a228dc219849bbb/pyproj-3.6.1-cp312-cp312-win32.whl", hash = "sha256:2d6ff73cc6dbbce3766b6c0bce70ce070193105d8de17aa2470009463682a8eb", size = 5626805, upload-time = "2023-09-21T02:07:35.28Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f3/2f32fe143cd7ba1d4d68f1b6dce9ca402d909cbd5a5830e3a8fa3d1acbbf/pyproj-3.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:7a27151ddad8e1439ba70c9b4b2b617b290c39395fa9ddb7411ebb0eb86d6fb0", size = 6079779, upload-time = "2023-09-21T02:07:37.486Z" }, + { url = "https://files.pythonhosted.org/packages/d7/50/d369bbe62d7a0d1e2cb40bc211da86a3f6e0f3c99f872957a72c3d5492d6/pyproj-3.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4ba1f9b03d04d8cab24d6375609070580a26ce76eaed54631f03bab00a9c737b", size = 6144755, upload-time = "2023-09-21T02:07:39.611Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/8d4f61065dfed965e53badd41201ad86a05af0c1bbc75dffb12ef0f5a7dd/pyproj-3.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:18faa54a3ca475bfe6255156f2f2874e9a1c8917b0004eee9f664b86ccc513d3", size = 4879187, upload-time = "2023-09-21T02:10:45.519Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/2cf8777cb2d5622a78195e690281b7029098795fde4751aec8128238b8bb/pyproj-3.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd43bd9a9b9239805f406fd82ba6b106bf4838d9ef37c167d3ed70383943ade1", size = 6192339, upload-time = "2023-09-21T02:17:09.942Z" }, + { url = "https://files.pythonhosted.org/packages/97/0a/b1525be9680369cc06dd288e12c59d24d5798b4afcdcf1b0915836e1caa6/pyproj-3.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50100b2726a3ca946906cbaa789dd0749f213abf0cbb877e6de72ca7aa50e1ae", size = 8332638, upload-time = "2023-09-21T02:07:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e8/e826e0a962f36bd925a933829cf6ef218efe2055db5ea292be40974a929d/pyproj-3.6.1-cp39-cp39-win32.whl", hash = "sha256:9274880263256f6292ff644ca92c46d96aa7e57a75c6df3f11d636ce845a1877", size = 5638159, upload-time = "2023-09-21T02:07:43.49Z" }, + { url = "https://files.pythonhosted.org/packages/43/d0/cbe29a4dcf38ee7e72bf695d0d3f2bee21b4f22ee6cf579ad974de9edfc8/pyproj-3.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:36b64c2cb6ea1cc091f329c5bd34f9c01bb5da8c8e4492c709bda6a09f96808f", size = 6090565, upload-time = "2023-09-21T02:07:45.735Z" }, + { url = "https://files.pythonhosted.org/packages/43/28/e8d2ca71dd56c27cbe668e4226963d61956cded222a2e839e6fec1ab6d82/pyproj-3.6.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fd93c1a0c6c4aedc77c0fe275a9f2aba4d59b8acf88cebfc19fe3c430cfabf4f", size = 6034252, upload-time = "2023-09-21T02:07:47.906Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/1ce27cb86f51a1f5aed3a1617802a6131b59ea78492141d1fbe36722595e/pyproj-3.6.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6420ea8e7d2a88cb148b124429fba8cd2e0fae700a2d96eab7083c0928a85110", size = 6386263, upload-time = "2023-09-21T02:07:49.586Z" }, +] [[package]] name = "pyproj" version = "3.7.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", + "python_full_version == '3.10.*'", ] dependencies = [ - { name = "certifi", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/10/a8480ea27ea4bbe896c168808854d00f2a9b49f95c0319ddcbba693c8a90/pyproj-3.7.1.tar.gz", hash = "sha256:60d72facd7b6b79853f19744779abcd3f804c4e0d4fa8815469db20c9f640a47", size = 226339 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/a3/c4cd4bba5b336075f145fe784fcaf4ef56ffbc979833303303e7a659dda2/pyproj-3.7.1-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:bf09dbeb333c34e9c546364e7df1ff40474f9fddf9e70657ecb0e4f670ff0b0e", size = 6262524 }, - { url = "https://files.pythonhosted.org/packages/40/45/4fdf18f4cc1995f1992771d2a51cf186a9d7a8ec973c9693f8453850c707/pyproj-3.7.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:6575b2e53cc9e3e461ad6f0692a5564b96e7782c28631c7771c668770915e169", size = 4665102 }, - { url = "https://files.pythonhosted.org/packages/0c/d2/360eb127380106cee83569954ae696b88a891c804d7a93abe3fbc15f5976/pyproj-3.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cb516ee35ed57789b46b96080edf4e503fdb62dbb2e3c6581e0d6c83fca014b", size = 9432667 }, - { url = "https://files.pythonhosted.org/packages/76/a5/c6e11b9a99ce146741fb4d184d5c468446c6d6015b183cae82ac822a6cfa/pyproj-3.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e47c4e93b88d99dd118875ee3ca0171932444cdc0b52d493371b5d98d0f30ee", size = 9259185 }, - { url = "https://files.pythonhosted.org/packages/41/56/a3c15c42145797a99363fa0fdb4e9805dccb8b4a76a6d7b2cdf36ebcc2a1/pyproj-3.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3e8d276caeae34fcbe4813855d0d97b9b825bab8d7a8b86d859c24a6213a5a0d", size = 10469103 }, - { url = "https://files.pythonhosted.org/packages/ef/73/c9194c2802fefe2a4fd4230bdd5ab083e7604e93c64d0356fa49c363bad6/pyproj-3.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f173f851ee75e54acdaa053382b6825b400cb2085663a9bb073728a59c60aebb", size = 10401391 }, - { url = "https://files.pythonhosted.org/packages/c5/1d/ce8bb5b9251b04d7c22d63619bb3db3d2397f79000a9ae05b3fd86a5837e/pyproj-3.7.1-cp310-cp310-win32.whl", hash = "sha256:f550281ed6e5ea88fcf04a7c6154e246d5714be495c50c9e8e6b12d3fb63e158", size = 5869997 }, - { url = "https://files.pythonhosted.org/packages/09/6a/ca145467fd2e5b21e3d5b8c2b9645dcfb3b68f08b62417699a1f5689008e/pyproj-3.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:3537668992a709a2e7f068069192138618c00d0ba113572fdd5ee5ffde8222f3", size = 6278581 }, - { url = "https://files.pythonhosted.org/packages/ab/0d/63670fc527e664068b70b7cab599aa38b7420dd009bdc29ea257e7f3dfb3/pyproj-3.7.1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:a94e26c1a4950cea40116775588a2ca7cf56f1f434ff54ee35a84718f3841a3d", size = 6264315 }, - { url = "https://files.pythonhosted.org/packages/25/9d/cbaf82cfb290d1f1fa42feb9ba9464013bb3891e40c4199f8072112e4589/pyproj-3.7.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:263b54ba5004b6b957d55757d846fc5081bc02980caa0279c4fc95fa0fff6067", size = 4666267 }, - { url = "https://files.pythonhosted.org/packages/79/53/24f9f9b8918c0550f3ff49ad5de4cf3f0688c9f91ff191476db8979146fe/pyproj-3.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6d6a2ccd5607cd15ef990c51e6f2dd27ec0a741e72069c387088bba3aab60fa", size = 9680510 }, - { url = "https://files.pythonhosted.org/packages/3c/ac/12fab74a908d40b63174dc704587febd0729414804bbfd873cabe504ff2d/pyproj-3.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c5dcf24ede53d8abab7d8a77f69ff1936c6a8843ef4fcc574646e4be66e5739", size = 9493619 }, - { url = "https://files.pythonhosted.org/packages/c4/45/26311d6437135da2153a178125db5dfb6abce831ce04d10ec207eabac70a/pyproj-3.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c2e7449840a44ce860d8bea2c6c1c4bc63fa07cba801dcce581d14dcb031a02", size = 10709755 }, - { url = "https://files.pythonhosted.org/packages/99/52/4ecd0986f27d0e6c8ee3a7bc5c63da15acd30ac23034f871325b297e61fd/pyproj-3.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0829865c1d3a3543f918b3919dc601eea572d6091c0dd175e1a054db9c109274", size = 10642970 }, - { url = "https://files.pythonhosted.org/packages/3f/a5/d3bfc018fc92195a000d1d28acc1f3f1df15ff9f09ece68f45a2636c0134/pyproj-3.7.1-cp311-cp311-win32.whl", hash = "sha256:6181960b4b812e82e588407fe5c9c68ada267c3b084db078f248db5d7f45d18a", size = 5868295 }, - { url = "https://files.pythonhosted.org/packages/92/39/ef6f06a5b223dbea308cfcbb7a0f72e7b506aef1850e061b2c73b0818715/pyproj-3.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ad0ff443a785d84e2b380869fdd82e6bfc11eba6057d25b4409a9bbfa867970", size = 6279871 }, - { url = "https://files.pythonhosted.org/packages/e6/c9/876d4345b8d17f37ac59ebd39f8fa52fc6a6a9891a420f72d050edb6b899/pyproj-3.7.1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:2781029d90df7f8d431e29562a3f2d8eafdf233c4010d6fc0381858dc7373217", size = 6264087 }, - { url = "https://files.pythonhosted.org/packages/ff/e6/5f8691f8c90e7f402cc80a6276eb19d2ec1faa150d5ae2dd9c7b0a254da8/pyproj-3.7.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d61bf8ab04c73c1da08eedaf21a103b72fa5b0a9b854762905f65ff8b375d394", size = 4669628 }, - { url = "https://files.pythonhosted.org/packages/42/ec/16475bbb79c1c68845c0a0d9c60c4fb31e61b8a2a20bc18b1a81e81c7f68/pyproj-3.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04abc517a8555d1b05fcee768db3280143fe42ec39fdd926a2feef31631a1f2f", size = 9721415 }, - { url = "https://files.pythonhosted.org/packages/b3/a3/448f05b15e318bd6bea9a32cfaf11e886c4ae61fa3eee6e09ed5c3b74bb2/pyproj-3.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084c0a475688f934d386c2ab3b6ce03398a473cd48adfda70d9ab8f87f2394a0", size = 9556447 }, - { url = "https://files.pythonhosted.org/packages/6a/ae/bd15fe8d8bd914ead6d60bca7f895a4e6f8ef7e3928295134ff9a7dad14c/pyproj-3.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a20727a23b1e49c7dc7fe3c3df8e56a8a7acdade80ac2f5cca29d7ca5564c145", size = 10758317 }, - { url = "https://files.pythonhosted.org/packages/9d/d9/5ccefb8bca925f44256b188a91c31238cae29ab6ee7f53661ecc04616146/pyproj-3.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bf84d766646f1ebd706d883755df4370aaf02b48187cedaa7e4239f16bc8213d", size = 10771259 }, - { url = "https://files.pythonhosted.org/packages/2a/7d/31dedff9c35fa703162f922eeb0baa6c44a3288469a5fd88d209e2892f9e/pyproj-3.7.1-cp312-cp312-win32.whl", hash = "sha256:5f0da2711364d7cb9f115b52289d4a9b61e8bca0da57f44a3a9d6fc9bdeb7274", size = 5859914 }, - { url = "https://files.pythonhosted.org/packages/3e/47/c6ab03d6564a7c937590cff81a2742b5990f096cce7c1a622d325be340ee/pyproj-3.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:aee664a9d806612af30a19dba49e55a7a78ebfec3e9d198f6a6176e1d140ec98", size = 6273196 }, - { url = "https://files.pythonhosted.org/packages/ef/01/984828464c9960036c602753fc0f21f24f0aa9043c18fa3f2f2b66a86340/pyproj-3.7.1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:5f8d02ef4431dee414d1753d13fa82a21a2f61494737b5f642ea668d76164d6d", size = 6253062 }, - { url = "https://files.pythonhosted.org/packages/68/65/6ecdcdc829811a2c160cdfe2f068a009fc572fd4349664f758ccb0853a7c/pyproj-3.7.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0b853ae99bda66cbe24b4ccfe26d70601d84375940a47f553413d9df570065e0", size = 4660548 }, - { url = "https://files.pythonhosted.org/packages/67/da/dda94c4490803679230ba4c17a12f151b307a0d58e8110820405ca2d98db/pyproj-3.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83db380c52087f9e9bdd8a527943b2e7324f275881125e39475c4f9277bdeec4", size = 9662464 }, - { url = "https://files.pythonhosted.org/packages/6f/57/f61b7d22c91ae1d12ee00ac4c0038714e774ebcd851b9133e5f4f930dd40/pyproj-3.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b35ed213892e211a3ce2bea002aa1183e1a2a9b79e51bb3c6b15549a831ae528", size = 9497461 }, - { url = "https://files.pythonhosted.org/packages/b7/f6/932128236f79d2ac7d39fe1a19667fdf7155d9a81d31fb9472a7a497790f/pyproj-3.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a8b15b0463d1303bab113d1a6af2860a0d79013c3a66fcc5475ce26ef717fd4f", size = 10708869 }, - { url = "https://files.pythonhosted.org/packages/1d/0d/07ac7712994454a254c383c0d08aff9916a2851e6512d59da8dc369b1b02/pyproj-3.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:87229e42b75e89f4dad6459200f92988c5998dfb093c7c631fb48524c86cd5dc", size = 10729260 }, - { url = "https://files.pythonhosted.org/packages/b0/d0/9c604bc72c37ba69b867b6df724d6a5af6789e8c375022c952f65b2af558/pyproj-3.7.1-cp313-cp313-win32.whl", hash = "sha256:d666c3a3faaf3b1d7fc4a544059c4eab9d06f84a604b070b7aa2f318e227798e", size = 5855462 }, - { url = "https://files.pythonhosted.org/packages/98/df/68a2b7f5fb6400c64aad82d72bcc4bc531775e62eedff993a77c780defd0/pyproj-3.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:d3caac7473be22b6d6e102dde6c46de73b96bc98334e577dfaee9886f102ea2e", size = 6266573 }, + { name = "certifi", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/10/a8480ea27ea4bbe896c168808854d00f2a9b49f95c0319ddcbba693c8a90/pyproj-3.7.1.tar.gz", hash = "sha256:60d72facd7b6b79853f19744779abcd3f804c4e0d4fa8815469db20c9f640a47", size = 226339, upload-time = "2025-02-16T04:28:46.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/a3/c4cd4bba5b336075f145fe784fcaf4ef56ffbc979833303303e7a659dda2/pyproj-3.7.1-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:bf09dbeb333c34e9c546364e7df1ff40474f9fddf9e70657ecb0e4f670ff0b0e", size = 6262524, upload-time = "2025-02-16T04:27:19.725Z" }, + { url = "https://files.pythonhosted.org/packages/40/45/4fdf18f4cc1995f1992771d2a51cf186a9d7a8ec973c9693f8453850c707/pyproj-3.7.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:6575b2e53cc9e3e461ad6f0692a5564b96e7782c28631c7771c668770915e169", size = 4665102, upload-time = "2025-02-16T04:27:24.428Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d2/360eb127380106cee83569954ae696b88a891c804d7a93abe3fbc15f5976/pyproj-3.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cb516ee35ed57789b46b96080edf4e503fdb62dbb2e3c6581e0d6c83fca014b", size = 9432667, upload-time = "2025-02-16T04:27:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/76/a5/c6e11b9a99ce146741fb4d184d5c468446c6d6015b183cae82ac822a6cfa/pyproj-3.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e47c4e93b88d99dd118875ee3ca0171932444cdc0b52d493371b5d98d0f30ee", size = 9259185, upload-time = "2025-02-16T04:27:30.35Z" }, + { url = "https://files.pythonhosted.org/packages/41/56/a3c15c42145797a99363fa0fdb4e9805dccb8b4a76a6d7b2cdf36ebcc2a1/pyproj-3.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3e8d276caeae34fcbe4813855d0d97b9b825bab8d7a8b86d859c24a6213a5a0d", size = 10469103, upload-time = "2025-02-16T04:27:33.542Z" }, + { url = "https://files.pythonhosted.org/packages/ef/73/c9194c2802fefe2a4fd4230bdd5ab083e7604e93c64d0356fa49c363bad6/pyproj-3.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f173f851ee75e54acdaa053382b6825b400cb2085663a9bb073728a59c60aebb", size = 10401391, upload-time = "2025-02-16T04:27:36.051Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1d/ce8bb5b9251b04d7c22d63619bb3db3d2397f79000a9ae05b3fd86a5837e/pyproj-3.7.1-cp310-cp310-win32.whl", hash = "sha256:f550281ed6e5ea88fcf04a7c6154e246d5714be495c50c9e8e6b12d3fb63e158", size = 5869997, upload-time = "2025-02-16T04:27:38.302Z" }, + { url = "https://files.pythonhosted.org/packages/09/6a/ca145467fd2e5b21e3d5b8c2b9645dcfb3b68f08b62417699a1f5689008e/pyproj-3.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:3537668992a709a2e7f068069192138618c00d0ba113572fdd5ee5ffde8222f3", size = 6278581, upload-time = "2025-02-16T04:27:41.051Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/63670fc527e664068b70b7cab599aa38b7420dd009bdc29ea257e7f3dfb3/pyproj-3.7.1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:a94e26c1a4950cea40116775588a2ca7cf56f1f434ff54ee35a84718f3841a3d", size = 6264315, upload-time = "2025-02-16T04:27:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/25/9d/cbaf82cfb290d1f1fa42feb9ba9464013bb3891e40c4199f8072112e4589/pyproj-3.7.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:263b54ba5004b6b957d55757d846fc5081bc02980caa0279c4fc95fa0fff6067", size = 4666267, upload-time = "2025-02-16T04:27:47.019Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/24f9f9b8918c0550f3ff49ad5de4cf3f0688c9f91ff191476db8979146fe/pyproj-3.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6d6a2ccd5607cd15ef990c51e6f2dd27ec0a741e72069c387088bba3aab60fa", size = 9680510, upload-time = "2025-02-16T04:27:49.239Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ac/12fab74a908d40b63174dc704587febd0729414804bbfd873cabe504ff2d/pyproj-3.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c5dcf24ede53d8abab7d8a77f69ff1936c6a8843ef4fcc574646e4be66e5739", size = 9493619, upload-time = "2025-02-16T04:27:52.65Z" }, + { url = "https://files.pythonhosted.org/packages/c4/45/26311d6437135da2153a178125db5dfb6abce831ce04d10ec207eabac70a/pyproj-3.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c2e7449840a44ce860d8bea2c6c1c4bc63fa07cba801dcce581d14dcb031a02", size = 10709755, upload-time = "2025-02-16T04:27:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/99/52/4ecd0986f27d0e6c8ee3a7bc5c63da15acd30ac23034f871325b297e61fd/pyproj-3.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0829865c1d3a3543f918b3919dc601eea572d6091c0dd175e1a054db9c109274", size = 10642970, upload-time = "2025-02-16T04:27:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a5/d3bfc018fc92195a000d1d28acc1f3f1df15ff9f09ece68f45a2636c0134/pyproj-3.7.1-cp311-cp311-win32.whl", hash = "sha256:6181960b4b812e82e588407fe5c9c68ada267c3b084db078f248db5d7f45d18a", size = 5868295, upload-time = "2025-02-16T04:28:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/92/39/ef6f06a5b223dbea308cfcbb7a0f72e7b506aef1850e061b2c73b0818715/pyproj-3.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ad0ff443a785d84e2b380869fdd82e6bfc11eba6057d25b4409a9bbfa867970", size = 6279871, upload-time = "2025-02-16T04:28:04.988Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c9/876d4345b8d17f37ac59ebd39f8fa52fc6a6a9891a420f72d050edb6b899/pyproj-3.7.1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:2781029d90df7f8d431e29562a3f2d8eafdf233c4010d6fc0381858dc7373217", size = 6264087, upload-time = "2025-02-16T04:28:09.036Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/5f8691f8c90e7f402cc80a6276eb19d2ec1faa150d5ae2dd9c7b0a254da8/pyproj-3.7.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d61bf8ab04c73c1da08eedaf21a103b72fa5b0a9b854762905f65ff8b375d394", size = 4669628, upload-time = "2025-02-16T04:28:10.944Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/16475bbb79c1c68845c0a0d9c60c4fb31e61b8a2a20bc18b1a81e81c7f68/pyproj-3.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04abc517a8555d1b05fcee768db3280143fe42ec39fdd926a2feef31631a1f2f", size = 9721415, upload-time = "2025-02-16T04:28:13.342Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a3/448f05b15e318bd6bea9a32cfaf11e886c4ae61fa3eee6e09ed5c3b74bb2/pyproj-3.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084c0a475688f934d386c2ab3b6ce03398a473cd48adfda70d9ab8f87f2394a0", size = 9556447, upload-time = "2025-02-16T04:28:15.818Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ae/bd15fe8d8bd914ead6d60bca7f895a4e6f8ef7e3928295134ff9a7dad14c/pyproj-3.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a20727a23b1e49c7dc7fe3c3df8e56a8a7acdade80ac2f5cca29d7ca5564c145", size = 10758317, upload-time = "2025-02-16T04:28:18.338Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/5ccefb8bca925f44256b188a91c31238cae29ab6ee7f53661ecc04616146/pyproj-3.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bf84d766646f1ebd706d883755df4370aaf02b48187cedaa7e4239f16bc8213d", size = 10771259, upload-time = "2025-02-16T04:28:20.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7d/31dedff9c35fa703162f922eeb0baa6c44a3288469a5fd88d209e2892f9e/pyproj-3.7.1-cp312-cp312-win32.whl", hash = "sha256:5f0da2711364d7cb9f115b52289d4a9b61e8bca0da57f44a3a9d6fc9bdeb7274", size = 5859914, upload-time = "2025-02-16T04:28:23.303Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/c6ab03d6564a7c937590cff81a2742b5990f096cce7c1a622d325be340ee/pyproj-3.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:aee664a9d806612af30a19dba49e55a7a78ebfec3e9d198f6a6176e1d140ec98", size = 6273196, upload-time = "2025-02-16T04:28:25.227Z" }, + { url = "https://files.pythonhosted.org/packages/ef/01/984828464c9960036c602753fc0f21f24f0aa9043c18fa3f2f2b66a86340/pyproj-3.7.1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:5f8d02ef4431dee414d1753d13fa82a21a2f61494737b5f642ea668d76164d6d", size = 6253062, upload-time = "2025-02-16T04:28:27.861Z" }, + { url = "https://files.pythonhosted.org/packages/68/65/6ecdcdc829811a2c160cdfe2f068a009fc572fd4349664f758ccb0853a7c/pyproj-3.7.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0b853ae99bda66cbe24b4ccfe26d70601d84375940a47f553413d9df570065e0", size = 4660548, upload-time = "2025-02-16T04:28:29.526Z" }, + { url = "https://files.pythonhosted.org/packages/67/da/dda94c4490803679230ba4c17a12f151b307a0d58e8110820405ca2d98db/pyproj-3.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83db380c52087f9e9bdd8a527943b2e7324f275881125e39475c4f9277bdeec4", size = 9662464, upload-time = "2025-02-16T04:28:31.437Z" }, + { url = "https://files.pythonhosted.org/packages/6f/57/f61b7d22c91ae1d12ee00ac4c0038714e774ebcd851b9133e5f4f930dd40/pyproj-3.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b35ed213892e211a3ce2bea002aa1183e1a2a9b79e51bb3c6b15549a831ae528", size = 9497461, upload-time = "2025-02-16T04:28:33.848Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f6/932128236f79d2ac7d39fe1a19667fdf7155d9a81d31fb9472a7a497790f/pyproj-3.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a8b15b0463d1303bab113d1a6af2860a0d79013c3a66fcc5475ce26ef717fd4f", size = 10708869, upload-time = "2025-02-16T04:28:37.34Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0d/07ac7712994454a254c383c0d08aff9916a2851e6512d59da8dc369b1b02/pyproj-3.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:87229e42b75e89f4dad6459200f92988c5998dfb093c7c631fb48524c86cd5dc", size = 10729260, upload-time = "2025-02-16T04:28:40.639Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d0/9c604bc72c37ba69b867b6df724d6a5af6789e8c375022c952f65b2af558/pyproj-3.7.1-cp313-cp313-win32.whl", hash = "sha256:d666c3a3faaf3b1d7fc4a544059c4eab9d06f84a604b070b7aa2f318e227798e", size = 5855462, upload-time = "2025-02-16T04:28:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/68a2b7f5fb6400c64aad82d72bcc4bc531775e62eedff993a77c780defd0/pyproj-3.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:d3caac7473be22b6d6e102dde6c46de73b96bc98334e577dfaee9886f102ea2e", size = 6266573, upload-time = "2025-02-16T04:28:44.727Z" }, ] [[package]] @@ -2273,62 +2653,62 @@ resolution-markers = [ dependencies = [ { name = "certifi", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/bd/f205552cd1713b08f93b09e39a3ec99edef0b3ebbbca67b486fdf1abe2de/pyproj-3.7.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:2514d61f24c4e0bb9913e2c51487ecdaeca5f8748d8313c933693416ca41d4d5", size = 6227022 }, - { url = "https://files.pythonhosted.org/packages/75/4c/9a937e659b8b418ab573c6d340d27e68716928953273e0837e7922fcac34/pyproj-3.7.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8693ca3892d82e70de077701ee76dd13d7bca4ae1c9d1e739d72004df015923a", size = 4625810 }, - { url = "https://files.pythonhosted.org/packages/c0/7d/a9f41e814dc4d1dc54e95b2ccaf0b3ebe3eb18b1740df05fe334724c3d89/pyproj-3.7.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5e26484d80fea56273ed1555abaea161e9661d81a6c07815d54b8e883d4ceb25", size = 9638694 }, - { url = "https://files.pythonhosted.org/packages/ad/ab/9bdb4a6216b712a1f9aab1c0fcbee5d3726f34a366f29c3e8c08a78d6b70/pyproj-3.7.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:281cb92847814e8018010c48b4069ff858a30236638631c1a91dd7bfa68f8a8a", size = 9493977 }, - { url = "https://files.pythonhosted.org/packages/c9/db/2db75b1b6190f1137b1c4e8ef6a22e1c338e46320f6329bfac819143e063/pyproj-3.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9c8577f0b7bb09118ec2e57e3babdc977127dd66326d6c5d755c76b063e6d9dc", size = 10841151 }, - { url = "https://files.pythonhosted.org/packages/89/f7/989643394ba23a286e9b7b3f09981496172f9e0d4512457ffea7dc47ffc7/pyproj-3.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a23f59904fac3a5e7364b3aa44d288234af267ca041adb2c2b14a903cd5d3ac5", size = 10751585 }, - { url = "https://files.pythonhosted.org/packages/53/6d/ad928fe975a6c14a093c92e6a319ca18f479f3336bb353a740bdba335681/pyproj-3.7.2-cp311-cp311-win32.whl", hash = "sha256:f2af4ed34b2cf3e031a2d85b067a3ecbd38df073c567e04b52fa7a0202afde8a", size = 5908533 }, - { url = "https://files.pythonhosted.org/packages/79/e0/b95584605cec9ed50b7ebaf7975d1c4ddeec5a86b7a20554ed8b60042bd7/pyproj-3.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:0b7cb633565129677b2a183c4d807c727d1c736fcb0568a12299383056e67433", size = 6320742 }, - { url = "https://files.pythonhosted.org/packages/b7/4d/536e8f93bca808175c2d0a5ac9fdf69b960d8ab6b14f25030dccb07464d7/pyproj-3.7.2-cp311-cp311-win_arm64.whl", hash = "sha256:38b08d85e3a38e455625b80e9eb9f78027c8e2649a21dec4df1f9c3525460c71", size = 6245772 }, - { url = "https://files.pythonhosted.org/packages/8d/ab/9893ea9fb066be70ed9074ae543914a618c131ed8dff2da1e08b3a4df4db/pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0a9bb26a6356fb5b033433a6d1b4542158fb71e3c51de49b4c318a1dff3aeaab", size = 6219832 }, - { url = "https://files.pythonhosted.org/packages/53/78/4c64199146eed7184eb0e85bedec60a4aa8853b6ffe1ab1f3a8b962e70a0/pyproj-3.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:567caa03021178861fad27fabde87500ec6d2ee173dd32f3e2d9871e40eebd68", size = 4620650 }, - { url = "https://files.pythonhosted.org/packages/b6/ac/14a78d17943898a93ef4f8c6a9d4169911c994e3161e54a7cedeba9d8dde/pyproj-3.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c203101d1dc3c038a56cff0447acc515dd29d6e14811406ac539c21eed422b2a", size = 9667087 }, - { url = "https://files.pythonhosted.org/packages/b8/be/212882c450bba74fc8d7d35cbd57e4af84792f0a56194819d98106b075af/pyproj-3.7.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1edc34266c0c23ced85f95a1ee8b47c9035eae6aca5b6b340327250e8e281630", size = 9552797 }, - { url = "https://files.pythonhosted.org/packages/ba/c0/c0f25c87b5d2a8686341c53c1792a222a480d6c9caf60311fec12c99ec26/pyproj-3.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa9f26c21bc0e2dc3d224cb1eb4020cf23e76af179a7c66fea49b828611e4260", size = 10837036 }, - { url = "https://files.pythonhosted.org/packages/5d/37/5cbd6772addde2090c91113332623a86e8c7d583eccb2ad02ea634c4a89f/pyproj-3.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9428b318530625cb389b9ddc9c51251e172808a4af79b82809376daaeabe5e9", size = 10775952 }, - { url = "https://files.pythonhosted.org/packages/69/a1/dc250e3cf83eb4b3b9a2cf86fdb5e25288bd40037ae449695550f9e96b2f/pyproj-3.7.2-cp312-cp312-win32.whl", hash = "sha256:b3d99ed57d319da042f175f4554fc7038aa4bcecc4ac89e217e350346b742c9d", size = 5898872 }, - { url = "https://files.pythonhosted.org/packages/4a/a6/6fe724b72b70f2b00152d77282e14964d60ab092ec225e67c196c9b463e5/pyproj-3.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:11614a054cd86a2ed968a657d00987a86eeb91fdcbd9ad3310478685dc14a128", size = 6312176 }, - { url = "https://files.pythonhosted.org/packages/5d/68/915cc32c02a91e76d02c8f55d5a138d6ef9e47a0d96d259df98f4842e558/pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3", size = 6233452 }, - { url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580 }, - { url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388 }, - { url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455 }, - { url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269 }, - { url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437 }, - { url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540 }, - { url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506 }, - { url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195 }, - { url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748 }, - { url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729 }, - { url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497 }, - { url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610 }, - { url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390 }, - { url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596 }, - { url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975 }, - { url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057 }, - { url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414 }, - { url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501 }, - { url = "https://files.pythonhosted.org/packages/42/17/c2b050d3f5b71b6edd0d96ae16c990fdc42a5f1366464a5c2772146de33a/pyproj-3.7.2-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:2aaa328605ace41db050d06bac1adc11f01b71fe95c18661497763116c3a0f02", size = 6214541 }, - { url = "https://files.pythonhosted.org/packages/03/68/68ada9c8aea96ded09a66cfd9bf87aa6db8c2edebe93f5bf9b66b0143fbc/pyproj-3.7.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:35dccbce8201313c596a970fde90e33605248b66272595c061b511c8100ccc08", size = 4617456 }, - { url = "https://files.pythonhosted.org/packages/81/e4/4c50ceca7d0e937977866b02cb64e6ccf4df979a5871e521f9e255df6073/pyproj-3.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:25b0b7cb0042444c29a164b993c45c1b8013d6c48baa61dc1160d834a277e83b", size = 9615590 }, - { url = "https://files.pythonhosted.org/packages/05/1e/ada6fb15a1d75b5bd9b554355a69a798c55a7dcc93b8d41596265c1772e3/pyproj-3.7.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:85def3a6388e9ba51f964619aa002a9d2098e77c6454ff47773bb68871024281", size = 9474960 }, - { url = "https://files.pythonhosted.org/packages/51/07/9d48ad0a8db36e16f842f2c8a694c1d9d7dcf9137264846bef77585a71f3/pyproj-3.7.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b1bccefec3875ab81eabf49059e2b2ea77362c178b66fd3528c3e4df242f1516", size = 10799478 }, - { url = "https://files.pythonhosted.org/packages/85/cf/2f812b529079f72f51ff2d6456b7fef06c01735e5cfd62d54ffb2b548028/pyproj-3.7.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d5371ca114d6990b675247355a801925814eca53e6c4b2f1b5c0a956336ee36e", size = 10710030 }, - { url = "https://files.pythonhosted.org/packages/99/9b/4626a19e1f03eba4c0e77b91a6cf0f73aa9cb5d51a22ee385c22812bcc2c/pyproj-3.7.2-cp314-cp314-win32.whl", hash = "sha256:77f066626030f41be543274f5ac79f2a511fe89860ecd0914f22131b40a0ec25", size = 5991181 }, - { url = "https://files.pythonhosted.org/packages/04/b2/5a6610554306a83a563080c2cf2c57565563eadd280e15388efa00fb5b33/pyproj-3.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a964da1696b8522806f4276ab04ccfff8f9eb95133a92a25900697609d40112", size = 6434721 }, - { url = "https://files.pythonhosted.org/packages/ae/ce/6c910ea2e1c74ef673c5d48c482564b8a7824a44c4e35cca2e765b68cfcc/pyproj-3.7.2-cp314-cp314-win_arm64.whl", hash = "sha256:e258ab4dbd3cf627809067c0ba8f9884ea76c8e5999d039fb37a1619c6c3e1f6", size = 6363821 }, - { url = "https://files.pythonhosted.org/packages/e4/e4/5532f6f7491812ba782a2177fe9de73fd8e2912b59f46a1d056b84b9b8f2/pyproj-3.7.2-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:bbbac2f930c6d266f70ec75df35ef851d96fdb3701c674f42fd23a9314573b37", size = 6241773 }, - { url = "https://files.pythonhosted.org/packages/20/1f/0938c3f2bbbef1789132d1726d9b0e662f10cfc22522743937f421ad664e/pyproj-3.7.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b7544e0a3d6339dc9151e9c8f3ea62a936ab7cc446a806ec448bbe86aebb979b", size = 4652537 }, - { url = "https://files.pythonhosted.org/packages/c7/a8/488b1ed47d25972f33874f91f09ca8f2227902f05f63a2b80dc73e7b1c97/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7f5133dca4c703e8acadf6f30bc567d39a42c6af321e7f81975c2518f3ed357", size = 9940864 }, - { url = "https://files.pythonhosted.org/packages/c7/cc/7f4c895d0cb98e47b6a85a6d79eaca03eb266129eed2f845125c09cf31ff/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5aff3343038d7426aa5076f07feb88065f50e0502d1b0d7c22ddfdd2c75a3f81", size = 9688868 }, - { url = "https://files.pythonhosted.org/packages/b2/b7/c7e306b8bb0f071d9825b753ee4920f066c40fbfcce9372c4f3cfb2fc4ed/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b0552178c61f2ac1c820d087e8ba6e62b29442debddbb09d51c4bf8acc84d888", size = 11045910 }, - { url = "https://files.pythonhosted.org/packages/42/fb/538a4d2df695980e2dde5c04d965fbdd1fe8c20a3194dc4aaa3952a4d1be/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:47d87db2d2c436c5fd0409b34d70bb6cdb875cca2ebe7a9d1c442367b0ab8d59", size = 10895724 }, - { url = "https://files.pythonhosted.org/packages/e8/8b/a3f0618b03957de9db5489a04558a8826f43906628bb0b766033aa3b5548/pyproj-3.7.2-cp314-cp314t-win32.whl", hash = "sha256:c9b6f1d8ad3e80a0ee0903a778b6ece7dca1d1d40f6d114ae01bc8ddbad971aa", size = 6056848 }, - { url = "https://files.pythonhosted.org/packages/bc/56/413240dd5149dd3291eda55aa55a659da4431244a2fd1319d0ae89407cfb/pyproj-3.7.2-cp314-cp314t-win_amd64.whl", hash = "sha256:1914e29e27933ba6f9822663ee0600f169014a2859f851c054c88cf5ea8a333c", size = 6517676 }, - { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844 }, +sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/bd/f205552cd1713b08f93b09e39a3ec99edef0b3ebbbca67b486fdf1abe2de/pyproj-3.7.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:2514d61f24c4e0bb9913e2c51487ecdaeca5f8748d8313c933693416ca41d4d5", size = 6227022, upload-time = "2025-08-14T12:03:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/75/4c/9a937e659b8b418ab573c6d340d27e68716928953273e0837e7922fcac34/pyproj-3.7.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8693ca3892d82e70de077701ee76dd13d7bca4ae1c9d1e739d72004df015923a", size = 4625810, upload-time = "2025-08-14T12:03:53.808Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/a9f41e814dc4d1dc54e95b2ccaf0b3ebe3eb18b1740df05fe334724c3d89/pyproj-3.7.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5e26484d80fea56273ed1555abaea161e9661d81a6c07815d54b8e883d4ceb25", size = 9638694, upload-time = "2025-08-14T12:03:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ab/9bdb4a6216b712a1f9aab1c0fcbee5d3726f34a366f29c3e8c08a78d6b70/pyproj-3.7.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:281cb92847814e8018010c48b4069ff858a30236638631c1a91dd7bfa68f8a8a", size = 9493977, upload-time = "2025-08-14T12:03:57.937Z" }, + { url = "https://files.pythonhosted.org/packages/c9/db/2db75b1b6190f1137b1c4e8ef6a22e1c338e46320f6329bfac819143e063/pyproj-3.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9c8577f0b7bb09118ec2e57e3babdc977127dd66326d6c5d755c76b063e6d9dc", size = 10841151, upload-time = "2025-08-14T12:04:00.271Z" }, + { url = "https://files.pythonhosted.org/packages/89/f7/989643394ba23a286e9b7b3f09981496172f9e0d4512457ffea7dc47ffc7/pyproj-3.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a23f59904fac3a5e7364b3aa44d288234af267ca041adb2c2b14a903cd5d3ac5", size = 10751585, upload-time = "2025-08-14T12:04:02.228Z" }, + { url = "https://files.pythonhosted.org/packages/53/6d/ad928fe975a6c14a093c92e6a319ca18f479f3336bb353a740bdba335681/pyproj-3.7.2-cp311-cp311-win32.whl", hash = "sha256:f2af4ed34b2cf3e031a2d85b067a3ecbd38df073c567e04b52fa7a0202afde8a", size = 5908533, upload-time = "2025-08-14T12:04:04.821Z" }, + { url = "https://files.pythonhosted.org/packages/79/e0/b95584605cec9ed50b7ebaf7975d1c4ddeec5a86b7a20554ed8b60042bd7/pyproj-3.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:0b7cb633565129677b2a183c4d807c727d1c736fcb0568a12299383056e67433", size = 6320742, upload-time = "2025-08-14T12:04:06.357Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/536e8f93bca808175c2d0a5ac9fdf69b960d8ab6b14f25030dccb07464d7/pyproj-3.7.2-cp311-cp311-win_arm64.whl", hash = "sha256:38b08d85e3a38e455625b80e9eb9f78027c8e2649a21dec4df1f9c3525460c71", size = 6245772, upload-time = "2025-08-14T12:04:08.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ab/9893ea9fb066be70ed9074ae543914a618c131ed8dff2da1e08b3a4df4db/pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0a9bb26a6356fb5b033433a6d1b4542158fb71e3c51de49b4c318a1dff3aeaab", size = 6219832, upload-time = "2025-08-14T12:04:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/53/78/4c64199146eed7184eb0e85bedec60a4aa8853b6ffe1ab1f3a8b962e70a0/pyproj-3.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:567caa03021178861fad27fabde87500ec6d2ee173dd32f3e2d9871e40eebd68", size = 4620650, upload-time = "2025-08-14T12:04:11.978Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ac/14a78d17943898a93ef4f8c6a9d4169911c994e3161e54a7cedeba9d8dde/pyproj-3.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c203101d1dc3c038a56cff0447acc515dd29d6e14811406ac539c21eed422b2a", size = 9667087, upload-time = "2025-08-14T12:04:13.964Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/212882c450bba74fc8d7d35cbd57e4af84792f0a56194819d98106b075af/pyproj-3.7.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1edc34266c0c23ced85f95a1ee8b47c9035eae6aca5b6b340327250e8e281630", size = 9552797, upload-time = "2025-08-14T12:04:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/c0f25c87b5d2a8686341c53c1792a222a480d6c9caf60311fec12c99ec26/pyproj-3.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa9f26c21bc0e2dc3d224cb1eb4020cf23e76af179a7c66fea49b828611e4260", size = 10837036, upload-time = "2025-08-14T12:04:18.733Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/5cbd6772addde2090c91113332623a86e8c7d583eccb2ad02ea634c4a89f/pyproj-3.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9428b318530625cb389b9ddc9c51251e172808a4af79b82809376daaeabe5e9", size = 10775952, upload-time = "2025-08-14T12:04:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/69/a1/dc250e3cf83eb4b3b9a2cf86fdb5e25288bd40037ae449695550f9e96b2f/pyproj-3.7.2-cp312-cp312-win32.whl", hash = "sha256:b3d99ed57d319da042f175f4554fc7038aa4bcecc4ac89e217e350346b742c9d", size = 5898872, upload-time = "2025-08-14T12:04:22.485Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a6/6fe724b72b70f2b00152d77282e14964d60ab092ec225e67c196c9b463e5/pyproj-3.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:11614a054cd86a2ed968a657d00987a86eeb91fdcbd9ad3310478685dc14a128", size = 6312176, upload-time = "2025-08-14T12:04:24.736Z" }, + { url = "https://files.pythonhosted.org/packages/5d/68/915cc32c02a91e76d02c8f55d5a138d6ef9e47a0d96d259df98f4842e558/pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3", size = 6233452, upload-time = "2025-08-14T12:04:27.287Z" }, + { url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" }, + { url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" }, + { url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" }, + { url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/c2b050d3f5b71b6edd0d96ae16c990fdc42a5f1366464a5c2772146de33a/pyproj-3.7.2-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:2aaa328605ace41db050d06bac1adc11f01b71fe95c18661497763116c3a0f02", size = 6214541, upload-time = "2025-08-14T12:05:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/03/68/68ada9c8aea96ded09a66cfd9bf87aa6db8c2edebe93f5bf9b66b0143fbc/pyproj-3.7.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:35dccbce8201313c596a970fde90e33605248b66272595c061b511c8100ccc08", size = 4617456, upload-time = "2025-08-14T12:05:04.563Z" }, + { url = "https://files.pythonhosted.org/packages/81/e4/4c50ceca7d0e937977866b02cb64e6ccf4df979a5871e521f9e255df6073/pyproj-3.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:25b0b7cb0042444c29a164b993c45c1b8013d6c48baa61dc1160d834a277e83b", size = 9615590, upload-time = "2025-08-14T12:05:06.094Z" }, + { url = "https://files.pythonhosted.org/packages/05/1e/ada6fb15a1d75b5bd9b554355a69a798c55a7dcc93b8d41596265c1772e3/pyproj-3.7.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:85def3a6388e9ba51f964619aa002a9d2098e77c6454ff47773bb68871024281", size = 9474960, upload-time = "2025-08-14T12:05:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/51/07/9d48ad0a8db36e16f842f2c8a694c1d9d7dcf9137264846bef77585a71f3/pyproj-3.7.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b1bccefec3875ab81eabf49059e2b2ea77362c178b66fd3528c3e4df242f1516", size = 10799478, upload-time = "2025-08-14T12:05:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/85/cf/2f812b529079f72f51ff2d6456b7fef06c01735e5cfd62d54ffb2b548028/pyproj-3.7.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d5371ca114d6990b675247355a801925814eca53e6c4b2f1b5c0a956336ee36e", size = 10710030, upload-time = "2025-08-14T12:05:16.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/9b/4626a19e1f03eba4c0e77b91a6cf0f73aa9cb5d51a22ee385c22812bcc2c/pyproj-3.7.2-cp314-cp314-win32.whl", hash = "sha256:77f066626030f41be543274f5ac79f2a511fe89860ecd0914f22131b40a0ec25", size = 5991181, upload-time = "2025-08-14T12:05:19.492Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/5a6610554306a83a563080c2cf2c57565563eadd280e15388efa00fb5b33/pyproj-3.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a964da1696b8522806f4276ab04ccfff8f9eb95133a92a25900697609d40112", size = 6434721, upload-time = "2025-08-14T12:05:21.022Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/6c910ea2e1c74ef673c5d48c482564b8a7824a44c4e35cca2e765b68cfcc/pyproj-3.7.2-cp314-cp314-win_arm64.whl", hash = "sha256:e258ab4dbd3cf627809067c0ba8f9884ea76c8e5999d039fb37a1619c6c3e1f6", size = 6363821, upload-time = "2025-08-14T12:05:22.627Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/5532f6f7491812ba782a2177fe9de73fd8e2912b59f46a1d056b84b9b8f2/pyproj-3.7.2-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:bbbac2f930c6d266f70ec75df35ef851d96fdb3701c674f42fd23a9314573b37", size = 6241773, upload-time = "2025-08-14T12:05:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/0938c3f2bbbef1789132d1726d9b0e662f10cfc22522743937f421ad664e/pyproj-3.7.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b7544e0a3d6339dc9151e9c8f3ea62a936ab7cc446a806ec448bbe86aebb979b", size = 4652537, upload-time = "2025-08-14T12:05:26.391Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/488b1ed47d25972f33874f91f09ca8f2227902f05f63a2b80dc73e7b1c97/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7f5133dca4c703e8acadf6f30bc567d39a42c6af321e7f81975c2518f3ed357", size = 9940864, upload-time = "2025-08-14T12:05:27.985Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/7f4c895d0cb98e47b6a85a6d79eaca03eb266129eed2f845125c09cf31ff/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5aff3343038d7426aa5076f07feb88065f50e0502d1b0d7c22ddfdd2c75a3f81", size = 9688868, upload-time = "2025-08-14T12:05:30.425Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/c7e306b8bb0f071d9825b753ee4920f066c40fbfcce9372c4f3cfb2fc4ed/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b0552178c61f2ac1c820d087e8ba6e62b29442debddbb09d51c4bf8acc84d888", size = 11045910, upload-time = "2025-08-14T12:05:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/42/fb/538a4d2df695980e2dde5c04d965fbdd1fe8c20a3194dc4aaa3952a4d1be/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:47d87db2d2c436c5fd0409b34d70bb6cdb875cca2ebe7a9d1c442367b0ab8d59", size = 10895724, upload-time = "2025-08-14T12:05:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/a3f0618b03957de9db5489a04558a8826f43906628bb0b766033aa3b5548/pyproj-3.7.2-cp314-cp314t-win32.whl", hash = "sha256:c9b6f1d8ad3e80a0ee0903a778b6ece7dca1d1d40f6d114ae01bc8ddbad971aa", size = 6056848, upload-time = "2025-08-14T12:05:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/bc/56/413240dd5149dd3291eda55aa55a659da4431244a2fd1319d0ae89407cfb/pyproj-3.7.2-cp314-cp314t-win_amd64.whl", hash = "sha256:1914e29e27933ba6f9822663ee0600f169014a2859f851c054c88cf5ea8a333c", size = 6517676, upload-time = "2025-08-14T12:05:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" }, ] [[package]] @@ -2339,9 +2719,9 @@ dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/16/6b4fbdd1fef59a0292cbb99f790b44983e390321eccbc5921b4d161da5d1/pyright-1.1.406.tar.gz", hash = "sha256:c4872bc58c9643dac09e8a2e74d472c62036910b3bd37a32813989ef7576ea2c", size = 4113151 } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/6b4fbdd1fef59a0292cbb99f790b44983e390321eccbc5921b4d161da5d1/pyright-1.1.406.tar.gz", hash = "sha256:c4872bc58c9643dac09e8a2e74d472c62036910b3bd37a32813989ef7576ea2c", size = 4113151, upload-time = "2025-10-02T01:04:45.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/a2/e309afbb459f50507103793aaef85ca4348b66814c86bc73908bdeb66d12/pyright-1.1.406-py3-none-any.whl", hash = "sha256:1d81fb43c2407bf566e97e57abb01c811973fdb21b2df8df59f870f688bdca71", size = 5980982 }, + { url = "https://files.pythonhosted.org/packages/f6/a2/e309afbb459f50507103793aaef85ca4348b66814c86bc73908bdeb66d12/pyright-1.1.406-py3-none-any.whl", hash = "sha256:1d81fb43c2407bf566e97e57abb01c811973fdb21b2df8df59f870f688bdca71", size = 5980982, upload-time = "2025-10-02T01:04:43.137Z" }, ] [[package]] @@ -2357,9 +2737,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] @@ -2370,9 +2750,9 @@ dependencies = [ { name = "py-cpuinfo" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/d0/a8bd08d641b393db3be3819b03e2d9bb8760ca8479080a26a5f6e540e99c/pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105", size = 337810 } +sdist = { url = "https://files.pythonhosted.org/packages/39/d0/a8bd08d641b393db3be3819b03e2d9bb8760ca8479080a26a5f6e540e99c/pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105", size = 337810, upload-time = "2024-10-30T11:51:48.521Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/d6/b41653199ea09d5969d4e385df9bbfd9a100f28ca7e824ce7c0a016e3053/pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89", size = 44259 }, + { url = "https://files.pythonhosted.org/packages/9e/d6/b41653199ea09d5969d4e385df9bbfd9a100f28ca7e824ce7c0a016e3053/pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89", size = 44259, upload-time = "2024-10-30T11:51:45.94Z" }, ] [[package]] @@ -2382,82 +2762,91 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "pytz" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] [[package]] @@ -2468,11 +2857,12 @@ dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, - { name = "urllib3" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] @@ -2480,36 +2870,38 @@ name = "rich" version = "14.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441 } +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368 }, + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, ] [[package]] name = "ruff" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/76/667f7536232ff6c3769a13f8d67911e038367350f7c3b3e09c4d98648fbc/ruff-0.4.1.tar.gz", hash = "sha256:d592116cdbb65f8b1b7e2a2b48297eb865f6bdc20641879aa9d7b9c11d86db79", size = 2309884 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7a/1eea0f76c900b824f50631645dd84a1e4e3bb52b44642c1b82e808375259/ruff-0.4.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2d9ef6231e3fbdc0b8c72404a1a0c46fd0dcea84efca83beb4681c318ea6a953", size = 16559105 }, - { url = "https://files.pythonhosted.org/packages/52/29/ce2d1aa82f0c8db7b1468fd4adf921c8572dfc2c95d25df2a7980edc4764/ruff-0.4.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9485f54a7189e6f7433e0058cf8581bee45c31a25cd69009d2a040d1bd4bfaef", size = 8496889 }, - { url = "https://files.pythonhosted.org/packages/b0/19/a1c7c7b9f15c58195675abad31ac4b4555c8b94d147ba8c7e9f6fcb9043f/ruff-0.4.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2921ac03ce1383e360e8a95442ffb0d757a6a7ddd9a5be68561a671e0e5807e", size = 8133815 }, - { url = "https://files.pythonhosted.org/packages/fa/b5/d48020b41bd05a47be6c53d59e5fa8cbbe3bda597535286948d27415c1f6/ruff-0.4.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eec8d185fe193ad053eda3a6be23069e0c8ba8c5d20bc5ace6e3b9e37d246d3f", size = 7514442 }, - { url = "https://files.pythonhosted.org/packages/b1/f5/4f81560b8b555fda93ac624d5e534cba8c2362aa29eebd7a1ebb20185bd3/ruff-0.4.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa27d9d72a94574d250f42b7640b3bd2edc4c58ac8ac2778a8c82374bb27984", size = 8681129 }, - { url = "https://files.pythonhosted.org/packages/e6/1c/66ed2617bfa589ff88490448bb49385bd776c7f39d09359e46cdcc78e537/ruff-0.4.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f1ee41580bff1a651339eb3337c20c12f4037f6110a36ae4a2d864c52e5ef954", size = 9445917 }, - { url = "https://files.pythonhosted.org/packages/36/8a/de76c13f9e1ce00bb03a70b371a3cd2cec86634263001b7afe8473f9da80/ruff-0.4.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0926cefb57fc5fced629603fbd1a23d458b25418681d96823992ba975f050c2b", size = 9143366 }, - { url = "https://files.pythonhosted.org/packages/29/1d/d49911b2ad919575b0895043b97db81cce401635eb20cd8ebba949a038b9/ruff-0.4.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c6e37f2e3cd74496a74af9a4fa67b547ab3ca137688c484749189bf3a686ceb", size = 10109996 }, - { url = "https://files.pythonhosted.org/packages/bd/38/0c172941d736433c494c5f3d3ce476c7a8060c70e2d8a2ab73fabd5e5869/ruff-0.4.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efd703a5975ac1998c2cc5e9494e13b28f31e66c616b0a76e206de2562e0843c", size = 8701793 }, - { url = "https://files.pythonhosted.org/packages/28/fa/70236002bc002edca0a3d4ed2e45f3d3f499a45a3e9fd606c87f2c5822fe/ruff-0.4.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b92f03b4aa9fa23e1799b40f15f8b95cdc418782a567d6c43def65e1bbb7f1cf", size = 8029779 }, - { url = "https://files.pythonhosted.org/packages/c4/3f/11b0a93ae0e50bc323bfe74a70bbd2b84f6e1cd83b71967f9f34d9032d91/ruff-0.4.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1c859f294f8633889e7d77de228b203eb0e9a03071b72b5989d89a0cf98ee262", size = 7527013 }, - { url = "https://files.pythonhosted.org/packages/50/19/1d25f4daf6518f615676cab90d205ef1e221500f95e4a79325e4cd2bf937/ruff-0.4.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b34510141e393519a47f2d7b8216fec747ea1f2c81e85f076e9f2910588d4b64", size = 8292363 }, - { url = "https://files.pythonhosted.org/packages/70/61/ad210ae4b48f15de5630d96eede38a6d98984130fb3b61e7000721848b71/ruff-0.4.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6e68d248ed688b9d69fd4d18737edcbb79c98b251bba5a2b031ce2470224bdf9", size = 8757422 }, - { url = "https://files.pythonhosted.org/packages/1c/30/b5f9fa73d7be01336750c8ca5bb3dc018a0ad29eb7e6d642785323bc67e5/ruff-0.4.1-py3-none-win32.whl", hash = "sha256:b90506f3d6d1f41f43f9b7b5ff845aeefabed6d2494307bc7b178360a8805252", size = 7600160 }, - { url = "https://files.pythonhosted.org/packages/90/8f/07e0b4e24337ca92521472b8f8030f450c9765fe7bcd177ff248f708c028/ruff-0.4.1-py3-none-win_amd64.whl", hash = "sha256:c7d391e5936af5c9e252743d767c564670dc3889aff460d35c518ee76e4b26d7", size = 8451086 }, - { url = "https://files.pythonhosted.org/packages/16/61/1843c9b453cd58b9c9e928388b609ebe70a63f3291bb10e7cfa24de69d03/ruff-0.4.1-py3-none-win_arm64.whl", hash = "sha256:a1eaf03d87e6a7cd5e661d36d8c6e874693cb9bc3049d110bc9a97b350680c43", size = 7964199 }, +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511, upload-time = "2025-03-21T13:31:17.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146, upload-time = "2025-03-21T13:30:26.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092, upload-time = "2025-03-21T13:30:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082, upload-time = "2025-03-21T13:30:39.962Z" }, + { url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818, upload-time = "2025-03-21T13:30:42.551Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251, upload-time = "2025-03-21T13:30:45.196Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566, upload-time = "2025-03-21T13:30:47.516Z" }, + { url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721, upload-time = "2025-03-21T13:30:49.56Z" }, + { url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274, upload-time = "2025-03-21T13:30:52.055Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284, upload-time = "2025-03-21T13:30:54.24Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861, upload-time = "2025-03-21T13:30:56.757Z" }, + { url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560, upload-time = "2025-03-21T13:30:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091, upload-time = "2025-03-21T13:31:01.45Z" }, + { url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133, upload-time = "2025-03-21T13:31:04.013Z" }, + { url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514, upload-time = "2025-03-21T13:31:06.166Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835, upload-time = "2025-03-21T13:31:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713, upload-time = "2025-03-21T13:31:13.148Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990, upload-time = "2025-03-21T13:31:15.206Z" }, ] [[package]] @@ -2519,27 +2911,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547 } +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712 }, + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, ] [[package]] name = "setuptools" version = "80.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] @@ -2549,9 +2941,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mpmath" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921 } +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353 }, + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] [[package]] @@ -2562,7 +2954,8 @@ dependencies = [ { name = "absl-py" }, { name = "grpcio" }, { name = "markdown" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, { name = "pillow" }, @@ -2572,7 +2965,7 @@ dependencies = [ { name = "werkzeug" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680 }, + { url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680, upload-time = "2025-07-17T19:20:49.638Z" }, ] [[package]] @@ -2580,8 +2973,8 @@ name = "tensorboard-data-server" version = "0.7.2" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356 }, - { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363 }, + { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" }, ] [[package]] @@ -2596,10 +2989,12 @@ dependencies = [ { name = "google-pasta" }, { name = "grpcio" }, { name = "h5py" }, - { name = "keras" }, + { name = "keras", version = "3.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "keras", version = "3.11.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "libclang" }, { name = "ml-dtypes" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "opt-einsum" }, { name = "packaging" }, @@ -2613,62 +3008,64 @@ dependencies = [ { name = "wrapt" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/07/ea91ac67a9fd36d3372099f5a3e69860ded544f877f5f2117802388f4212/tensorflow-2.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02a0293d94f5c8b7125b66abf622cc4854a33ae9d618a0d41309f95e091bbaea", size = 259307122 }, - { url = "https://files.pythonhosted.org/packages/e5/9e/0d57922cf46b9e91de636cd5b5e0d7a424ebe98f3245380a713f1f6c2a0b/tensorflow-2.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7abd7f3a010e0d354dc804182372779a722d474c4d8a3db8f4a3f5baef2a591e", size = 620425510 }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a3d455db88ab5b35ce53ab885ec0dd9f28d905a86a2250423048bc8cafa0/tensorflow-2.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e9568c8efcb05c0266be223e3269c62ebf7ad3498f156438311735f6fa5ced5", size = 259465882 }, - { url = "https://files.pythonhosted.org/packages/ff/0c/7df285ee8a88139fab0b237003634d90690759fae9c18f55ddb7c04656ec/tensorflow-2.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:481499fd0f824583de8945be61d5e827898cdaa4f5ea1bc2cc28ca2ccff8229e", size = 620570129 }, - { url = "https://files.pythonhosted.org/packages/ec/b4/f028a5de27d0fda10ba6145bc76e40c37ff6d2d1e95b601adb5ae17d635e/tensorflow-2.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bfbfb3dd0e22bffc45fe1e922390d27753e99261fab8a882e802cf98a0e078f", size = 259533109 }, - { url = "https://files.pythonhosted.org/packages/9c/d1/6aa15085d672056d5f08b5f28b1c7ce01c4e12149a23b0c98e3c79d04441/tensorflow-2.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25265b0bc527e0d54b1e9cc60c44a24f44a809fe27666b905f0466471f9c52ec", size = 620682547 }, - { url = "https://files.pythonhosted.org/packages/ea/4c/c1aa90c5cc92e9f7f9c78421e121ef25bae7d378f8d1d4cbad46c6308836/tensorflow-2.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47c88e05a07f1ead4977b4894b3ecd4d8075c40191065afc4fd9355c9db3d926", size = 259663776 }, - { url = "https://files.pythonhosted.org/packages/43/fb/8be8547c128613d82a2b006004026d86ed0bd672e913029a98153af4ffab/tensorflow-2.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa3729b0126f75a99882b89fb7d536515721eda8014a63e259e780ba0a37372", size = 620815537 }, + { url = "https://files.pythonhosted.org/packages/ff/07/ea91ac67a9fd36d3372099f5a3e69860ded544f877f5f2117802388f4212/tensorflow-2.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02a0293d94f5c8b7125b66abf622cc4854a33ae9d618a0d41309f95e091bbaea", size = 259307122, upload-time = "2025-08-13T16:50:47.909Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9e/0d57922cf46b9e91de636cd5b5e0d7a424ebe98f3245380a713f1f6c2a0b/tensorflow-2.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7abd7f3a010e0d354dc804182372779a722d474c4d8a3db8f4a3f5baef2a591e", size = 620425510, upload-time = "2025-08-13T16:51:02.608Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a3d455db88ab5b35ce53ab885ec0dd9f28d905a86a2250423048bc8cafa0/tensorflow-2.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e9568c8efcb05c0266be223e3269c62ebf7ad3498f156438311735f6fa5ced5", size = 259465882, upload-time = "2025-08-13T16:51:39.546Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0c/7df285ee8a88139fab0b237003634d90690759fae9c18f55ddb7c04656ec/tensorflow-2.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:481499fd0f824583de8945be61d5e827898cdaa4f5ea1bc2cc28ca2ccff8229e", size = 620570129, upload-time = "2025-08-13T16:51:55.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b4/f028a5de27d0fda10ba6145bc76e40c37ff6d2d1e95b601adb5ae17d635e/tensorflow-2.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bfbfb3dd0e22bffc45fe1e922390d27753e99261fab8a882e802cf98a0e078f", size = 259533109, upload-time = "2025-08-13T16:52:31.513Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d1/6aa15085d672056d5f08b5f28b1c7ce01c4e12149a23b0c98e3c79d04441/tensorflow-2.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25265b0bc527e0d54b1e9cc60c44a24f44a809fe27666b905f0466471f9c52ec", size = 620682547, upload-time = "2025-08-13T16:52:46.396Z" }, + { url = "https://files.pythonhosted.org/packages/ea/4c/c1aa90c5cc92e9f7f9c78421e121ef25bae7d378f8d1d4cbad46c6308836/tensorflow-2.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47c88e05a07f1ead4977b4894b3ecd4d8075c40191065afc4fd9355c9db3d926", size = 259663776, upload-time = "2025-08-13T16:53:24.507Z" }, + { url = "https://files.pythonhosted.org/packages/43/fb/8be8547c128613d82a2b006004026d86ed0bd672e913029a98153af4ffab/tensorflow-2.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa3729b0126f75a99882b89fb7d536515721eda8014a63e259e780ba0a37372", size = 620815537, upload-time = "2025-08-13T16:53:42.577Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/a26d49895586207b2704403366ef976dcaa6ed07514699dae9a4fc3fa1a9/tensorflow-2.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bc33759249c98eabcee9debd24e74506bbe29ac139e050cf0c74aa9888ebdf", size = 259307564, upload-time = "2025-08-13T16:54:17.691Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fe/f3d738dc7c93ed5f67f9ace8dd3ed66971dab7c5a47f2d1c504ef0d0cf1d/tensorflow-2.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0deb5c583dfc53b54fd158a194ce0087b406bb6518af400ca3809735e4548ec3", size = 620427169, upload-time = "2025-08-13T16:54:33.431Z" }, ] [[package]] name = "termcolor" version = "3.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684 }, + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, ] [[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] [[package]] @@ -2679,7 +3076,8 @@ dependencies = [ { name = "filelock" }, { name = "fsspec" }, { name = "jinja2" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "networkx", version = "3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, @@ -2701,26 +3099,30 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/63/28/110f7274254f1b8476c561dada127173f994afa2b1ffc044efb773c15650/torch-2.8.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:0be92c08b44009d4131d1ff7a8060d10bafdb7ddcb7359ef8d8c5169007ea905", size = 102052793 }, - { url = "https://files.pythonhosted.org/packages/70/1c/58da560016f81c339ae14ab16c98153d51c941544ae568da3cb5b1ceb572/torch-2.8.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:89aa9ee820bb39d4d72b794345cccef106b574508dd17dbec457949678c76011", size = 888025420 }, - { url = "https://files.pythonhosted.org/packages/70/87/f69752d0dd4ba8218c390f0438130c166fa264a33b7025adb5014b92192c/torch-2.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8e5bf982e87e2b59d932769938b698858c64cc53753894be25629bdf5cf2f46", size = 241363614 }, - { url = "https://files.pythonhosted.org/packages/ef/d6/e6d4c57e61c2b2175d3aafbfb779926a2cfd7c32eeda7c543925dceec923/torch-2.8.0-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:a3f16a58a9a800f589b26d47ee15aca3acf065546137fc2af039876135f4c760", size = 73611154 }, - { url = "https://files.pythonhosted.org/packages/8f/c4/3e7a3887eba14e815e614db70b3b529112d1513d9dae6f4d43e373360b7f/torch-2.8.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:220a06fd7af8b653c35d359dfe1aaf32f65aa85befa342629f716acb134b9710", size = 102073391 }, - { url = "https://files.pythonhosted.org/packages/5a/63/4fdc45a0304536e75a5e1b1bbfb1b56dd0e2743c48ee83ca729f7ce44162/torch-2.8.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c12fa219f51a933d5f80eeb3a7a5d0cbe9168c0a14bbb4055f1979431660879b", size = 888063640 }, - { url = "https://files.pythonhosted.org/packages/84/57/2f64161769610cf6b1c5ed782bd8a780e18a3c9d48931319f2887fa9d0b1/torch-2.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:8c7ef765e27551b2fbfc0f41bcf270e1292d9bf79f8e0724848b1682be6e80aa", size = 241366752 }, - { url = "https://files.pythonhosted.org/packages/a4/5e/05a5c46085d9b97e928f3f037081d3d2b87fb4b4195030fc099aaec5effc/torch-2.8.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:5ae0524688fb6707c57a530c2325e13bb0090b745ba7b4a2cd6a3ce262572916", size = 73621174 }, - { url = "https://files.pythonhosted.org/packages/49/0c/2fd4df0d83a495bb5e54dca4474c4ec5f9c62db185421563deeb5dabf609/torch-2.8.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e2fab4153768d433f8ed9279c8133a114a034a61e77a3a104dcdf54388838705", size = 101906089 }, - { url = "https://files.pythonhosted.org/packages/99/a8/6acf48d48838fb8fe480597d98a0668c2beb02ee4755cc136de92a0a956f/torch-2.8.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2aca0939fb7e4d842561febbd4ffda67a8e958ff725c1c27e244e85e982173c", size = 887913624 }, - { url = "https://files.pythonhosted.org/packages/af/8a/5c87f08e3abd825c7dfecef5a0f1d9aa5df5dd0e3fd1fa2f490a8e512402/torch-2.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:2f4ac52f0130275d7517b03a33d2493bab3693c83dcfadf4f81688ea82147d2e", size = 241326087 }, - { url = "https://files.pythonhosted.org/packages/be/66/5c9a321b325aaecb92d4d1855421e3a055abd77903b7dab6575ca07796db/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:619c2869db3ada2c0105487ba21b5008defcc472d23f8b80ed91ac4a380283b0", size = 73630478 }, - { url = "https://files.pythonhosted.org/packages/10/4e/469ced5a0603245d6a19a556e9053300033f9c5baccf43a3d25ba73e189e/torch-2.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2b2f96814e0345f5a5aed9bf9734efa913678ed19caf6dc2cddb7930672d6128", size = 101936856 }, - { url = "https://files.pythonhosted.org/packages/16/82/3948e54c01b2109238357c6f86242e6ecbf0c63a1af46906772902f82057/torch-2.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:65616ca8ec6f43245e1f5f296603e33923f4c30f93d65e103d9e50c25b35150b", size = 887922844 }, - { url = "https://files.pythonhosted.org/packages/e3/54/941ea0a860f2717d86a811adf0c2cd01b3983bdd460d0803053c4e0b8649/torch-2.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:659df54119ae03e83a800addc125856effda88b016dfc54d9f65215c3975be16", size = 241330968 }, - { url = "https://files.pythonhosted.org/packages/de/69/8b7b13bba430f5e21d77708b616f767683629fc4f8037564a177d20f90ed/torch-2.8.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:1a62a1ec4b0498930e2543535cf70b1bef8c777713de7ceb84cd79115f553767", size = 73915128 }, - { url = "https://files.pythonhosted.org/packages/15/0e/8a800e093b7f7430dbaefa80075aee9158ec22e4c4fc3c1a66e4fb96cb4f/torch-2.8.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:83c13411a26fac3d101fe8035a6b0476ae606deb8688e904e796a3534c197def", size = 102020139 }, - { url = "https://files.pythonhosted.org/packages/4a/15/5e488ca0bc6162c86a33b58642bc577c84ded17c7b72d97e49b5833e2d73/torch-2.8.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8f0a9d617a66509ded240add3754e462430a6c1fc5589f86c17b433dd808f97a", size = 887990692 }, - { url = "https://files.pythonhosted.org/packages/b4/a8/6a04e4b54472fc5dba7ca2341ab219e529f3c07b6941059fbf18dccac31f/torch-2.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a7242b86f42be98ac674b88a4988643b9bc6145437ec8f048fea23f72feb5eca", size = 241603453 }, - { url = "https://files.pythonhosted.org/packages/04/6e/650bb7f28f771af0cb791b02348db8b7f5f64f40f6829ee82aa6ce99aabe/torch-2.8.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:7b677e17f5a3e69fdef7eb3b9da72622f8d322692930297e4ccb52fefc6c8211", size = 73632395 }, + { url = "https://files.pythonhosted.org/packages/63/28/110f7274254f1b8476c561dada127173f994afa2b1ffc044efb773c15650/torch-2.8.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:0be92c08b44009d4131d1ff7a8060d10bafdb7ddcb7359ef8d8c5169007ea905", size = 102052793, upload-time = "2025-08-06T14:53:15.852Z" }, + { url = "https://files.pythonhosted.org/packages/70/1c/58da560016f81c339ae14ab16c98153d51c941544ae568da3cb5b1ceb572/torch-2.8.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:89aa9ee820bb39d4d72b794345cccef106b574508dd17dbec457949678c76011", size = 888025420, upload-time = "2025-08-06T14:54:18.014Z" }, + { url = "https://files.pythonhosted.org/packages/70/87/f69752d0dd4ba8218c390f0438130c166fa264a33b7025adb5014b92192c/torch-2.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8e5bf982e87e2b59d932769938b698858c64cc53753894be25629bdf5cf2f46", size = 241363614, upload-time = "2025-08-06T14:53:31.496Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/e6d4c57e61c2b2175d3aafbfb779926a2cfd7c32eeda7c543925dceec923/torch-2.8.0-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:a3f16a58a9a800f589b26d47ee15aca3acf065546137fc2af039876135f4c760", size = 73611154, upload-time = "2025-08-06T14:53:10.919Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c4/3e7a3887eba14e815e614db70b3b529112d1513d9dae6f4d43e373360b7f/torch-2.8.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:220a06fd7af8b653c35d359dfe1aaf32f65aa85befa342629f716acb134b9710", size = 102073391, upload-time = "2025-08-06T14:53:20.937Z" }, + { url = "https://files.pythonhosted.org/packages/5a/63/4fdc45a0304536e75a5e1b1bbfb1b56dd0e2743c48ee83ca729f7ce44162/torch-2.8.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c12fa219f51a933d5f80eeb3a7a5d0cbe9168c0a14bbb4055f1979431660879b", size = 888063640, upload-time = "2025-08-06T14:55:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/84/57/2f64161769610cf6b1c5ed782bd8a780e18a3c9d48931319f2887fa9d0b1/torch-2.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:8c7ef765e27551b2fbfc0f41bcf270e1292d9bf79f8e0724848b1682be6e80aa", size = 241366752, upload-time = "2025-08-06T14:53:38.692Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5e/05a5c46085d9b97e928f3f037081d3d2b87fb4b4195030fc099aaec5effc/torch-2.8.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:5ae0524688fb6707c57a530c2325e13bb0090b745ba7b4a2cd6a3ce262572916", size = 73621174, upload-time = "2025-08-06T14:53:25.44Z" }, + { url = "https://files.pythonhosted.org/packages/49/0c/2fd4df0d83a495bb5e54dca4474c4ec5f9c62db185421563deeb5dabf609/torch-2.8.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e2fab4153768d433f8ed9279c8133a114a034a61e77a3a104dcdf54388838705", size = 101906089, upload-time = "2025-08-06T14:53:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/99/a8/6acf48d48838fb8fe480597d98a0668c2beb02ee4755cc136de92a0a956f/torch-2.8.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2aca0939fb7e4d842561febbd4ffda67a8e958ff725c1c27e244e85e982173c", size = 887913624, upload-time = "2025-08-06T14:56:44.33Z" }, + { url = "https://files.pythonhosted.org/packages/af/8a/5c87f08e3abd825c7dfecef5a0f1d9aa5df5dd0e3fd1fa2f490a8e512402/torch-2.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:2f4ac52f0130275d7517b03a33d2493bab3693c83dcfadf4f81688ea82147d2e", size = 241326087, upload-time = "2025-08-06T14:53:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/be/66/5c9a321b325aaecb92d4d1855421e3a055abd77903b7dab6575ca07796db/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:619c2869db3ada2c0105487ba21b5008defcc472d23f8b80ed91ac4a380283b0", size = 73630478, upload-time = "2025-08-06T14:53:57.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/4e/469ced5a0603245d6a19a556e9053300033f9c5baccf43a3d25ba73e189e/torch-2.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2b2f96814e0345f5a5aed9bf9734efa913678ed19caf6dc2cddb7930672d6128", size = 101936856, upload-time = "2025-08-06T14:54:01.526Z" }, + { url = "https://files.pythonhosted.org/packages/16/82/3948e54c01b2109238357c6f86242e6ecbf0c63a1af46906772902f82057/torch-2.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:65616ca8ec6f43245e1f5f296603e33923f4c30f93d65e103d9e50c25b35150b", size = 887922844, upload-time = "2025-08-06T14:55:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/e3/54/941ea0a860f2717d86a811adf0c2cd01b3983bdd460d0803053c4e0b8649/torch-2.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:659df54119ae03e83a800addc125856effda88b016dfc54d9f65215c3975be16", size = 241330968, upload-time = "2025-08-06T14:54:45.293Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/8b7b13bba430f5e21d77708b616f767683629fc4f8037564a177d20f90ed/torch-2.8.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:1a62a1ec4b0498930e2543535cf70b1bef8c777713de7ceb84cd79115f553767", size = 73915128, upload-time = "2025-08-06T14:54:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/0e/8a800e093b7f7430dbaefa80075aee9158ec22e4c4fc3c1a66e4fb96cb4f/torch-2.8.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:83c13411a26fac3d101fe8035a6b0476ae606deb8688e904e796a3534c197def", size = 102020139, upload-time = "2025-08-06T14:54:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/4a/15/5e488ca0bc6162c86a33b58642bc577c84ded17c7b72d97e49b5833e2d73/torch-2.8.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8f0a9d617a66509ded240add3754e462430a6c1fc5589f86c17b433dd808f97a", size = 887990692, upload-time = "2025-08-06T14:56:18.286Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a8/6a04e4b54472fc5dba7ca2341ab219e529f3c07b6941059fbf18dccac31f/torch-2.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a7242b86f42be98ac674b88a4988643b9bc6145437ec8f048fea23f72feb5eca", size = 241603453, upload-time = "2025-08-06T14:55:22.945Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/650bb7f28f771af0cb791b02348db8b7f5f64f40f6829ee82aa6ce99aabe/torch-2.8.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:7b677e17f5a3e69fdef7eb3b9da72622f8d322692930297e4ccb52fefc6c8211", size = 73632395, upload-time = "2025-08-06T14:55:28.645Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b0/a321f27270049baa12f5c3fb0d6ceea005634787e3af9a8d75dce8306b0a/torch-2.8.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:da6afa31c13b669d4ba49d8a2169f0db2c3ec6bec4af898aa714f401d4c38904", size = 102059214, upload-time = "2025-08-06T14:55:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/fd/dd/1630cb51b10d3d2e97db95e5a84c32def81fc26b005bce6fc880b0e6db81/torch-2.8.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:06fcee8000e5c62a9f3e52a688b9c5abb7c6228d0e56e3452983416025c41381", size = 888024302, upload-time = "2025-08-06T14:57:28.23Z" }, + { url = "https://files.pythonhosted.org/packages/b9/dc/1f1f621afe15e3c496e1e8f94f8903f75f87e7d642d5a985e92210cc208d/torch-2.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:5128fe752a355d9308e56af1ad28b15266fe2da5948660fad44de9e3a9e36e8c", size = 241249338, upload-time = "2025-08-06T14:57:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/ae/95/ae26263aceb3d57b821179f827d0e321373ed49423e603dd5906ab14a730/torch-2.8.0-cp39-none-macosx_11_0_arm64.whl", hash = "sha256:e9f071f5b52a9f6970dc8a919694b27a91ae9dc08898b2b988abbef5eddfd1ae", size = 73610795, upload-time = "2025-08-06T14:57:11.513Z" }, ] [[package]] @@ -2730,9 +3132,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] [[package]] @@ -2740,23 +3142,25 @@ name = "triton" version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "setuptools" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/62/ee/0ee5f64a87eeda19bbad9bc54ae5ca5b98186ed00055281fd40fb4beb10e/triton-3.4.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ff2785de9bc02f500e085420273bb5cc9c9bb767584a4aa28d6e360cec70128", size = 155430069 }, - { url = "https://files.pythonhosted.org/packages/7d/39/43325b3b651d50187e591eefa22e236b2981afcebaefd4f2fc0ea99df191/triton-3.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b70f5e6a41e52e48cfc087436c8a28c17ff98db369447bcaff3b887a3ab4467", size = 155531138 }, - { url = "https://files.pythonhosted.org/packages/d0/66/b1eb52839f563623d185f0927eb3530ee4d5ffe9d377cdaf5346b306689e/triton-3.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c1d84a5c0ec2c0f8e8a072d7fd150cab84a9c239eaddc6706c081bfae4eb04", size = 155560068 }, - { url = "https://files.pythonhosted.org/packages/30/7b/0a685684ed5322d2af0bddefed7906674f67974aa88b0fae6e82e3b766f6/triton-3.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00be2964616f4c619193cb0d1b29a99bd4b001d7dc333816073f92cf2a8ccdeb", size = 155569223 }, - { url = "https://files.pythonhosted.org/packages/20/63/8cb444ad5cdb25d999b7d647abac25af0ee37d292afc009940c05b82dda0/triton-3.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7936b18a3499ed62059414d7df563e6c163c5e16c3773678a3ee3d417865035d", size = 155659780 }, + { url = "https://files.pythonhosted.org/packages/62/ee/0ee5f64a87eeda19bbad9bc54ae5ca5b98186ed00055281fd40fb4beb10e/triton-3.4.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ff2785de9bc02f500e085420273bb5cc9c9bb767584a4aa28d6e360cec70128", size = 155430069, upload-time = "2025-07-30T19:58:21.715Z" }, + { url = "https://files.pythonhosted.org/packages/7d/39/43325b3b651d50187e591eefa22e236b2981afcebaefd4f2fc0ea99df191/triton-3.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b70f5e6a41e52e48cfc087436c8a28c17ff98db369447bcaff3b887a3ab4467", size = 155531138, upload-time = "2025-07-30T19:58:29.908Z" }, + { url = "https://files.pythonhosted.org/packages/d0/66/b1eb52839f563623d185f0927eb3530ee4d5ffe9d377cdaf5346b306689e/triton-3.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c1d84a5c0ec2c0f8e8a072d7fd150cab84a9c239eaddc6706c081bfae4eb04", size = 155560068, upload-time = "2025-07-30T19:58:37.081Z" }, + { url = "https://files.pythonhosted.org/packages/30/7b/0a685684ed5322d2af0bddefed7906674f67974aa88b0fae6e82e3b766f6/triton-3.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00be2964616f4c619193cb0d1b29a99bd4b001d7dc333816073f92cf2a8ccdeb", size = 155569223, upload-time = "2025-07-30T19:58:44.017Z" }, + { url = "https://files.pythonhosted.org/packages/20/63/8cb444ad5cdb25d999b7d647abac25af0ee37d292afc009940c05b82dda0/triton-3.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7936b18a3499ed62059414d7df563e6c163c5e16c3773678a3ee3d417865035d", size = 155659780, upload-time = "2025-07-30T19:58:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/12/34/1251beb5a3cb93f3950ebe68732752014646003ef6eb11eb5f1a37ca78cd/triton-3.4.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e5c1442eaeabae2e2452ae765801bd53cd4ce873cab0d1bdd59a32ab2d9397", size = 155430799, upload-time = "2025-07-30T19:58:57.664Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -2766,27 +3170,46 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "1.26.20" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, ] [[package]] name = "urllib3" version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] [[package]] @@ -2796,169 +3219,188 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, ] [[package]] name = "wheel" version = "0.45.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545 } +sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494 }, + { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, ] [[package]] name = "wrapt" version = "1.17.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975 }, - { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149 }, - { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209 }, - { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551 }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376 }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604 }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782 }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076 }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036 }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156 }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102 }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732 }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072 }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214 }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105 }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766 }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163 }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963 }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945 }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857 }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571 }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094 }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659 }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946 }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591 }, +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/dd0791943613885f62619f18ee6107e6133237a6b6ed8a9ecfac339d0b4f/wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a", size = 81745, upload-time = "2025-08-12T05:52:49.62Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/bb2d19bd1a614cc4f438abac13ae26c57186197920432d2a915183b15a8b/wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139", size = 82833, upload-time = "2025-08-12T05:52:27.738Z" }, + { url = "https://files.pythonhosted.org/packages/8d/eb/66579aea6ad36f07617fedca8e282e49c7c9bab64c63b446cfe4f7f47a49/wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df", size = 81889, upload-time = "2025-08-12T05:52:29.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/9c/a56b5ac0e2473bdc3fb11b22dd69ff423154d63861cf77911cdde5e38fd2/wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b", size = 81344, upload-time = "2025-08-12T05:52:50.869Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] [[package]] name = "xxhash" version = "3.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845 }, - { url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807 }, - { url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786 }, - { url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830 }, - { url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606 }, - { url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872 }, - { url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217 }, - { url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139 }, - { url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669 }, - { url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018 }, - { url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058 }, - { url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628 }, - { url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577 }, - { url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487 }, - { url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863 }, - { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844 }, - { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809 }, - { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665 }, - { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550 }, - { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384 }, - { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749 }, - { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880 }, - { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912 }, - { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654 }, - { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867 }, - { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012 }, - { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409 }, - { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574 }, - { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481 }, - { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861 }, - { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744 }, - { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816 }, - { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035 }, - { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914 }, - { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163 }, - { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411 }, - { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883 }, - { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392 }, - { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898 }, - { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655 }, - { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001 }, - { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431 }, - { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617 }, - { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534 }, - { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876 }, - { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738 }, - { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821 }, - { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127 }, - { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975 }, - { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241 }, - { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471 }, - { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936 }, - { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440 }, - { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990 }, - { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689 }, - { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068 }, - { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495 }, - { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620 }, - { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542 }, - { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880 }, - { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956 }, - { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072 }, - { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409 }, - { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736 }, - { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833 }, - { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348 }, - { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070 }, - { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907 }, - { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839 }, - { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304 }, - { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930 }, - { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787 }, - { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916 }, - { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799 }, - { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044 }, - { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754 }, - { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846 }, - { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343 }, - { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074 }, - { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388 }, - { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614 }, - { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024 }, - { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541 }, - { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305 }, - { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848 }, - { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142 }, - { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547 }, - { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214 }, - { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290 }, - { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795 }, - { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955 }, - { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072 }, - { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579 }, - { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854 }, - { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965 }, - { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484 }, - { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162 }, - { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007 }, - { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956 }, - { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401 }, - { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083 }, - { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913 }, - { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586 }, - { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526 }, - { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898 }, - { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662 }, - { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056 }, - { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251 }, - { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481 }, - { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565 }, +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" }, + { url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" }, + { url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" }, + { url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" }, + { url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, + { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, + { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, + { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/03/ff/1b4bb3f397552116c1df6266c1b83a21aeeb26061ab1f462984b499a3870/xxhash-3.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc604dc06027dbeb8281aeac5899c35fcfe7c77b25212833709f0bff4ce74d2a", size = 32844, upload-time = "2025-10-02T14:36:39.157Z" }, + { url = "https://files.pythonhosted.org/packages/c1/db/27146d0bee4346a9a31f7b498a81fc02747f6f1e6c52a2e7989504278051/xxhash-3.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:277175a73900ad43a8caeb8b99b9604f21fe8d7c842f2f9061a364a7e220ddb7", size = 30806, upload-time = "2025-10-02T14:36:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/4896188df564908817a75de19bf7f2384b99a75af2d528f9c49326f76458/xxhash-3.6.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfbc5b91397c8c2972fdac13fb3e4ed2f7f8ccac85cd2c644887557780a9b6e2", size = 193448, upload-time = "2025-10-02T14:36:41.797Z" }, + { url = "https://files.pythonhosted.org/packages/51/c5/be8953f62e772340319a826ce1e07489935600089756cf83b628cd36ebe3/xxhash-3.6.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2762bfff264c4e73c0e507274b40634ff465e025f0eaf050897e88ec8367575d", size = 212547, upload-time = "2025-10-02T14:36:43.581Z" }, + { url = "https://files.pythonhosted.org/packages/51/1a/1e9f0b911d1cf00dd537c074ae3fae15b535a7f0d9e7edd42a9d2c4f78ce/xxhash-3.6.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f171a900d59d51511209f7476933c34a0c2c711078d3c80e74e0fe4f38680ec", size = 211309, upload-time = "2025-10-02T14:36:45.307Z" }, + { url = "https://files.pythonhosted.org/packages/63/88/b284c6a128d88dc47f201957f926e707db79fb7415a87072e15c0e490de0/xxhash-3.6.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:780b90c313348f030b811efc37b0fa1431163cb8db8064cf88a7936b6ce5f222", size = 444480, upload-time = "2025-10-02T14:36:47.226Z" }, + { url = "https://files.pythonhosted.org/packages/87/e4/798293a2bf9e4fac5f6d53ce59cba4739930778dfc6c7c73f40044ab0e6e/xxhash-3.6.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b242455eccdfcd1fa4134c431a30737d2b4f045770f8fe84356b3469d4b919", size = 192957, upload-time = "2025-10-02T14:36:48.968Z" }, + { url = "https://files.pythonhosted.org/packages/78/55/bfd0d7db447a927897469048b953caececa3532e743b940dd1f5c1032d24/xxhash-3.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a75ffc1bd5def584129774c158e108e5d768e10b75813f2b32650bb041066ed6", size = 209850, upload-time = "2025-10-02T14:36:50.258Z" }, + { url = "https://files.pythonhosted.org/packages/31/06/d08ef9a792bfebfd2fb2bcbf04a541ad283bef74749ead6f089a0809d288/xxhash-3.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1fc1ed882d1e8df932a66e2999429ba6cc4d5172914c904ab193381fba825360", size = 197342, upload-time = "2025-10-02T14:36:51.651Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1a/aebf90797c94e9ca407c28e23f54d71f7149d91a93406a08a09e44d06994/xxhash-3.6.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:44e342e8cc11b4e79dae5c57f2fb6360c3c20cc57d32049af8f567f5b4bcb5f4", size = 209757, upload-time = "2025-10-02T14:36:53.009Z" }, + { url = "https://files.pythonhosted.org/packages/3c/80/799eec3d0a144dc3edf8c19b4f139c27fb923c50b34352796089ca206429/xxhash-3.6.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c2f9ccd5c4be370939a2e17602fbc49995299203da72a3429db013d44d590e86", size = 412773, upload-time = "2025-10-02T14:36:54.691Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f9/09df7545699de09219a205123b8463ce9ea83f48acc7aeeba0269507f9d3/xxhash-3.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:02ea4cb627c76f48cd9fb37cf7ab22bd51e57e1b519807234b473faebe526796", size = 190357, upload-time = "2025-10-02T14:36:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/07/40/2f8327f94e64a3f34d6ce3347c55207c322abbc80ae486ea45df4c62e7b3/xxhash-3.6.0-cp39-cp39-win32.whl", hash = "sha256:6551880383f0e6971dc23e512c9ccc986147ce7bfa1cd2e4b520b876c53e9f3d", size = 30585, upload-time = "2025-10-02T14:36:57.664Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c8/2ecbc6799be9c02e8bf7b5a66cd94832b6ac13d59808746f0d402481c6ad/xxhash-3.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:7c35c4cdc65f2a29f34425c446f2f5cdcd0e3c34158931e1cc927ece925ab802", size = 31512, upload-time = "2025-10-02T14:36:58.837Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/1d5459a9c587c94d7b8bcc710bd08bbfa145cbd814ebde41b48494362a21/xxhash-3.6.0-cp39-cp39-win_arm64.whl", hash = "sha256:ffc578717a347baf25be8397cb10d2528802d24f94cfc005c0e44fef44b5cdd6", size = 27878, upload-time = "2025-10-02T14:37:00.201Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, + { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, ] [[package]] @@ -2970,92 +3412,118 @@ dependencies = [ { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910 }, - { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644 }, - { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322 }, - { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786 }, - { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627 }, - { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149 }, - { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327 }, - { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054 }, - { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035 }, - { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962 }, - { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399 }, - { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649 }, - { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563 }, - { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609 }, - { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224 }, - { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753 }, - { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817 }, - { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833 }, - { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070 }, - { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818 }, - { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003 }, - { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537 }, - { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358 }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362 }, - { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979 }, - { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274 }, - { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294 }, - { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169 }, - { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776 }, - { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341 }, - { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988 }, - { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113 }, - { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485 }, - { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686 }, - { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667 }, - { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025 }, - { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709 }, - { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287 }, - { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429 }, - { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429 }, - { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862 }, - { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616 }, - { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954 }, - { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575 }, - { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061 }, - { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142 }, - { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894 }, - { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378 }, - { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069 }, - { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249 }, - { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710 }, - { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811 }, - { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078 }, - { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748 }, - { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595 }, - { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616 }, - { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324 }, - { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676 }, - { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614 }, - { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766 }, - { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615 }, - { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982 }, - { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792 }, - { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049 }, - { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774 }, - { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252 }, - { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198 }, - { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346 }, - { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826 }, - { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217 }, - { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700 }, - { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644 }, - { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452 }, - { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378 }, - { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261 }, - { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987 }, - { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361 }, - { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460 }, - { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486 }, - { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219 }, - { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693 }, - { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803 }, - { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709 }, - { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591 }, - { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003 }, - { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542 }, +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, + { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, + { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, + { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, + { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, + { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, + { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, + { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, + { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, + { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, + { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, + { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, + { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/01/75/0d37402d208d025afa6b5b8eb80e466d267d3fd1927db8e317d29a94a4cb/yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3", size = 134259, upload-time = "2025-06-10T00:45:29.882Z" }, + { url = "https://files.pythonhosted.org/packages/73/84/1fb6c85ae0cf9901046f07d0ac9eb162f7ce6d95db541130aa542ed377e6/yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b", size = 91269, upload-time = "2025-06-10T00:45:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9c/eae746b24c4ea29a5accba9a06c197a70fa38a49c7df244e0d3951108861/yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983", size = 89995, upload-time = "2025-06-10T00:45:35.066Z" }, + { url = "https://files.pythonhosted.org/packages/fb/30/693e71003ec4bc1daf2e4cf7c478c417d0985e0a8e8f00b2230d517876fc/yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805", size = 325253, upload-time = "2025-06-10T00:45:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a2/5264dbebf90763139aeb0b0b3154763239398400f754ae19a0518b654117/yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba", size = 320897, upload-time = "2025-06-10T00:45:39.962Z" }, + { url = "https://files.pythonhosted.org/packages/e7/17/77c7a89b3c05856489777e922f41db79ab4faf58621886df40d812c7facd/yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e", size = 340696, upload-time = "2025-06-10T00:45:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/6d/55/28409330b8ef5f2f681f5b478150496ec9cf3309b149dab7ec8ab5cfa3f0/yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723", size = 335064, upload-time = "2025-06-10T00:45:43.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/58/cb0257cbd4002828ff735f44d3c5b6966c4fd1fc8cc1cd3cd8a143fbc513/yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000", size = 327256, upload-time = "2025-06-10T00:45:46.393Z" }, + { url = "https://files.pythonhosted.org/packages/53/f6/c77960370cfa46f6fb3d6a5a79a49d3abfdb9ef92556badc2dcd2748bc2a/yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5", size = 316389, upload-time = "2025-06-10T00:45:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/64/ab/be0b10b8e029553c10905b6b00c64ecad3ebc8ace44b02293a62579343f6/yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c", size = 340481, upload-time = "2025-06-10T00:45:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c3/3f327bd3905a4916029bf5feb7f86dcf864c7704f099715f62155fb386b2/yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240", size = 336941, upload-time = "2025-06-10T00:45:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/040bdd5d3b3bb02b4a6ace4ed4075e02f85df964d6e6cb321795d2a6496a/yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee", size = 339936, upload-time = "2025-06-10T00:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/911867b8e8c7463b84dfdc275e0d99b04b66ad5132b503f184fe76be8ea4/yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010", size = 360163, upload-time = "2025-06-10T00:45:56.87Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/8c389f6c6ca0379b57b2da87f1f126c834777b4931c5ee8427dd65d0ff6b/yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8", size = 359108, upload-time = "2025-06-10T00:45:58.869Z" }, + { url = "https://files.pythonhosted.org/packages/7f/09/ae4a649fb3964324c70a3e2b61f45e566d9ffc0affd2b974cbf628957673/yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d", size = 351875, upload-time = "2025-06-10T00:46:01.45Z" }, + { url = "https://files.pythonhosted.org/packages/8d/43/bbb4ed4c34d5bb62b48bf957f68cd43f736f79059d4f85225ab1ef80f4b9/yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06", size = 82293, upload-time = "2025-06-10T00:46:03.763Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/ce185848a7dba68ea69e932674b5c1a42a1852123584bccc5443120f857c/yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00", size = 87385, upload-time = "2025-06-10T00:46:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ] From d4df6186122064fa20ff59143a8c4a2de0dba139 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 1 Apr 2026 04:28:21 +0800 Subject: [PATCH 114/206] docs: clarify helper function guidance in AGENTS.md (#6357) This PR tightens the contributor guidance in `AGENTS.md` around introducing helper functions. It asks contributors to add helpers only when they materially reduce cognitive load or remove substantial duplication, and it explicitly forbids thin wrapper helpers that only rename or forward calls. This is a docs-only change, so no code checks were run. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 8543d23521a..bb7ff391cea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ AWS_DEFAULT_REGION=us-east-1 pytest --run-integration python/tests/test_s3_ddb.p - Comments should explain non-obvious "why" reasoning, not restate what the code does. - Remove debug prints (`println!`, `dbg!`, `print()`) before merging — use `tracing` or logging frameworks. - Extract logic repeated in 2+ places into a shared helper; inline single-use logic at its call site. +- Think carefully before adding a helper: only introduce one when it materially reduces cognitive load or eliminates substantial duplication, and do not add thin wrappers that only rename or forward existing calls. - Keep PRs focused — no drive-by refactors, reformatting, or cosmetic changes. - Be mindful of memory use: avoid collecting streams of `RecordBatch` into memory; use `RoaringBitmap` instead of `HashSet`. From 88d8fafb43790123072e1a0a58c5a71375b124a4 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Tue, 31 Mar 2026 14:37:55 -0700 Subject: [PATCH 115/206] feat: io_uring based file reader (#5777) ~~This is still a draft while waiting on https://github.com/lance-format/lance/pull/5755 and https://github.com/lance-format/lance/pull/5773~~ This PR adds a new URI scheme `file+uring`. The scheme uses the same local file reader as `file` but has two custom `Reader` implementations that are based on the io_uring API. One of these creates a configurable number of process-wide ring threads and the reader communicates with this thread using a queue. The second assumes that the scheduler and decoder run on the same thread and uses a thread local uring instance. Both are able to saturate up to 1.5M IOPS/s when combined with the scheduler rework. I've tested the thread local variant up to 2M IOPS/s. These numbers are assuming the data is not in the kernel page cache. I've seen results as high as 4M IOPS/s when the data is cached. --------- Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 189 ++++---- Cargo.toml | 1 + python/Cargo.lock | 14 + rust/lance-io/Cargo.toml | 5 + rust/lance-io/src/lib.rs | 2 + rust/lance-io/src/object_store.rs | 52 +++ rust/lance-io/src/object_store/providers.rs | 3 + rust/lance-io/src/uring.rs | 84 ++++ rust/lance-io/src/uring/current_thread.rs | 421 ++++++++++++++++++ .../src/uring/current_thread_future.rs | 102 +++++ rust/lance-io/src/uring/future.rs | 46 ++ rust/lance-io/src/uring/reader.rs | 292 ++++++++++++ rust/lance-io/src/uring/requests.rs | 54 +++ rust/lance-io/src/uring/tests.rs | 392 ++++++++++++++++ rust/lance-io/src/uring/thread.rs | 396 ++++++++++++++++ rust/lance/src/index/vector/builder.rs | 4 +- 16 files changed, 1976 insertions(+), 81 deletions(-) create mode 100644 rust/lance-io/src/uring.rs create mode 100644 rust/lance-io/src/uring/current_thread.rs create mode 100644 rust/lance-io/src/uring/current_thread_future.rs create mode 100644 rust/lance-io/src/uring/future.rs create mode 100644 rust/lance-io/src/uring/reader.rs create mode 100644 rust/lance-io/src/uring/requests.rs create mode 100644 rust/lance-io/src/uring/tests.rs create mode 100644 rust/lance-io/src/uring/thread.rs diff --git a/Cargo.lock b/Cargo.lock index 49ac3b56a23..1389b0f6a97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,7 +37,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpufeatures", ] [[package]] @@ -114,6 +114,21 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstream" version = "1.0.0" @@ -121,7 +136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -135,6 +150,15 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + [[package]] name = "anstyle-parse" version = "1.0.0" @@ -646,9 +670,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -1355,16 +1379,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.4" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.3.0", + "cpufeatures", ] [[package]] @@ -1495,9 +1519,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -1609,7 +1633,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream", + "anstream 1.0.0", "anstyle", "clap_lex", "strsim 0.11.1", @@ -1635,9 +1659,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.58" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] @@ -1788,15 +1812,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crc" version = "3.3.0" @@ -3091,9 +3106,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -3101,11 +3116,11 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ - "anstream", + "anstream 0.6.21", "anstyle", "env_filter", "jiff", @@ -4350,6 +4365,17 @@ version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" +[[package]] +name = "io-uring" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd7bddefd0a8833b88a4b68f90dae22c7450d11b354198baee3874fd811b344" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -4358,9 +4384,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.12" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -4510,21 +4536,19 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "jsonb" -version = "0.5.6" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb98fb29636087c40ad0d1274d9a30c0c1e83e03ae93f6e7e89247b37fcc6953" +checksum = "2a901f06163d352fbe41c3c2ff5e08b75330a003cc941e988fb501022f5421e6" dependencies = [ "byteorder", "ethnum", @@ -4533,11 +4557,11 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float 5.3.0", + "ordered-float 5.1.0", "rand 0.9.2", + "ryu", "serde", "serde_json", - "zmij", ] [[package]] @@ -5034,12 +5058,15 @@ dependencies = [ "deepsize", "futures", "http 1.4.0", + "io-uring", "lance-arrow", "lance-core", "lance-namespace", + "libc", "log", "mock_instant", "mockall", + "moka", "object_store", "object_store_opendal", "opendal", @@ -5388,9 +5415,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "bitflags 2.11.0", "libc", @@ -5766,9 +5793,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -5969,9 +5996,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-format" @@ -6279,9 +6306,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.3.0" +version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" dependencies = [ "num-traits", ] @@ -6827,9 +6854,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.11.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bit-set", "bit-vec", @@ -7532,9 +7559,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" @@ -7939,7 +7966,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -7950,7 +7977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -7991,9 +8018,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simdutf8" @@ -8090,11 +8117,11 @@ dependencies = [ [[package]] name = "spade" -version = "2.15.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.15.5", "num-traits", "robust", "smallvec", @@ -8827,9 +8854,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "d15b06e6c39068c203e7c1d0bc3944796d867449e7668ef7fa5ea43727cb846e" dependencies = [ "indexmap", "toml_datetime", @@ -9128,9 +9155,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" @@ -9227,9 +9254,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -9333,9 +9360,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -9346,19 +9373,23 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.65" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ + "cfg-if", + "futures-util", "js-sys", + "once_cell", "wasm-bindgen", + "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9366,9 +9397,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -9379,9 +9410,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -9435,9 +9466,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.92" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -9805,9 +9836,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] @@ -10022,18 +10053,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index c922eff6b8b..527ad080889 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,6 +146,7 @@ geo-types = "0.7.16" http = "1.1.0" humantime = "2.2.0" hyperloglogplus = { version = "0.4.1", features = ["const-loop"] } +io-uring = "0.7" itertools = "0.13" jieba-rs = { version = "0.8.1", default-features = false } jsonb = { version = "0.5.3", default-features = false, features = ["databend"] } diff --git a/python/Cargo.lock b/python/Cargo.lock index 4507a617872..a090f245765 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -3667,6 +3667,17 @@ version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" +[[package]] +name = "io-uring" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd7bddefd0a8833b88a4b68f90dae22c7450d11b354198baee3874fd811b344" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -4212,10 +4223,13 @@ dependencies = [ "deepsize", "futures", "http 1.4.0", + "io-uring", "lance-arrow", "lance-core", "lance-namespace", + "libc", "log", + "moka", "object_store", "object_store_opendal", "opendal", diff --git a/rust/lance-io/Cargo.toml b/rust/lance-io/Cargo.toml index 3aa0e1dab96..e1cbfd70f2b 100644 --- a/rust/lance-io/Cargo.toml +++ b/rust/lance-io/Cargo.toml @@ -38,6 +38,7 @@ deepsize.workspace = true futures.workspace = true http.workspace = true log.workspace = true +moka.workspace = true pin-project.workspace = true prost.workspace = true serde.workspace = true @@ -49,6 +50,10 @@ path_abs.workspace = true rand.workspace = true tempfile.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } +io-uring = { workspace = true } + [dev-dependencies] criterion.workspace = true test-log.workspace = true diff --git a/rust/lance-io/src/lib.rs b/rust/lance-io/src/lib.rs index e1729db73be..f3c772e9b01 100644 --- a/rust/lance-io/src/lib.rs +++ b/rust/lance-io/src/lib.rs @@ -21,6 +21,8 @@ pub mod stream; #[cfg(test)] pub mod testing; pub mod traits; +#[cfg(target_os = "linux")] +pub mod uring; pub mod utils; pub use scheduler::{bytes_read_counter, iops_counter}; diff --git a/rust/lance-io/src/object_store.rs b/rust/lance-io/src/object_store.rs index 4eb1e4ff403..6fe5c152046 100644 --- a/rust/lance-io/src/object_store.rs +++ b/rust/lance-io/src/object_store.rs @@ -32,6 +32,8 @@ use tokio::io::AsyncWriteExt; use url::Url; use super::local::LocalObjectReader; +#[cfg(target_os = "linux")] +use crate::uring::{UringCurrentThreadReader, UringReader}; mod list_retry; pub mod providers; pub mod storage_options; @@ -597,6 +599,31 @@ impl ObjectStore { ) .await } + #[cfg(target_os = "linux")] + "file+uring" => { + // Check if current-thread mode enabled + let use_current_thread = std::env::var("LANCE_URING_CURRENT_THREAD") + .map(|v| str_is_truthy(&v)) + .unwrap_or(false); + + if use_current_thread { + UringCurrentThreadReader::open( + path, + self.block_size, + None, + Arc::new(self.io_tracker.clone()), + ) + .await + } else { + UringReader::open( + path, + self.block_size, + None, + Arc::new(self.io_tracker.clone()), + ) + .await + } + } _ => Ok(Box::new(CloudObjectReader::new( self.inner.clone(), path.clone(), @@ -634,6 +661,31 @@ impl ObjectStore { ) .await } + #[cfg(target_os = "linux")] + "file+uring" => { + // Check if current-thread mode enabled + let use_current_thread = std::env::var("LANCE_URING_CURRENT_THREAD") + .map(|v| str_is_truthy(&v)) + .unwrap_or(false); + + if use_current_thread { + UringCurrentThreadReader::open( + path, + self.block_size, + Some(known_size), + Arc::new(self.io_tracker.clone()), + ) + .await + } else { + UringReader::open( + path, + self.block_size, + Some(known_size), + Arc::new(self.io_tracker.clone()), + ) + .await + } + } _ => Ok(Box::new(CloudObjectReader::new( self.inner.clone(), path.clone(), diff --git a/rust/lance-io/src/object_store/providers.rs b/rust/lance-io/src/object_store/providers.rs index 3583b27b288..03efa281e01 100644 --- a/rust/lance-io/src/object_store/providers.rs +++ b/rust/lance-io/src/object_store/providers.rs @@ -89,6 +89,7 @@ pub struct ObjectStoreRegistryStats { /// - `file`: A local file object store, with optimized code paths. /// - `file-object-store`: A local file object store that uses the ObjectStore API, /// for all operations. Used for testing with ObjectStore wrappers. +/// - `file+uring`: A local file object store using io_uring (Linux only). /// - `s3`: An S3 object store. /// - `s3+ddb`: An S3 object store with DynamoDB for metadata. /// - `az`: An Azure Blob Storage object store. @@ -301,6 +302,8 @@ impl Default for ObjectStoreRegistry { "file-object-store".into(), Arc::new(local::FileStoreProvider), ); + #[cfg(target_os = "linux")] + providers.insert("file+uring".into(), Arc::new(local::FileStoreProvider)); #[cfg(feature = "aws")] { diff --git a/rust/lance-io/src/uring.rs b/rust/lance-io/src/uring.rs new file mode 100644 index 00000000000..06786178164 --- /dev/null +++ b/rust/lance-io/src/uring.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! io_uring-based I/O for disks with high IOPS capacity (e.g. NVMe) +//! +//! This module provides two implementations of the [`Reader`](crate::traits::Reader) trait +//! using Linux's io_uring interface for asynchronous I/O. +//! +//! One of these uses a pool of dedicated background threads which each own an io_uring instance. +//! Read requests are submitted to a background thread's pool. +//! +//! The other implementation uses a thread-local io_uring instance. This only works if the future +//! is polled by the same thread that submitted the request. This means that the runtime must be +//! a single-threaded runtime. +//! +//! # Configuration +//! +//! The io_uring reader is enabled by using the `file+uring://` URI scheme instead of `file://`. +//! Additional tuning parameters are controlled by environment variables: +//! +//! - `LANCE_URING_CURRENT_THREAD` - Use thread-local io_uring (default: false) +//! - `LANCE_URING_BLOCK_SIZE` - Block size in bytes (default: 4KB) +//! - `LANCE_URING_IO_PARALLELISM` - Max concurrent operations (default: 128) +//! - `LANCE_URING_QUEUE_DEPTH` - io_uring queue depth (default: 16K) +//! - `LANCE_URING_THREAD_COUNT` - Number of io_uring threads to use (default: 2) +//! - `LANCE_URING_SUBMIT_BATCH_SIZE` - Number of requests to batch before submitting (default: 128) +//! - `LANCE_URING_POLL_TIMEOUT_MS` - Thread poll timeout in milliseconds (default: 10) +//! +//! Note: the block size and io parallelism are not actually used by the io_uring implementation. These +//! variables just control what the filesystem reports up to Lance. +//! +//! # Platform Support +//! +//! This module is only available on Linux and requires kernel 5.1 or newer. +//! On other platforms, the code falls back to [`LocalObjectReader`](crate::local::LocalObjectReader). +//! +//! # Example +//! +//! ```no_run +//! # use lance_io::object_store::ObjectStore; +//! # async fn example() -> lance_core::Result<()> { +//! // Enable io_uring by using the file+uring:// scheme +//! let uri = "file+uring:///path/to/file.dat"; +//! let (store, path) = ObjectStore::from_uri(uri).await?; +//! let reader = store.open(&path).await?; +//! +//! // Reader will use io_uring +//! let data = reader.get_range(0..1024).await?; +//! # Ok(()) +//! # } +//! ``` + +mod future; +mod reader; +mod requests; +mod thread; + +// Thread-local io_uring implementation for current-thread runtimes +pub(crate) mod current_thread; +pub(crate) mod current_thread_future; + +#[cfg(test)] +mod tests; + +use std::sync::LazyLock; + +pub(crate) use current_thread::UringCurrentThreadReader; +pub use reader::UringReader; + +/// Default block size for io_uring reads (4KB) +pub const DEFAULT_URING_BLOCK_SIZE: usize = 4 * 1024; + +/// Default I/O parallelism for io_uring (128 concurrent operations) +pub const DEFAULT_URING_IO_PARALLELISM: usize = 128; + +/// Default io_uring queue depth (16K entries) +pub const DEFAULT_URING_QUEUE_DEPTH: usize = 16 * 1024; + +/// Cached `LANCE_URING_BLOCK_SIZE` env var, read once at first access. +pub(crate) static URING_BLOCK_SIZE: LazyLock> = LazyLock::new(|| { + std::env::var("LANCE_URING_BLOCK_SIZE") + .ok() + .and_then(|s| s.parse().ok()) +}); diff --git a/rust/lance-io/src/uring/current_thread.rs b/rust/lance-io/src/uring/current_thread.rs new file mode 100644 index 00000000000..bc09af058e6 --- /dev/null +++ b/rust/lance-io/src/uring/current_thread.rs @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Thread-local io_uring implementation for current-thread runtimes. +//! +//! This implementation creates a thread-local IoUring instance per thread +//! and directly processes completions during future polling, eliminating +//! the need for background threads and MPSC channels. + +use super::requests::{IoRequest, RequestState}; +use super::{DEFAULT_URING_BLOCK_SIZE, DEFAULT_URING_IO_PARALLELISM, URING_BLOCK_SIZE}; +use crate::local::to_local_path; +use crate::traits::Reader; +use crate::uring::DEFAULT_URING_QUEUE_DEPTH; +use crate::utils::tracking_store::IOTracker; +use bytes::{Bytes, BytesMut}; +use deepsize::DeepSizeOf; +use futures::future::BoxFuture; +use futures::{FutureExt, TryFutureExt}; +use io_uring::{IoUring, opcode, types}; +use lance_core::{Error, Result}; +use object_store::path::Path; + +use std::cell::{LazyCell, RefCell}; +use std::collections::HashMap; +use std::fs::File; +use std::future::Future; +use std::io::{self, ErrorKind}; +use std::ops::Range; +use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tracing::instrument; + +// Re-use file handle types from reader.rs +use super::reader::{CacheKey, CachedReaderData, HANDLE_CACHE, UringFileHandle}; + +/// Global counter for generating unique user_data values +static USER_DATA_COUNTER: AtomicU64 = AtomicU64::new(1); + +/// Thread-local io_uring instance with pending requests +struct ThreadLocalUring { + ring: IoUring, + pending: HashMap>, +} + +thread_local! { + static URING: LazyCell> = LazyCell::new(|| { + let queue_depth = std::env::var("LANCE_URING_QUEUE_DEPTH") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_URING_QUEUE_DEPTH); + + let ring = IoUring::builder() + // Ensures work is only done in submit_and_wait + .setup_defer_taskrun() + // Enable perf. optimization when there is only one issuer thread + .setup_single_issuer() + .build(queue_depth as u32) + .expect("Failed to create io_uring"); + + log::debug!( + "Created thread-local io_uring with queue depth {}", + queue_depth + ); + + RefCell::new(ThreadLocalUring { + ring, + pending: HashMap::new(), + }) + }); +} + +/// Push request to thread-local submission queue +pub(super) fn push_request(request: Arc) -> io::Result<()> { + URING.with(|cell| { + let mut uring = cell.borrow_mut(); + + // Generate unique user_data + let user_data = USER_DATA_COUNTER.fetch_add(1, Ordering::Relaxed); + + // Get buffer pointer, adjusting for any bytes already read (short read retry) + let (buffer_ptr, read_offset, read_length) = { + let state = request.state.lock().unwrap(); + let br = state.bytes_read; + ( + unsafe { state.buffer.as_ptr().add(br) as *mut u8 }, + request.offset + br as u64, + (request.length - br) as u32, + ) + }; + + // Prepare read operation + let read_op = + opcode::Read::new(types::Fd(request.fd), buffer_ptr, read_length).offset(read_offset); + + // Get submission queue + let mut sq = uring.ring.submission(); + + // Check if SQ has space + if sq.is_full() { + drop(sq); + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "io_uring submission queue full", + )); + } + + // Push to SQ + unsafe { + sq.push(&read_op.build().user_data(user_data)) + .map_err(|_| io::Error::other("Failed to push to SQ"))?; + } + drop(sq); + + // Track request in pending map + uring.pending.insert(user_data, request); + + // Don't submit here - let the future handle submission + + Ok(()) + }) +} + +/// Process completions from thread-local IoUring +pub(super) fn process_thread_local_completions() -> io::Result { + URING.with(|cell| { + let mut uring = cell.borrow_mut(); + let mut completed = 0; + let mut retries: Vec> = Vec::new(); + + // Collect completions first to avoid borrowing ring and pending simultaneously + let cqes: Vec<_> = uring + .ring + .completion() + .map(|cqe| (cqe.user_data(), cqe.result())) + .collect(); + + for (user_data, result) in cqes { + if let Some(request) = uring.pending.remove(&user_data) { + let mut state = request.state.lock().unwrap(); + + if result < 0 { + // Kernel error + state.err = Some(io::Error::from_raw_os_error(-result)); + state.completed = true; + } else if result == 0 { + // EOF before full read completed + let br = state.bytes_read; + state.err = Some(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!("unexpected EOF: read {} of {} bytes", br, request.length), + )); + state.buffer.truncate(br); + state.completed = true; + } else { + // Positive result: n bytes read + let n = result as usize; + state.bytes_read += n; + let br = state.bytes_read; + + if br >= request.length { + // Full read complete + state.buffer.truncate(br); + state.completed = true; + } else { + // Short read — need retry; don't mark completed or wake + drop(state); + retries.push(request); + + continue; + } + } + + // Wake waiting future + if let Some(waker) = state.waker.take() { + drop(state); + waker.wake(); + } + + completed += 1; + } else { + log::warn!("Received completion for unknown user_data: {}", user_data); + } + } + + // Resubmit short-read retries + for request in retries { + // Generate unique user_data + let user_data = USER_DATA_COUNTER.fetch_add(1, Ordering::Relaxed); + + let (buffer_ptr, read_offset, read_length) = { + let state = request.state.lock().unwrap(); + let br = state.bytes_read; + ( + unsafe { state.buffer.as_ptr().add(br) as *mut u8 }, + request.offset + br as u64, + (request.length - br) as u32, + ) + }; + + let read_op = opcode::Read::new(types::Fd(request.fd), buffer_ptr, read_length) + .offset(read_offset); + + let mut sq = uring.ring.submission(); + if sq.is_full() { + drop(sq); + request.fail(io::Error::new( + io::ErrorKind::WouldBlock, + "io_uring submission queue full during retry", + )); + continue; + } + + unsafe { + if sq.push(&read_op.build().user_data(user_data)).is_err() { + request.fail(io::Error::other("Failed to push short-read retry to SQ")); + continue; + } + } + drop(sq); + + uring.pending.insert(user_data, request); + } + + if completed > 0 { + log::trace!("Processed {} completions", completed); + } + + Ok(completed) + }) +} + +/// Submit all pending requests and wait with timeout 0 (non-blocking) +pub(super) fn submit_and_wait_thread_local() -> io::Result<()> { + URING.with(|cell| { + let uring = cell.borrow_mut(); + // Submit with wait=1 (do at least some work) + uring.ring.submit_and_wait(1)?; + Ok(()) + }) +} + +/// Thread-local io_uring-based reader for current-thread runtimes +#[derive(Debug)] +pub struct UringCurrentThreadReader { + /// File handle + handle: Arc, + + /// Block size for I/O operations + block_size: usize, + + /// File size (determined at open time) + size: usize, + + /// I/O tracker for monitoring operations + io_tracker: Arc, +} + +impl DeepSizeOf for UringCurrentThreadReader { + fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize { + // Skip file handle (just a system resource) + // Only count the path's deep size + self.handle.path.as_ref().deep_size_of_children(context) + } +} + +impl UringCurrentThreadReader { + /// Open a file with thread-local io_uring + /// + /// This reuses the file handle caching infrastructure from UringReader + #[instrument(level = "debug")] + pub(crate) async fn open( + path: &Path, + block_size: usize, + known_size: Option, + io_tracker: Arc, + ) -> Result> { + // Determine block size with environment variable override + let block_size = URING_BLOCK_SIZE.unwrap_or(block_size.max(DEFAULT_URING_BLOCK_SIZE)); + + let cache_key = CacheKey::new(path, block_size); + + // Try to get from cache first + if let Some(data) = HANDLE_CACHE.get(&cache_key).await { + // Use known_size if provided, otherwise use cached size + let size = known_size.unwrap_or(data.size); + return Ok(Box::new(Self { + handle: data.handle, + block_size, + size, + io_tracker, + }) as Box); + } + + // Cache miss - open file and get size + let path_clone = path.clone(); + let local_path = to_local_path(path); + + let data = tokio::task::spawn_blocking(move || { + let file = File::open(&local_path).map_err(|e| match e.kind() { + ErrorKind::NotFound => Error::not_found(path_clone.to_string()), + _ => e.into(), + })?; + + // Get size from known_size or file metadata + let size = match known_size { + Some(s) => s, + None => file.metadata()?.len() as usize, + }; + + Ok::<_, Error>(CachedReaderData { + handle: Arc::new(UringFileHandle::new(file, path_clone)), + size, + }) + }) + .await??; + + // Insert into cache + HANDLE_CACHE.insert(cache_key, data.clone()).await; + + // Return new reader instance + Ok(Box::new(Self { + handle: data.handle.clone(), + block_size, + size: data.size, + io_tracker, + }) as Box) + } + + /// Submit a read request and return a future + fn submit_read( + &self, + offset: u64, + length: usize, + ) -> Pin> + Send>> { + let mut buffer = BytesMut::with_capacity(length); + unsafe { + buffer.set_len(length); + } + + let request = Arc::new(IoRequest { + fd: self.handle.fd, + offset, + length, + thread_id: std::thread::current().id(), + state: Mutex::new(RequestState { + completed: false, + waker: None, + err: None, + buffer, + bytes_read: 0, + }), + }); + + match push_request(request.clone()) { + Ok(()) => Box::pin(super::current_thread_future::UringCurrentThreadFuture::new( + request, + )), + Err(e) => Box::pin(async move { + Err(object_store::Error::Generic { + store: "io_uring_ct", + source: Box::new(e), + }) + }), + } + } +} + +impl Reader for UringCurrentThreadReader { + fn path(&self) -> &Path { + &self.handle.path + } + + fn block_size(&self) -> usize { + self.block_size + } + + fn io_parallelism(&self) -> usize { + std::env::var("LANCE_URING_IO_PARALLELISM") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_URING_IO_PARALLELISM) + } + + /// Returns the file size + fn size(&self) -> BoxFuture<'_, object_store::Result> { + Box::pin(async move { Ok(self.size) }) + } + + /// Read a range of bytes using thread-local io_uring + #[instrument(level = "debug", skip(self))] + fn get_range(&self, range: Range) -> BoxFuture<'static, object_store::Result> { + let io_tracker = self.io_tracker.clone(); + let path = self.handle.path.clone(); + let num_bytes = range.len() as u64; + let range_u64 = (range.start as u64)..(range.end as u64); + + self.submit_read(range.start as u64, range.len()) + .map_ok(move |bytes| { + io_tracker.record_read("get_range", path, num_bytes, Some(range_u64)); + bytes + }) + .boxed() + } + + /// Read the entire file using thread-local io_uring + #[instrument(level = "debug", skip(self))] + fn get_all(&self) -> BoxFuture<'static, object_store::Result> { + let size = self.size; + let io_tracker = self.io_tracker.clone(); + let path = self.handle.path.clone(); + + self.submit_read(0, size) + .map_ok(move |bytes| { + io_tracker.record_read("get_all", path, bytes.len() as u64, None); + bytes + }) + .boxed() + } +} diff --git a/rust/lance-io/src/uring/current_thread_future.rs b/rust/lance-io/src/uring/current_thread_future.rs new file mode 100644 index 00000000000..dbcf2242673 --- /dev/null +++ b/rust/lance-io/src/uring/current_thread_future.rs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Future implementation for thread-local io_uring operations. +//! +//! This future actively processes completions during polling instead of +//! relying on background tasks. + +use super::current_thread::{process_thread_local_completions, submit_and_wait_thread_local}; +use super::requests::IoRequest; +use bytes::Bytes; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +/// Future that awaits completion of a thread-local io_uring read operation +pub struct UringCurrentThreadFuture { + request: Arc, +} + +impl UringCurrentThreadFuture { + pub(super) fn new(request: Arc) -> Self { + Self { request } + } +} + +impl Future for UringCurrentThreadFuture { + type Output = object_store::Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // Check thread safety + if self.request.thread_id != std::thread::current().id() { + panic!("Request thread ID does not match current thread ID"); + } + + // First, check if we've been completed by some other future polling for completions. + let mut state = self.request.state.lock().unwrap(); + + if state.completed { + // Take result and return Ready + match state.err.take() { + Some(err) => { + return Poll::Ready(Err(object_store::Error::Generic { + store: "io_uring_ct", + source: Box::new(err), + })); + } + None => { + let br = state.bytes_read; + state.buffer.truncate(br); + let bytes = std::mem::take(&mut state.buffer).freeze(); + return Poll::Ready(Ok(bytes)); + } + } + } + + drop(state); + + // If not, then we should do any available work and then process completions. + if let Err(e) = submit_and_wait_thread_local() { + log::debug!("Submit and wait error: {:?}", e); + } + + if let Err(e) = process_thread_local_completions() { + log::warn!("Error processing completions: {:?}", e); + } + + // Check if our request completed + let mut state = self.request.state.lock().unwrap(); + + if state.completed { + // Take result and return Ready + match state.err.take() { + Some(err) => { + return Poll::Ready(Err(object_store::Error::Generic { + store: "io_uring_ct", + source: Box::new(err), + })); + } + None => { + let br = state.bytes_read; + state.buffer.truncate(br); + let bytes = std::mem::take(&mut state.buffer).freeze(); + return Poll::Ready(Ok(bytes)); + } + } + } + + // Not done yet - immediately wake and return Pending (don't store waker) + // which will force the future to be polled again. This is intentionally + // a busy loop. io_uring is intended for fast disks where read latency is + // so small that the cost of a true context switch (parking and unparking) + // would be too high. + // + // We are effectively doing a "yield" here while we wait for + // the io_uring thread to complete the request. + drop(state); + cx.waker().wake_by_ref(); + Poll::Pending + } +} diff --git a/rust/lance-io/src/uring/future.rs b/rust/lance-io/src/uring/future.rs new file mode 100644 index 00000000000..64b38993683 --- /dev/null +++ b/rust/lance-io/src/uring/future.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Future implementation for io_uring read operations. + +use super::requests::IoRequest; +use bytes::Bytes; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +/// Future that awaits completion of an io_uring read operation. +/// +/// This future is woken by the io_uring thread when the operation completes. +pub(super) struct UringReadFuture { + pub(super) request: Arc, +} + +impl Future for UringReadFuture { + type Output = object_store::Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let mut state = self.request.state.lock().unwrap(); + + if state.completed { + // Operation completed - take the result + match state.err.take() { + Some(err) => Poll::Ready(Err(object_store::Error::Generic { + store: "io_uring", + source: Box::new(err), + })), + None => { + let br = state.bytes_read; + state.buffer.truncate(br); + let bytes = std::mem::take(&mut state.buffer).freeze(); + Poll::Ready(Ok(bytes)) + } + } + } else { + // Operation not yet complete - store waker and return Pending + state.waker = Some(cx.waker().clone()); + Poll::Pending + } + } +} diff --git a/rust/lance-io/src/uring/reader.rs b/rust/lance-io/src/uring/reader.rs new file mode 100644 index 00000000000..0e7b0101ba8 --- /dev/null +++ b/rust/lance-io/src/uring/reader.rs @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! UringReader implementation. + +use super::future::UringReadFuture; +use super::requests::IoRequest; +use super::thread::{SUBMITTED_COUNTER, THREAD_SELECTOR, URING_THREADS}; +use super::{DEFAULT_URING_BLOCK_SIZE, DEFAULT_URING_IO_PARALLELISM, URING_BLOCK_SIZE}; +use crate::local::to_local_path; +use crate::traits::Reader; +use crate::uring::requests::RequestState; +use crate::utils::tracking_store::IOTracker; +use bytes::{Bytes, BytesMut}; +use deepsize::DeepSizeOf; +use futures::future::BoxFuture; +use futures::{FutureExt, TryFutureExt}; +use lance_core::{Error, Result}; +use object_store::path::Path; +use std::fs::File; +use std::future::Future; +use std::io::{self, ErrorKind}; +use std::ops::Range; +use std::os::unix::io::{AsRawFd, RawFd}; +use std::pin::Pin; +use std::sync::atomic::Ordering; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::Duration; +use tracing::instrument; + +/// Cache key for UringReader instances. +/// We cache by (path, block_size) because block_size affects reader behavior. +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub(super) struct CacheKey { + path: String, + block_size: usize, +} + +impl CacheKey { + pub(super) fn new(path: &Path, block_size: usize) -> Self { + Self { + path: path.to_string(), + block_size, + } + } +} + +/// Data stored in the cache for each opened file. +#[derive(Clone)] +pub(super) struct CachedReaderData { + pub(super) handle: Arc, + pub(super) size: usize, +} + +/// Global cache of open file handles. +/// Entries expire after 60 seconds to ensure files are eventually closed. +pub(super) static HANDLE_CACHE: LazyLock> = + LazyLock::new(|| { + moka::future::Cache::builder() + .time_to_live(Duration::from_secs(60)) + .max_capacity(10_000) + .build() + }); + +/// File handle for io_uring operations. +/// +/// Keeps the file alive and provides the raw file descriptor. +#[derive(Debug)] +pub(super) struct UringFileHandle { + /// The file (kept alive via Arc) + #[allow(unused)] + file: Arc, + + /// Raw file descriptor for io_uring + pub(super) fd: RawFd, + + /// Object store path + pub(super) path: Path, +} + +impl UringFileHandle { + pub(super) fn new(file: File, path: Path) -> Self { + let fd = file.as_raw_fd(); + Self { + file: Arc::new(file), + fd, + path, + } + } +} + +/// io_uring-based reader for local files. +/// +/// This reader uses a dedicated process-wide thread running an io_uring event loop +/// for high-performance asynchronous I/O. +#[derive(Debug)] +pub struct UringReader { + /// File handle + handle: Arc, + + /// Block size for I/O operations + block_size: usize, + + /// File size (determined at open time) + size: usize, + + /// I/O tracker for monitoring operations + io_tracker: Arc, +} + +impl DeepSizeOf for UringReader { + fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize { + // Skip file handle (just a system resource) + // Only count the path's deep size + self.handle.path.as_ref().deep_size_of_children(context) + } +} + +impl UringReader { + /// Open a file with io_uring. + /// + /// This is the internal constructor used by ObjectStore. + #[instrument(level = "debug")] + pub(crate) async fn open( + path: &Path, + block_size: usize, + known_size: Option, + io_tracker: Arc, + ) -> Result> { + // Determine block size with environment variable override + let block_size = URING_BLOCK_SIZE.unwrap_or(block_size.max(DEFAULT_URING_BLOCK_SIZE)); + + let cache_key = CacheKey::new(path, block_size); + + // Try to get from cache first + if let Some(data) = HANDLE_CACHE.get(&cache_key).await { + // Use known_size if provided, otherwise use cached size + let size = known_size.unwrap_or(data.size); + return Ok(Box::new(Self { + handle: data.handle, + block_size, + size, + io_tracker, + }) as Box); + } + + // Cache miss - open file and get size + let path_clone = path.clone(); + let local_path = to_local_path(path); + + let data = tokio::task::spawn_blocking(move || { + let file = File::open(&local_path).map_err(|e| match e.kind() { + ErrorKind::NotFound => Error::not_found(path_clone.to_string()), + _ => e.into(), + })?; + + // Get size from known_size or file metadata + let size = match known_size { + Some(s) => s, + None => file.metadata()?.len() as usize, + }; + + Ok::<_, Error>(CachedReaderData { + handle: Arc::new(UringFileHandle::new(file, path_clone)), + size, + }) + }) + .await??; + + // Insert into cache + HANDLE_CACHE.insert(cache_key, data.clone()).await; + + // Return new reader instance + Ok(Box::new(Self { + handle: data.handle.clone(), + block_size, + size: data.size, + io_tracker, + }) as Box) + } + + /// Submit a read request to the io_uring thread via channel and return a future. + fn submit_read( + &self, + offset: u64, + length: usize, + ) -> Pin> + Send>> { + let mut buffer = BytesMut::with_capacity(length); + unsafe { + buffer.set_len(length); + } + + // Create IoRequest with all data + let request = Arc::new(IoRequest { + fd: self.handle.fd, + offset, + length, + thread_id: std::thread::current().id(), + state: Mutex::new(RequestState { + completed: false, + waker: None, + err: None, + buffer, + bytes_read: 0, + }), + }); + + // Increment submitted counter before sending to channel + SUBMITTED_COUNTER.fetch_add(1, Ordering::Relaxed); + + // Select thread in round-robin fashion + let thread_idx = + (THREAD_SELECTOR.fetch_add(1, Ordering::Relaxed) as usize) % URING_THREADS.len(); + + // Send to selected thread via channel + match URING_THREADS[thread_idx] + .request_tx + .send(Arc::clone(&request)) + { + Ok(()) => { + // Return future that will be woken when operation completes + Box::pin(UringReadFuture { request }) + } + Err(_) => { + // Thread died - decrement counter and return error future + SUBMITTED_COUNTER.fetch_sub(1, Ordering::Relaxed); + Box::pin(async move { + Err(object_store::Error::Generic { + store: "UringReader", + source: Box::new(io::Error::new( + io::ErrorKind::BrokenPipe, + "io_uring thread died", + )), + }) + }) + } + } + } +} + +impl Reader for UringReader { + fn path(&self) -> &Path { + &self.handle.path + } + + fn block_size(&self) -> usize { + self.block_size + } + + fn io_parallelism(&self) -> usize { + std::env::var("LANCE_URING_IO_PARALLELISM") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_URING_IO_PARALLELISM) + } + + /// Returns the file size. + fn size(&self) -> BoxFuture<'_, object_store::Result> { + Box::pin(async move { Ok(self.size) }) + } + + /// Read a range of bytes using io_uring. + #[instrument(level = "debug", skip(self))] + fn get_range(&self, range: Range) -> BoxFuture<'static, object_store::Result> { + let io_tracker = self.io_tracker.clone(); + let path = self.handle.path.clone(); + let num_bytes = range.len() as u64; + let range_u64 = (range.start as u64)..(range.end as u64); + + self.submit_read(range.start as u64, range.len()) + .map_ok(move |bytes| { + io_tracker.record_read("get_range", path, num_bytes, Some(range_u64)); + bytes + }) + .boxed() + } + + /// Read the entire file using io_uring. + #[instrument(level = "debug", skip(self))] + fn get_all(&self) -> BoxFuture<'static, object_store::Result> { + let size = self.size; + let io_tracker = self.io_tracker.clone(); + let path = self.handle.path.clone(); + + self.submit_read(0, size) + .map_ok(move |bytes| { + io_tracker.record_read("get_all", path, bytes.len() as u64, None); + bytes + }) + .boxed() + } +} diff --git a/rust/lance-io/src/uring/requests.rs b/rust/lance-io/src/uring/requests.rs new file mode 100644 index 00000000000..fa257507dc1 --- /dev/null +++ b/rust/lance-io/src/uring/requests.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Protocol types for communication between UringReader and the io_uring thread. + +use bytes::BytesMut; +use std::io; +use std::os::unix::io::RawFd; +use std::sync::Mutex; +use std::task::Waker; +use std::thread::ThreadId; + +pub(super) struct RequestState { + pub completed: bool, + pub waker: Option, + pub err: Option, + pub buffer: BytesMut, + /// Accumulated bytes read across retries (for handling short reads). + pub bytes_read: usize, +} + +/// I/O request object that contains all state for a single read operation. +/// This is shared between the submitter, uring thread, and future via Arc. +pub(super) struct IoRequest { + /// File descriptor to read from. + pub fd: RawFd, + + /// Byte offset to start reading from. + pub offset: u64, + + /// Number of bytes to read. + pub length: usize, + + pub thread_id: ThreadId, + + /// Completion flag - set to true when operation completes. + pub state: Mutex, +} + +impl IoRequest { + /// Mark this request as failed with the given error. + /// + /// Sets the error, marks completed, and wakes any waiting future. + /// Used when a request cannot be submitted (e.g. SQ full). + pub(super) fn fail(&self, err: io::Error) { + let mut state = self.state.lock().unwrap(); + state.err = Some(err); + state.completed = true; + if let Some(waker) = state.waker.take() { + drop(state); + waker.wake(); + } + } +} diff --git a/rust/lance-io/src/uring/tests.rs b/rust/lance-io/src/uring/tests.rs new file mode 100644 index 00000000000..19d931da629 --- /dev/null +++ b/rust/lance-io/src/uring/tests.rs @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Tests for io_uring reader implementation. + +use crate::object_store::ObjectStore; +use lance_core::Result; +use std::io::Write; +use std::time::Duration; +use tempfile::NamedTempFile; + +/// Helper to create a temporary file with test data +fn create_test_file(size: usize) -> Result<(NamedTempFile, Vec)> { + let mut file = NamedTempFile::new()?; + let data: Vec = (0..size).map(|i| (i % 256) as u8).collect(); + file.write_all(&data)?; + file.flush()?; + Ok((file, data)) +} + +#[tokio::test] +async fn test_read_small_file() -> Result<()> { + let (file, expected_data) = create_test_file(1024)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + let reader = store.open(&path).await?; + + // Read entire file + let data = reader.get_all().await.unwrap(); + assert_eq!(data.as_ref(), expected_data.as_slice()); + + Ok(()) +} + +#[tokio::test] +async fn test_read_range() -> Result<()> { + let (file, expected_data) = create_test_file(4096)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + let reader = store.open(&path).await?; + + // Read a range in the middle + let range = 1000..2000; + let data = reader.get_range(range.clone()).await.unwrap(); + assert_eq!(data.as_ref(), &expected_data[range]); + + Ok(()) +} + +#[tokio::test] +async fn test_read_multiple_ranges() -> Result<()> { + let (file, expected_data) = create_test_file(8192)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + let reader = store.open(&path).await?; + + // Read multiple ranges + let ranges = vec![0..100, 500..600, 2000..3000]; + for range in ranges { + let data = reader.get_range(range.clone()).await.unwrap(); + assert_eq!(data.as_ref(), &expected_data[range]); + } + + Ok(()) +} + +#[tokio::test] +async fn test_file_size() -> Result<()> { + let size = 5000; + let (file, _) = create_test_file(size)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + let reader = store.open(&path).await?; + + assert_eq!(reader.size().await.unwrap(), size); + + Ok(()) +} + +#[tokio::test] +async fn test_concurrent_reads() -> Result<()> { + let (file, expected_data) = create_test_file(16384)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + + // Perform multiple concurrent reads + let mut tasks = vec![]; + for i in 0..10 { + let reader_clone = store.open(&path).await?; + let expected = expected_data.clone(); + tasks.push(tokio::spawn(async move { + let range = (i * 1000)..((i + 1) * 1000); + let data = reader_clone.get_range(range.clone()).await.unwrap(); + assert_eq!(data.as_ref(), &expected[range]); + })); + } + + // Wait for all tasks + for task in tasks { + task.await.unwrap(); + } + + Ok(()) +} + +#[tokio::test] +async fn test_large_file_read() -> Result<()> { + // Test with a larger file (1MB) + let size = 1024 * 1024; + let (file, expected_data) = create_test_file(size)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + let reader = store.open(&path).await?; + + // Read entire file + let data = reader.get_all().await.unwrap(); + assert_eq!(data.len(), size); + assert_eq!(data.as_ref(), expected_data.as_slice()); + + Ok(()) +} + +#[tokio::test] +async fn test_read_edge_cases() -> Result<()> { + let (file, expected_data) = create_test_file(4096)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + let reader = store.open(&path).await?; + + // Read from start + let data = reader.get_range(0..100).await.unwrap(); + assert_eq!(data.as_ref(), &expected_data[0..100]); + + // Read to end + let data = reader.get_range(4000..4096).await.unwrap(); + assert_eq!(data.as_ref(), &expected_data[4000..4096]); + + // Read single byte + let data = reader.get_range(2000..2001).await.unwrap(); + assert_eq!(data.as_ref(), &expected_data[2000..2001]); + + Ok(()) +} + +#[tokio::test] +async fn test_file_not_found() { + let uri = "file+uring:///nonexistent/file.dat"; + let (store, path) = ObjectStore::from_uri(uri).await.unwrap(); + + // Should fail to open non-existent file + let result = store.open(&path).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_block_size_and_parallelism() -> Result<()> { + let (file, _) = create_test_file(1024)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + let reader = store.open(&path).await?; + + // Check default values (or configured values) + assert!(reader.block_size() > 0); + assert!(reader.io_parallelism() > 0); + + Ok(()) +} + +#[tokio::test] +async fn test_path() -> Result<()> { + let (file, _) = create_test_file(1024)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + let reader = store.open(&path).await?; + + // Verify path is preserved + assert_eq!(reader.path(), &path); + + Ok(()) +} + +/// Test that reading past EOF returns an error. +/// +/// This exercises the case where `known_size` passed to `open_with_size` is larger +/// than the actual file, causing io_uring to hit EOF before the full read completes. +#[tokio::test] +async fn test_short_read_get_all() -> Result<()> { + let actual_size: usize = 8192; + let (file, _expected_data) = create_test_file(actual_size)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + + // Open with inflated known_size — the reader will think the file is 2x its real size + let inflated_size = actual_size * 2; + let reader = store.open_with_size(&path, inflated_size).await?; + + // get_all() will submit a read for inflated_size bytes from an actual_size file. + // The kernel reads actual_size bytes then returns 0 (EOF) — this should be an error. + let result = reader.get_all().await; + assert!(result.is_err(), "reading past EOF should return an error"); + + Ok(()) +} + +/// Test that a range read extending past EOF returns an error. +#[tokio::test] +async fn test_short_read_get_range_past_eof() -> Result<()> { + let actual_size: usize = 8192; + let (file, _expected_data) = create_test_file(actual_size)?; + let file_path = file.path().to_str().unwrap(); + let uri = format!("file+uring://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + let reader = store.open(&path).await?; + + // Request a range that starts inside the file but extends past EOF. + // File is 8192 bytes; reading 4096..16384 hits EOF — this should be an error. + let range_start = 4096; + let range_end = actual_size * 2; // 16384, well past EOF + let result = reader.get_range(range_start..range_end).await; + assert!( + result.is_err(), + "range extending past EOF should return an error" + ); + + Ok(()) +} + +/// Test that when push_to_sq fails (SQ full), the request's future returns +/// an error instead of hanging forever. +/// +/// This directly tests the thread-path scenario: create an IoUring with +/// queue_depth=2, fill the SQ, then try to push a 3rd request. The 3rd +/// request's future should return an error within the timeout. +/// +/// BUG: currently the failed push silently drops the request, so the +/// future hangs and the timeout fires. +#[tokio::test] +async fn test_retry_sq_full_thread() -> Result<()> { + use super::future::UringReadFuture; + use super::requests::{IoRequest, RequestState}; + use super::thread::push_to_sq; + use bytes::BytesMut; + use io_uring::IoUring; + use std::collections::HashMap; + use std::os::unix::io::AsRawFd; + use std::sync::{Arc, Mutex}; + + let (file, _) = create_test_file(4096)?; + let fd = file.as_file().as_raw_fd(); + + // Create a tiny ring with queue_depth=2 + let mut ring = IoUring::new(2).unwrap(); + let mut pending: HashMap> = HashMap::new(); + + // Helper to create a request + let make_request = || { + Arc::new(IoRequest { + fd, + offset: 0, + length: 4096, + thread_id: std::thread::current().id(), + state: Mutex::new(RequestState { + completed: false, + waker: None, + err: None, + buffer: BytesMut::zeroed(4096), + bytes_read: 0, + }), + }) + }; + + // Fill the SQ (capacity=2) + let _r1 = make_request(); + let _r2 = make_request(); + push_to_sq(&mut ring, &mut pending, _r1).unwrap(); + push_to_sq(&mut ring, &mut pending, _r2).unwrap(); + + // 3rd push should fail — SQ is full + let r3 = make_request(); + let push_result = push_to_sq(&mut ring, &mut pending, r3.clone()); + assert!(push_result.is_err(), "3rd push should fail (SQ full)"); + + // r3's future should return an error, not hang forever. + // BUG: currently nobody sets completed=true or err on r3, so the future hangs. + let future = UringReadFuture { request: r3 }; + let result = tokio::time::timeout(Duration::from_secs(2), future).await; + assert!( + result.is_ok(), + "future timed out — request was dropped without error on SQ-full push failure" + ); + + Ok(()) +} + +/// Test that when push_to_sq fails (SQ full) on the current-thread path, +/// the request's future returns an error instead of hanging forever. +/// +/// Uses UringCurrentThreadFuture (which will be a no-op poller since the +/// thread-local URING has no knowledge of this request) after push_to_sq +/// has already completed the request with an error. +#[tokio::test(flavor = "current_thread")] +async fn test_retry_sq_full_current_thread() -> Result<()> { + use super::current_thread_future::UringCurrentThreadFuture; + use super::requests::{IoRequest, RequestState}; + use super::thread::push_to_sq; + use bytes::BytesMut; + use io_uring::IoUring; + use std::collections::HashMap; + use std::os::unix::io::AsRawFd; + use std::sync::{Arc, Mutex}; + + let (file, _) = create_test_file(4096)?; + let fd = file.as_file().as_raw_fd(); + + // Create a tiny ring with queue_depth=2 + let mut ring = IoUring::new(2).unwrap(); + let mut pending: HashMap> = HashMap::new(); + + let make_request = || { + Arc::new(IoRequest { + fd, + offset: 0, + length: 4096, + thread_id: std::thread::current().id(), + state: Mutex::new(RequestState { + completed: false, + waker: None, + err: None, + buffer: BytesMut::zeroed(4096), + bytes_read: 0, + }), + }) + }; + + // Fill the SQ (capacity=2) + push_to_sq(&mut ring, &mut pending, make_request()).unwrap(); + push_to_sq(&mut ring, &mut pending, make_request()).unwrap(); + + // 3rd push should fail — SQ is full + let r3 = make_request(); + let push_result = push_to_sq(&mut ring, &mut pending, r3.clone()); + assert!(push_result.is_err(), "3rd push should fail (SQ full)"); + + // r3's future should return an error, not hang forever. + let future = UringCurrentThreadFuture::new(r3); + let result = tokio::time::timeout(Duration::from_secs(2), future).await; + assert!( + result.is_ok(), + "future timed out — request was dropped without error on SQ-full push failure" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_uring_not_enabled_with_file_scheme() -> Result<()> { + // Verify that files opened with file:// don't use uring + let (file, expected_data) = create_test_file(1024)?; + let file_path = file.path().to_str().unwrap(); + // Use regular file:// scheme, should NOT use uring + let uri = format!("file://{}", file_path); + + let (store, path) = ObjectStore::from_uri(&uri).await?; + let reader = store.open(&path).await?; + + // Should still be able to read, just won't use uring + let data = reader.get_all().await.unwrap(); + assert_eq!(data.as_ref(), expected_data.as_slice()); + + Ok(()) +} diff --git a/rust/lance-io/src/uring/thread.rs b/rust/lance-io/src/uring/thread.rs new file mode 100644 index 00000000000..d2ef197947d --- /dev/null +++ b/rust/lance-io/src/uring/thread.rs @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Dedicated thread for io_uring operations. +//! +//! This module provides a background thread that owns an io_uring instance +//! and processes read requests from a channel. Readers send requests via +//! an MPSC channel, and the thread handles submission and completion processing. + +use super::DEFAULT_URING_QUEUE_DEPTH; +use super::requests::IoRequest; +use io_uring::{IoUring, opcode, types}; +use std::collections::HashMap; +use std::io; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, sync_channel}; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; + +/// Handle to the io_uring background thread. +/// +/// This provides a channel sender for submitting read requests to the thread. +pub(super) struct UringThreadHandle { + pub request_tx: SyncSender>, +} + +/// Lazy-initialized io_uring thread pool. +/// +/// Multiple threads are spawned on first access and run until process exit. +pub(super) static URING_THREADS: LazyLock> = LazyLock::new(|| { + let queue_depth = get_queue_depth(); + let thread_count = get_thread_count(); + + let mut threads = Vec::with_capacity(thread_count); + + for i in 0..thread_count { + let (tx, rx) = sync_channel(queue_depth); + + std::thread::Builder::new() + .name(format!("lance-uring-{}", i)) + .spawn(move || run_uring_thread(rx, queue_depth, i)) + .expect("Failed to spawn io_uring thread"); + + threads.push(UringThreadHandle { request_tx: tx }); + } + + log::info!( + "io_uring thread pool spawned ({} threads, queue_depth={})", + thread_count, + queue_depth + ); + + threads +}); + +/// Atomic counter for round-robin thread selection. +pub(super) static THREAD_SELECTOR: AtomicU64 = AtomicU64::new(0); + +/// Counter for generating unique user_data values. +/// +/// Each io_uring operation needs a unique user_data ID to match completions +/// with their corresponding requests. +static USER_DATA_COUNTER: AtomicU64 = AtomicU64::new(1); + +/// Counter for requests that have been submitted to the thread but not yet received. +/// +/// This tracks requests sitting in the channel queue waiting to be received by the thread. +pub(super) static SUBMITTED_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Default batch size for submission - how many requests to batch before calling submit(). +const DEFAULT_SUBMIT_BATCH_SIZE: usize = 128; + +/// Default number of io_uring threads. +const DEFAULT_URING_THREAD_COUNT: usize = 2; + +/// Get the configured queue depth from environment variable. +fn get_queue_depth() -> usize { + std::env::var("LANCE_URING_QUEUE_DEPTH") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_URING_QUEUE_DEPTH) +} + +/// Get the configured poll timeout from environment variable. +fn get_poll_timeout() -> Duration { + let timeout_ms = std::env::var("LANCE_URING_POLL_TIMEOUT_MS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(10); + + Duration::from_millis(timeout_ms) +} + +/// Get the configured submit batch size from environment variable. +fn get_submit_batch_size() -> usize { + std::env::var("LANCE_URING_SUBMIT_BATCH_SIZE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_SUBMIT_BATCH_SIZE) +} + +/// Get the configured number of uring threads from environment variable. +fn get_thread_count() -> usize { + std::env::var("LANCE_URING_THREAD_COUNT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_URING_THREAD_COUNT) +} + +/// Main loop for the io_uring thread. +/// +/// This thread: +/// 1. Receives requests from the channel +/// 2. Submits them to io_uring +/// 3. Processes completions +/// 4. Wakes futures via their wakers +fn run_uring_thread(request_rx: Receiver>, queue_depth: usize, thread_id: usize) { + // Create local io_uring instance + let mut ring = IoUring::builder() + // .setup_sqpoll(100) + .build(queue_depth as u32) + .expect("Failed to create io_uring"); + + let mut pending: HashMap> = HashMap::with_capacity(queue_depth); + let poll_timeout = get_poll_timeout(); + let submit_batch_size = get_submit_batch_size(); + let mut last_log = Instant::now(); + let log_interval = Duration::from_millis(100); + let mut completed_iops = 0usize; + let mut completed_sectors = 0usize; + let mut min_in_flight = usize::MAX; + + loop { + // Track minimum in-flight count + let in_flight = pending.len(); + min_in_flight = min_in_flight.min(in_flight); + + // Log in-flight requests every 100ms + let now = Instant::now(); + if now.duration_since(last_log) >= log_interval { + let submitted = SUBMITTED_COUNTER.load(Ordering::Relaxed); + log::info!( + "io_uring[{}]: {} submitted, {} in flight (min {}), {} iops completed, {} sectors completed", + thread_id, + submitted, + in_flight, + min_in_flight, + completed_iops, + completed_sectors + ); + last_log = now; + completed_iops = 0; // Reset counter after logging + completed_sectors = 0; // Reset counter after logging + min_in_flight = usize::MAX; // Reset min tracker + } + + // Process all available completions first + let mut needs_submit = false; + let completions = process_completions(&mut ring, &mut pending); + match completions { + Ok(result) => { + completed_iops += result.iops; + completed_sectors += result.sectors; + + // Resubmit any short-read retries + for request in result.retries { + if let Err(e) = push_to_sq(&mut ring, &mut pending, request) { + log::error!("Failed to resubmit short read: {}", e); + } else { + needs_submit = true; + } + } + } + Err(e) => { + log::error!("Error processing io_uring completions: {}", e); + } + } + + min_in_flight = min_in_flight.min(pending.len()); + + // Batch submit requests - keep pulling from channel and pushing to SQ + // until we hit batch size or channel is empty + let mut batch_count = 0; + loop { + // Try to receive new request + // Use recv_timeout only when pending is empty, otherwise use try_recv + let recv_result = if pending.is_empty() && batch_count == 0 { + // No operations in flight and no batch started - we can afford to wait with timeout + request_rx.recv_timeout(poll_timeout).map_err(|e| match e { + RecvTimeoutError::Timeout => std::sync::mpsc::TryRecvError::Empty, + RecvTimeoutError::Disconnected => std::sync::mpsc::TryRecvError::Disconnected, + }) + } else { + // Operations in flight or batch in progress - busy loop with try_recv + request_rx.try_recv() + }; + + match recv_result { + Ok(request) => { + // Decrement submitted counter when we receive the request from channel + SUBMITTED_COUNTER.fetch_sub(1, Ordering::Relaxed); + + // Push to submission queue (but don't submit yet) + if let Err(e) = push_to_sq(&mut ring, &mut pending, request) { + log::error!("Failed to push to io_uring SQ: {}", e); + } else { + batch_count += 1; + } + + // Break if we've hit the batch size limit + if batch_count >= submit_batch_size { + break; + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + // No more requests in channel - break to submit the batch + break; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + // All senders dropped - submit batch and shutdown + if batch_count > 0 + && let Err(e) = ring.submit() + { + log::error!( + "io_uring[{}]: Failed to submit io_uring batch: {}", + thread_id, + e + ); + } + log::info!( + "io_uring thread {} shutting down (channel disconnected)", + thread_id + ); + return; + } + } + } + + // Submit if we have any requests (from channel or retries) + if (batch_count > 0 || needs_submit) + && let Err(e) = ring.submit() + { + log::error!( + "Failed to submit io_uring batch of {} requests: {}", + batch_count, + e + ); + } + } +} + +/// Push a read request to the io_uring submission queue (without submitting). +/// +/// This generates a unique user_data ID, prepares the read operation, +/// and pushes it to the SQ. The caller is responsible for calling ring.submit(). +pub(super) fn push_to_sq( + ring: &mut IoUring, + pending: &mut HashMap>, + request: Arc, +) -> io::Result<()> { + // Generate unique user_data + let user_data = USER_DATA_COUNTER.fetch_add(1, Ordering::Relaxed); + + // Get buffer pointer, adjusting for any bytes already read (short read retry) + let (buffer_ptr, read_offset, read_length) = { + let state = request.state.lock().unwrap(); + let br = state.bytes_read; + ( + unsafe { state.buffer.as_ptr().add(br) as *mut u8 }, + request.offset + br as u64, + (request.length - br) as u32, + ) + }; + + // Prepare read operation + let read_op = + opcode::Read::new(types::Fd(request.fd), buffer_ptr, read_length).offset(read_offset); + + // Get submission queue + let mut sq = ring.submission(); + + // Check if SQ has space + if sq.is_full() { + drop(sq); + request.fail(io::Error::new( + io::ErrorKind::WouldBlock, + "io_uring submission queue full", + )); + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "io_uring submission queue full", + )); + } + + // Push to SQ + unsafe { + if sq.push(&read_op.build().user_data(user_data)).is_err() { + drop(sq); + request.fail(io::Error::other("Failed to push to SQ")); + return Err(io::Error::other("Failed to push to SQ")); + } + } + drop(sq); + + // Track request in pending map + pending.insert(user_data, request); + + Ok(()) +} + +struct CompletionResult { + iops: usize, + sectors: usize, + retries: Vec>, +} + +/// Process all available completions from the io_uring. +/// +/// This iterates through the completion queue, matches completions to requests, +/// updates their state, and wakes any waiting futures. Short reads are collected +/// into `retries` for resubmission; EOF before a full read is an error. +/// +/// Returns completion stats and a list of requests needing resubmission. +fn process_completions( + ring: &mut IoUring, + pending: &mut HashMap>, +) -> io::Result { + let mut iops = 0; + let mut sectors = 0; + let mut retries = Vec::new(); + + // Process all available completions + for cqe in ring.completion() { + let user_data = cqe.user_data(); + let result = cqe.result(); + + // Look up request + if let Some(request) = pending.remove(&user_data) { + let mut state = request.state.lock().unwrap(); + + if result < 0 { + // Kernel error + state.err = Some(io::Error::from_raw_os_error(-result)); + state.completed = true; + } else if result == 0 { + // EOF before full read completed + let br = state.bytes_read; + state.err = Some(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!("unexpected EOF: read {} of {} bytes", br, request.length), + )); + state.buffer.truncate(br); + state.completed = true; + } else { + // Positive result: n bytes read + let n = result as usize; + state.bytes_read += n; + let br = state.bytes_read; + + if br >= request.length { + // Full read complete + state.buffer.truncate(br); + state.completed = true; + + if request.length > 0 { + let first_sector = request.offset / 4096; + let last_sector = (request.offset + request.length as u64 - 1) / 4096; + let num_sectors = (last_sector - first_sector + 1) as usize; + sectors += num_sectors; + } + } else { + // Short read — need retry; don't mark completed or wake + drop(state); + retries.push(request); + continue; + } + } + + // Wake the future if it's waiting + if let Some(waker) = state.waker.take() { + drop(state); // Release lock before waking + waker.wake(); + } + + iops += 1; + } else { + log::warn!("Received completion for unknown user_data: {}", user_data); + } + } + + Ok(CompletionResult { + iops, + sectors, + retries, + }) +} diff --git a/rust/lance/src/index/vector/builder.rs b/rust/lance/src/index/vector/builder.rs index c2d6427b251..54fa9f6ac96 100644 --- a/rust/lance/src/index/vector/builder.rs +++ b/rust/lance/src/index/vector/builder.rs @@ -259,7 +259,7 @@ impl IvfIndexBuilder progress .stage_start("train_ivf", max_iters, "iterations") .await?; - self.with_ivf(self.load_or_build_ivf().await?); + self.with_ivf(self.load_or_build_ivf().boxed().await?); progress.stage_complete("train_ivf").await?; progress.stage_start("train_quantizer", None, "").await?; @@ -270,7 +270,7 @@ impl IvfIndexBuilder if self.shuffle_reader.is_none() { let num_rows = self.num_rows_to_shuffle().await?; progress.stage_start("shuffle", num_rows, "rows").await?; - self.shuffle_dataset().await?; + self.shuffle_dataset().boxed().await?; progress.stage_complete("shuffle").await?; } From 526a72f6b1dcba7fa7a5b85a3c18e577afc05668 Mon Sep 17 00:00:00 2001 From: "nathan.ma" Date: Wed, 1 Apr 2026 14:33:51 +0800 Subject: [PATCH 116/206] feat: expose branch_identifier in python and java bindings (#6360) ### Motivation - Surface the full branch lineage (version -> uuid mapping) from Rust `BranchIdentifier` into higher-level bindings so clients can inspect a branch's lineage chain. ### Description - Update the JNI layer (`java/lance-jni/src/blocking_dataset.rs`) to construct and pass a `List` when creating the Java `Branch` object. - Extend the Java model (`java/src/main/java/org/lance/Branch.java`) with an inner `BranchVersionMapping` class, add a `branchIdentifier` field and getter, update constructor/signature, and include it in `toString`, `equals`, and `hashCode`. - Update Java tests (`java/src/test/java/org/lance/DatasetTest.java`) to assert the presence and contents of `branchIdentifier` for created branches. - Add `branch_identifier: List[Tuple[int, str]]` to the Python `Branch` `TypedDict` (`python/python/lance/dataset.py`) and update the Python Rust bridge (`python/src/dataset.rs`) to include `meta.identifier.version_mapping` in the returned branch dictionaries. - Update Python tests (`python/python/tests/test_dataset.py`) to validate the `branch_identifier` contents in `branches.list()` responses. ### Testing - Ran the modified Java unit test `DatasetTest::test_branches` which exercises branch creation, listing and metadata assertions, and it passed. - Ran the modified Python test `test_branches` with `pytest` which verifies `branch_identifier` in `branches.list()` and it passed. - Exercised JNI/bridge code paths while running the above tests to validate interop and serialization of the version->uuid mappings, and no failures were observed. --- java/lance-jni/src/blocking_dataset.rs | 22 +++++- java/src/main/java/org/lance/Branch.java | 68 +++++++++++++++++-- java/src/test/java/org/lance/DatasetTest.java | 9 +++ python/python/lance/dataset.py | 1 + python/python/tests/test_dataset.py | 12 +++- python/src/dataset.rs | 2 + 6 files changed, 106 insertions(+), 8 deletions(-) diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index 00e30f1f6b0..3064e9c043f 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -2533,12 +2533,32 @@ fn inner_list_branches<'local>( } else { JObject::null() }; + let jbranch_identifier = env.new_object( + "java/util/ArrayList", + "(I)V", + &[JValue::Int(contents.identifier.version_mapping.len() as i32)], + )?; + for (version, uuid) in contents.identifier.version_mapping.iter() { + let juuid = env.new_string(uuid)?; + let jmapping = env.new_object( + "org/lance/Branch$BranchVersionMapping", + "(JLjava/lang/String;)V", + &[JValue::Long(*version as i64), JValue::Object(&juuid)], + )?; + env.call_method( + &jbranch_identifier, + "add", + "(Ljava/lang/Object;)Z", + &[JValue::Object(&jmapping)], + )?; + } let jbranch = env.new_object( "org/lance/Branch", - "(Ljava/lang/String;Ljava/lang/String;JJI)V", + "(Ljava/lang/String;Ljava/lang/String;Ljava/util/List;JJI)V", &[ JValue::Object(&jname), JValue::Object(&jparent), + JValue::Object(&jbranch_identifier), JValue::Long(contents.parent_version as i64), JValue::Long(contents.create_at as i64), JValue::Int(contents.manifest_size as i32), diff --git a/java/src/main/java/org/lance/Branch.java b/java/src/main/java/org/lance/Branch.java index f2a1f5b21ea..07f79a08f6a 100755 --- a/java/src/main/java/org/lance/Branch.java +++ b/java/src/main/java/org/lance/Branch.java @@ -14,27 +14,78 @@ package org.lance; import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; +import java.util.List; import java.util.Objects; import java.util.Optional; /** * Branch metadata aligned with Rust's BranchContents. name is the branch name, parentBranch may be - * null (indicating main), parentVersion is the version on which the branch was created, createAt is - * the unix timestamp (seconds), and manifestSize is the size of the referenced manifest file in - * bytes. + * null (indicating main), branchIdentifier is the lineage chain {@code [(version, uuid), ...]}, + * parentVersion is the version on which the branch was created, createAt is the unix timestamp + * (seconds), and manifestSize is the size of the referenced manifest file in bytes. */ public class Branch { + /** A single lineage hop in the Rust BranchIdentifier.version_mapping vector. */ + public static class BranchVersionMapping { + private final long version; + private final String uuid; + + public BranchVersionMapping(long version, String uuid) { + this.version = version; + this.uuid = Objects.requireNonNull(uuid); + } + + public long getVersion() { + return version; + } + + public String getUuid() { + return uuid; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("version", version).add("uuid", uuid).toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BranchVersionMapping that = (BranchVersionMapping) o; + return version == that.version && Objects.equals(uuid, that.uuid); + } + + @Override + public int hashCode() { + return Objects.hash(version, uuid); + } + } + private final String name; private final Optional parentBranch; + private final ImmutableList branchIdentifier; private final long parentVersion; private final long createAt; private final int manifestSize; public Branch( String name, String parentBranch, long parentVersion, long createAt, int manifestSize) { + this(name, parentBranch, ImmutableList.of(), parentVersion, createAt, manifestSize); + } + + public Branch( + String name, + String parentBranch, + List branchIdentifier, + long parentVersion, + long createAt, + int manifestSize) { this.name = name; this.parentBranch = Optional.ofNullable(parentBranch); + this.branchIdentifier = ImmutableList.copyOf(Objects.requireNonNull(branchIdentifier)); this.parentVersion = parentVersion; this.createAt = createAt; this.manifestSize = manifestSize; @@ -48,6 +99,10 @@ public Optional getParentBranch() { return parentBranch; } + public ImmutableList getBranchIdentifier() { + return branchIdentifier; + } + public long getParentVersion() { return parentVersion; } @@ -65,6 +120,7 @@ public String toString() { return MoreObjects.toStringHelper(this) .add("name", name) .add("parentBranch", parentBranch) + .add("branchIdentifier", branchIdentifier) .add("parentVersion", parentVersion) .add("createAt", createAt) .add("manifestSize", manifestSize) @@ -80,11 +136,13 @@ public boolean equals(Object o) { && createAt == branch.createAt && manifestSize == branch.manifestSize && Objects.equals(name, branch.name) - && Objects.equals(parentBranch, branch.parentBranch); + && Objects.equals(parentBranch, branch.parentBranch) + && Objects.equals(branchIdentifier, branch.branchIdentifier); } @Override public int hashCode() { - return Objects.hash(name, parentBranch, parentVersion, createAt, manifestSize); + return Objects.hash( + name, parentBranch, branchIdentifier, parentVersion, createAt, manifestSize); } } diff --git a/java/src/test/java/org/lance/DatasetTest.java b/java/src/test/java/org/lance/DatasetTest.java index 6c387aac499..2b241013427 100644 --- a/java/src/test/java/org/lance/DatasetTest.java +++ b/java/src/test/java/org/lance/DatasetTest.java @@ -1736,12 +1736,21 @@ void testBranches(@TempDir Path tempDir) { assertEquals("branch1", branch1Meta.getName()); assertEquals(2, branch1Meta.getParentVersion()); assertFalse(branch1Meta.getParentBranch().isPresent()); + assertEquals(1, branch1Meta.getBranchIdentifier().size()); + assertEquals(2, branch1Meta.getBranchIdentifier().get(0).getVersion()); + assertFalse(branch1Meta.getBranchIdentifier().get(0).getUuid().isEmpty()); assertTrue(branch1Meta.getCreateAt() > 0); assertTrue(branch1Meta.getManifestSize() > 0); assertEquals("branch2", branch2Meta.getName()); assertTrue(branch2Meta.getParentBranch().isPresent()); assertEquals("branch1", branch2Meta.getParentBranch().get()); + assertEquals(2, branch2Meta.getBranchIdentifier().size()); + assertEquals( + branch1Meta.getBranchIdentifier().get(0), + branch2Meta.getBranchIdentifier().get(0)); + assertEquals(3, branch2Meta.getBranchIdentifier().get(1).getVersion()); + assertFalse(branch2Meta.getBranchIdentifier().get(1).getUuid().isEmpty()); assertEquals(3, branch2Meta.getParentVersion()); assertTrue(branch2Meta.getCreateAt() > 0); assertTrue(branch2Meta.getManifestSize() > 0); diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index 7496746285a..2f38279301a 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -4375,6 +4375,7 @@ class Tag(TypedDict): class Branch(TypedDict): parent_branch: Optional[str] + branch_identifier: List[Tuple[int, str]] parent_version: int create_at: int manifest_size: int diff --git a/python/python/tests/test_dataset.py b/python/python/tests/test_dataset.py index 889c37036a0..8d4f70d6efb 100644 --- a/python/python/tests/test_dataset.py +++ b/python/python/tests/test_dataset.py @@ -5231,11 +5231,16 @@ def test_branches(tmp_path: Path): branch1.tags.create("main_latest", (None, None)) branch1.tags.create("main_latest2", ("main", None)) branch1.create_branch("branch_from_main", ("main", None)) + branches_with_main = branch1.branches.list() assert branch1.tags.list()["branch1_latest"]["branch"] == "branch1" assert branch1.tags.list()["main_latest"]["branch"] is None assert branch1.tags.list()["main_latest2"]["branch"] is None - assert branch1.branches.list()["branch_from_main"]["parent_branch"] is None - assert branch1.branches.list()["branch_from_main"]["parent_version"] == 1 + assert branches_with_main["branch_from_main"]["parent_branch"] is None + assert branches_with_main["branch_from_main"]["branch_identifier"][0][0] == 1 + assert isinstance( + branches_with_main["branch_from_main"]["branch_identifier"][0][1], str + ) + assert branches_with_main["branch_from_main"]["parent_version"] == 1 assert branch1.checkout_version("main_latest").latest_version == 1 assert branch1.checkout_version("main_latest2").latest_version == 1 assert branch1.checkout_version(("branch_from_main", None)).latest_version == 1 @@ -5261,6 +5266,9 @@ def test_branches(tmp_path: Path): b1_meta = branches["branch1"] assert isinstance(b1_meta["parent_version"], int) assert b1_meta["manifest_size"] > 0 + assert b1_meta["branch_identifier"][0][0] == b1_meta["parent_version"] + assert isinstance(b1_meta["branch_identifier"][0][1], str) + assert len(b1_meta["branch_identifier"][0][1]) > 0 assert "create_at" in b1_meta try: diff --git a/python/src/dataset.rs b/python/src/dataset.rs index 35306636c93..f441c00fae4 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -1818,6 +1818,7 @@ impl Dataset { for (name, meta) in branches.iter() { let dict = PyDict::new(py); dict.set_item("parent_branch", meta.parent_branch.clone())?; + dict.set_item("branch_identifier", meta.identifier.version_mapping.clone())?; dict.set_item("parent_version", meta.parent_version)?; dict.set_item("create_at", meta.create_at)?; dict.set_item("manifest_size", meta.manifest_size)?; @@ -1852,6 +1853,7 @@ impl Dataset { for (name, meta) in ordered.into_iter() { let dict = PyDict::new(py); dict.set_item("parent_branch", meta.parent_branch.clone())?; + dict.set_item("branch_identifier", meta.identifier.version_mapping.clone())?; dict.set_item("parent_version", meta.parent_version)?; dict.set_item("create_at", meta.create_at)?; dict.set_item("manifest_size", meta.manifest_size)?; From 1ef6310971462f8d0868f8d4c19dfb54f7609e96 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 1 Apr 2026 17:17:28 +0800 Subject: [PATCH 117/206] refactor!: align distributed index build around segments (#6313) This refactor removes the old `IndexSegment` / segment-builder workflow from distributed vector indexing and aligns the public flow around `IndexMetadata` returned from fragment-level builds. Worker builds now produce uncommitted index metadata, callers optionally merge caller-defined metadata groups, and the final metadata set is committed as one logical index. ```python # Build uncommitted index metadata segment_0 = dataset.create_index_uncommitted(...) segment_1 = dataset.create_index_uncommitted(...) # Merge segments if needed merged_segment = dataset.merge_existing_index_segments([ segment_0, segment_1, ]) # Commit segments dataset.commit_existing_index_segments("vector_idx", "vector", [merged_segment]) ``` --- docs/src/guide/distributed_indexing.md | 42 ++- .../distributed_vector_segment_build.svg | 24 +- java/lance-jni/src/blocking_dataset.rs | 121 +------- java/src/main/java/org/lance/Dataset.java | 16 +- .../java/org/lance/index/VectorIndexTest.java | 25 +- python/python/lance/__init__.py | 2 - python/python/lance/dataset.py | 27 +- python/python/lance/indices/__init__.py | 4 - python/python/lance/lance/__init__.pyi | 17 +- .../python/lance/lance/indices/__init__.pyi | 17 -- python/python/tests/test_vector_index.py | 130 +++++++-- python/src/dataset.rs | 104 ++----- python/src/indices.rs | 120 ++------ python/src/lib.rs | 5 +- rust/lance/src/dataset.rs | 2 +- rust/lance/src/dataset/index.rs | 24 +- rust/lance/src/index.rs | 268 +++++++++++------- rust/lance/src/index/api.rs | 167 +---------- rust/lance/src/index/create.rs | 261 ++++++----------- rust/lance/src/index/vector/ivf.rs | 252 +++------------- rust/lance/src/index/vector/ivf/v2.rs | 108 +++---- 21 files changed, 556 insertions(+), 1180 deletions(-) diff --git a/docs/src/guide/distributed_indexing.md b/docs/src/guide/distributed_indexing.md index 9c75c5ceb88..83da11dbe01 100644 --- a/docs/src/guide/distributed_indexing.md +++ b/docs/src/guide/distributed_indexing.md @@ -93,37 +93,29 @@ First, multiple workers build segments in parallel: or Python `create_index_uncommitted(..., fragment_ids=...)` 2. each worker writes one segment under `indices//` -### Segment Build +### Segment Merge -Then the caller turns those existing segments into one or more physical -segments: +Then the caller decides whether those existing segments should be committed as-is +or merged into larger segments: -1. create a builder with `create_index_segment_builder()` -2. provide segment metadata with `with_segments(...)` -3. optionally choose a grouping policy with `with_target_segment_bytes(...)` -4. call `plan()` to get `Vec` - -At that point the caller has two execution choices: - -- call `build(plan)` for each plan and run those builds in parallel -- call `build_all()` to let Lance build every planned segment on the current node - -After the physical segments are built, publish them with -`commit_existing_index_segments(...)`. +1. keep the worker outputs as-is and commit them directly with + `commit_existing_index_segments(...)`, or +2. group one or more existing segments and call + `merge_existing_index_segments(...)` for each caller-defined group +3. commit the final segment list with `commit_existing_index_segments(...)` Within a single commit, built segments must have disjoint fragment coverage. -## Internal Segmented Finalize Model +## Internal Finalize Model Internally, Lance models distributed vector segment build as: -1. **plan** which input segments should become each physical segment -2. **build** each segment from its selected input segments -3. **commit** the resulting physical segments as one logical index +1. **build** one uncommitted segment per worker +2. **optionally merge** caller-defined groups of existing segments +3. **commit** the resulting segments as one logical index -The plan step is driven by the segment metadata returned from -`execute_uncommitted()` and any additional inputs requested by the segment -build APIs. +The merge step is driven directly by the `IndexMetadata` returned from +`execute_uncommitted()`. This is intentionally a storage-level model: @@ -133,10 +125,10 @@ This is intentionally a storage-level model: ## Segment Grouping -When Lance builds segments from existing inputs, it may either: +The caller chooses the final segment grouping: -- keep segment boundaries, so each input segment becomes one physical segment -- group multiple input segments into a larger physical segment +- keep segment boundaries, so each worker output is committed directly +- merge multiple existing segments into a larger segment before commit The grouping decision is separate from worker build. Workers only build segments; Lance applies the segment build policy when it plans diff --git a/docs/src/images/distributed_vector_segment_build.svg b/docs/src/images/distributed_vector_segment_build.svg index d36f2726010..4d52e2a0c82 100644 --- a/docs/src/images/distributed_vector_segment_build.svg +++ b/docs/src/images/distributed_vector_segment_build.svg @@ -65,40 +65,40 @@ - + - Segment planner - create_index_segment_builder → plan() + Caller-defined grouping + merge_existing_index_segments(...) -Vec<IndexSegmentPlan> +Vec<IndexMetadata> - + - Parallel segment build + Optional segment merge - build(plan[0]) - → IndexSegment 0 + merge(group[0]) + → IndexMetadata 0 - build(plan[1]) - → IndexSegment 1 + merge(group[1]) + → IndexMetadata 1 - build(plan[N]) - → IndexSegment N + merge(group[N]) + → IndexMetadata N diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index 3064e9c043f..700cbdf308a 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -38,7 +38,7 @@ use lance::dataset::{ ColumnAlteration, CommitBuilder, Dataset, NewColumnTransform, ProjectionRequest, ReadParams, Version, WriteParams, }; -use lance::index::{DatasetIndexExt, IndexSegment}; +use lance::index::DatasetIndexExt; use lance::io::commit::namespace_manifest::LanceNamespaceExternalManifestStore; use lance::io::{ObjectStore, ObjectStoreParams}; use lance::session::Session as LanceSession; @@ -1075,53 +1075,29 @@ fn inner_merge_index_metadata( } #[unsafe(no_mangle)] -pub extern "system" fn Java_org_lance_Dataset_nativeBuildIndexSegments<'local>( +pub extern "system" fn Java_org_lance_Dataset_nativeMergeExistingIndexSegments<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, java_segments: JObject, - target_segment_bytes_jobj: JObject, ) -> JObject<'local> { ok_or_throw!( env, - inner_build_index_segments( - &mut env, - java_dataset, - java_segments, - target_segment_bytes_jobj - ) + inner_merge_existing_index_segments(&mut env, java_dataset, java_segments) ) } -fn inner_build_index_segments<'local>( +fn inner_merge_existing_index_segments<'local>( env: &mut JNIEnv<'local>, java_dataset: JObject, java_segments: JObject, - target_segment_bytes_jobj: JObject, ) -> Result> { let segments = import_vec_to_rust(env, &java_segments, |env, obj| obj.extract_object(env))?; - let target_segment_bytes = env - .get_long_opt(&target_segment_bytes_jobj)? - .map(|v| v as u64); - let template = segment_template(&segments)?; - - let built_segments = { + let merged_segment = { let dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - let mut builder = dataset_guard - .inner - .create_index_segment_builder() - .with_segments(segments); - if let Some(target_segment_bytes) = target_segment_bytes { - builder = builder.with_target_segment_bytes(target_segment_bytes); - } - RT.block_on(builder.build_all())? + RT.block_on(dataset_guard.inner.merge_existing_index_segments(segments))? }; - - let built_metadata = built_segments - .into_iter() - .map(|segment| index_segment_to_metadata(&template, segment)) - .collect::>(); - export_vec(env, &built_metadata) + (&merged_segment).into_java(env) } #[unsafe(no_mangle)] @@ -1153,12 +1129,7 @@ fn inner_commit_existing_index_segments<'local>( ) -> Result> { let index_name = index_name.extract(env)?; let column = column.extract(env)?; - let segment_metadata = - import_vec_to_rust(env, &java_segments, |env, obj| obj.extract_object(env))?; - let segments = segment_metadata - .iter() - .map(index_metadata_to_segment) - .collect::>>()?; + let segments = import_vec_to_rust(env, &java_segments, |env, obj| obj.extract_object(env))?; let committed = { let mut dataset_guard = @@ -1174,82 +1145,6 @@ fn inner_commit_existing_index_segments<'local>( export_vec(env, &committed) } -struct SegmentTemplate { - name: String, - fields: Vec, - dataset_version: u64, -} - -fn segment_template(segments: &[IndexMetadata]) -> Result { - let first = segments - .first() - .ok_or_else(|| Error::input_error("segments cannot be empty".to_string()))?; - for segment in &segments[1..] { - if segment.name != first.name { - return Err(Error::input_error(format!( - "All segments must share the same index name, got '{}' and '{}'", - first.name, segment.name - ))); - } - if segment.fields != first.fields { - return Err(Error::input_error(format!( - "All segments must target the same field ids, got {:?} and {:?}", - first.fields, segment.fields - ))); - } - if segment.dataset_version != first.dataset_version { - return Err(Error::input_error(format!( - "All segments must share the same dataset version, got {} and {}", - first.dataset_version, segment.dataset_version - ))); - } - } - - Ok(SegmentTemplate { - name: first.name.clone(), - fields: first.fields.clone(), - dataset_version: first.dataset_version, - }) -} - -fn index_metadata_to_segment(metadata: &IndexMetadata) -> Result { - let fragment_bitmap = metadata.fragment_bitmap.clone().ok_or_else(|| { - Error::input_error(format!( - "Segment '{}' is missing fragment coverage metadata", - metadata.uuid - )) - })?; - let index_details = metadata.index_details.clone().ok_or_else(|| { - Error::input_error(format!( - "Segment '{}' is missing index details metadata", - metadata.uuid - )) - })?; - - Ok(IndexSegment::new( - metadata.uuid, - fragment_bitmap, - index_details, - metadata.index_version, - )) -} - -fn index_segment_to_metadata(template: &SegmentTemplate, segment: IndexSegment) -> IndexMetadata { - let (uuid, fragment_bitmap, index_details, index_version) = segment.into_parts(); - IndexMetadata { - uuid, - fields: template.fields.clone(), - name: template.name.clone(), - dataset_version: template.dataset_version, - fragment_bitmap: Some(fragment_bitmap), - index_details: Some(index_details), - index_version, - created_at: Some(Utc::now()), - base_id: None, - files: None, - } -} - #[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeOptimizeIndices( mut env: JNIEnv, diff --git a/java/src/main/java/org/lance/Dataset.java b/java/src/main/java/org/lance/Dataset.java index 0bd693e88e2..5cba7696a76 100644 --- a/java/src/main/java/org/lance/Dataset.java +++ b/java/src/main/java/org/lance/Dataset.java @@ -1024,25 +1024,17 @@ public void mergeIndexMetadata( private native void innerMergeIndexMetadata( String indexUUID, int indexType, Optional batchReadHead); - /** - * Build physical vector index segments from previously-created fragment-level index outputs. - * - * @param segments segment metadata returned by {@link #createIndex(IndexOptions)} when - * fragmentIds are provided - * @param targetSegmentBytes optional size target for merged physical segments - * @return built physical segment metadata - */ - public List buildIndexSegments(List segments, Optional targetSegmentBytes) { + /** Merge one caller-defined group of existing uncommitted vector index segments. */ + public Index mergeExistingIndexSegments(List segments) { Preconditions.checkNotNull(segments, "segments cannot be null"); Preconditions.checkArgument(!segments.isEmpty(), "segments cannot be empty"); try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); - return nativeBuildIndexSegments(segments, targetSegmentBytes); + return nativeMergeExistingIndexSegments(segments); } } - private native List nativeBuildIndexSegments( - List segments, Optional targetSegmentBytes); + private native Index nativeMergeExistingIndexSegments(List segments); /** * Publish one or more existing physical index segments as a logical index. diff --git a/java/src/test/java/org/lance/index/VectorIndexTest.java b/java/src/test/java/org/lance/index/VectorIndexTest.java index a96b6593d30..50499197b34 100755 --- a/java/src/test/java/org/lance/index/VectorIndexTest.java +++ b/java/src/test/java/org/lance/index/VectorIndexTest.java @@ -29,7 +29,6 @@ import java.nio.file.Path; import java.util.Collections; import java.util.List; -import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -95,13 +94,11 @@ public void testCreateIvfFlatIndexDistributively(@TempDir Path tempDir) throws E dataset.listIndexes().contains(TestVectorDataset.indexName), "Partially created IVF_FLAT index should not present before commit"); - List builtSegments = - dataset.buildIndexSegments(List.of(firstSegment, secondSegment), Optional.empty()); - assertEquals(2, builtSegments.size()); - List committed = dataset.commitExistingIndexSegments( - TestVectorDataset.indexName, TestVectorDataset.vectorColumnName, builtSegments); + TestVectorDataset.indexName, + TestVectorDataset.vectorColumnName, + List.of(firstSegment, secondSegment)); assertEquals(2, committed.size()); assertTrue(dataset.listIndexes().contains(TestVectorDataset.indexName)); } @@ -187,13 +184,11 @@ public void testCreateIvfPqIndexDistributively(@TempDir Path tempDir) throws Exc dataset.listIndexes().contains(TestVectorDataset.indexName), "Partially created IVF_PQ index should not present before commit"); - List builtSegments = - dataset.buildIndexSegments(List.of(firstSegment, secondSegment), Optional.empty()); - assertEquals(2, builtSegments.size()); - List committed = dataset.commitExistingIndexSegments( - TestVectorDataset.indexName, TestVectorDataset.vectorColumnName, builtSegments); + TestVectorDataset.indexName, + TestVectorDataset.vectorColumnName, + List.of(firstSegment, secondSegment)); assertEquals(2, committed.size()); assertTrue(dataset.listIndexes().contains(TestVectorDataset.indexName)); } @@ -263,13 +258,11 @@ public void testCreateIvfSqIndexDistributively(@TempDir Path tempDir) throws Exc dataset.listIndexes().contains(TestVectorDataset.indexName), "Partially created IVF_SQ index should not present before commit"); - List builtSegments = - dataset.buildIndexSegments(List.of(firstSegment, secondSegment), Optional.empty()); - assertEquals(2, builtSegments.size()); - List committed = dataset.commitExistingIndexSegments( - TestVectorDataset.indexName, TestVectorDataset.vectorColumnName, builtSegments); + TestVectorDataset.indexName, + TestVectorDataset.vectorColumnName, + List.of(firstSegment, secondSegment)); assertEquals(2, committed.size()); assertTrue(dataset.listIndexes().contains(TestVectorDataset.indexName)); } diff --git a/python/python/lance/__init__.py b/python/python/lance/__init__.py index 95bacfc3091..0b905457a50 100644 --- a/python/python/lance/__init__.py +++ b/python/python/lance/__init__.py @@ -29,7 +29,6 @@ from .lance import ( DatasetBasePath, FFILanceTableProvider, - IndexSegmentBuilder, ScanStatistics, bytes_read_counter, iops_counter, @@ -65,7 +64,6 @@ "FragmentMetadata", "Index", "IndexFile", - "IndexSegmentBuilder", "LanceDataset", "LanceFragment", "LanceOperation", diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index 2f38279301a..fda7ddd5d0d 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -47,7 +47,7 @@ from .dependencies import numpy as np from .dependencies import pandas as pd from .fragment import DataFile, FragmentMetadata, LanceFragment -from .indices import IndexConfig, IndexSegment, SupportedDistributedIndices +from .indices import IndexConfig, SupportedDistributedIndices from .lance import ( CleanupStats, Compaction, @@ -3261,7 +3261,7 @@ def create_index( This enables distributed/fragment-level indexing. When provided, the method creates one segment but does not commit the index to the dataset. The returned metadata can be passed to - ``create_index_segment_builder().with_segments(...)`` + optionally merged with ``merge_existing_index_segments(...)`` and then committed with ``commit_existing_index_segments(...)``. index_uuid : str, optional A UUID to use for the segment written by this call. @@ -3440,8 +3440,9 @@ def create_index_uncommitted( 1. run :meth:`create_index_uncommitted` on each worker with that worker's assigned ``fragment_ids`` 2. collect the returned :class:`Index` objects - 3. pass them to :meth:`IndexSegmentBuilder.with_segments` - 4. build one or more physical segments and commit them with + 3. optionally merge one or more caller-defined groups with + :meth:`merge_existing_index_segments` + 4. commit the final segment list with :meth:`commit_existing_index_segments` Parameters are the same as :meth:`create_index`, with one additional @@ -3517,9 +3518,9 @@ def merge_index_metadata( Merge distributed scalar index metadata. Vector distributed indexing no longer uses this API. For vector indices, - build segments with :meth:`create_index_uncommitted`, plan or - merge them with :meth:`create_index_segment_builder`, and publish them - with :meth:`commit_existing_index_segments`. + build segments with :meth:`create_index_uncommitted`, optionally merge + caller-defined groups with :meth:`merge_existing_index_segments`, and + publish them with :meth:`commit_existing_index_segments`. This method does NOT commit changes. @@ -3553,18 +3554,14 @@ def merge_index_metadata( self._ds.merge_index_metadata(index_uuid, t, batch_readhead) return None - def create_index_segment_builder(self): + def merge_existing_index_segments(self, segments: List[Index]) -> Index: """ - Create a builder for turning existing segments into physical segments. - - Provide the segment metadata returned by - :meth:`create_index_uncommitted` through - :meth:`IndexSegmentBuilder.with_segments`. + Merge one caller-defined group of existing uncommitted segments. """ - return self._ds.create_index_segment_builder() + return self._ds.merge_existing_index_segments(segments) def commit_existing_index_segments( - self, index_name: str, column: str, segments: List[IndexSegment] + self, index_name: str, column: str, segments: List[Index] ) -> LanceDataset: """ Commit built index segments as one logical index. diff --git a/python/python/lance/indices/__init__.py b/python/python/lance/indices/__init__.py index b35e5d5b174..edf9e5091ff 100644 --- a/python/python/lance/indices/__init__.py +++ b/python/python/lance/indices/__init__.py @@ -8,9 +8,7 @@ from .ivf import IvfModel from .pq import PqModel -IndexSegment = _lance.indices.IndexSegment IndexSegmentDescription = _lance.indices.IndexSegmentDescription -IndexSegmentPlan = _lance.indices.IndexSegmentPlan __all__ = [ "IndicesBuilder", @@ -18,9 +16,7 @@ "PqModel", "IvfModel", "IndexFileVersion", - "IndexSegment", "IndexSegmentDescription", - "IndexSegmentPlan", ] diff --git a/python/python/lance/lance/__init__.pyi b/python/python/lance/lance/__init__.pyi index f0be29f39ca..6b9e6e2ea0c 100644 --- a/python/python/lance/lance/__init__.pyi +++ b/python/python/lance/lance/__init__.pyi @@ -61,9 +61,7 @@ from .fragment import ( RowIdMeta as RowIdMeta, ) from .indices import IndexDescription as IndexDescription -from .indices import IndexSegment as IndexSegment from .indices import IndexSegmentDescription as IndexSegmentDescription -from .indices import IndexSegmentPlan as IndexSegmentPlan from .lance import PySearchFilter from .optimize import ( Compaction as Compaction, @@ -188,15 +186,6 @@ class LanceColumnStatistics: class _Session: def size_bytes(self) -> int: ... -class IndexSegmentBuilder: - @property - def staging_index_uuid(self) -> str: ... - def with_partial_indices(self, partial_indices: List[Index]) -> Self: ... - def with_target_segment_bytes(self, bytes: int) -> Self: ... - def plan(self) -> List[IndexSegmentPlan]: ... - def build(self, plan: IndexSegmentPlan) -> IndexSegment: ... - def build_all(self) -> List[IndexSegment]: ... - class LanceBlobFile: def close(self): ... def is_closed(self) -> bool: ... @@ -372,11 +361,9 @@ class _Dataset: def merge_index_metadata( self, index_uuid: str, index_type: str, batch_readhead: Optional[int] = None ): ... - def create_index_segment_builder( - self, staging_index_uuid: str - ) -> IndexSegmentBuilder: ... + def merge_existing_index_segments(self, segments: List[Index]) -> Index: ... def commit_existing_index_segments( - self, index_name: str, column: str, segments: List[IndexSegment] + self, index_name: str, column: str, segments: List[Index] ) -> None: ... def count_fragments(self) -> int: ... def num_small_files(self, max_rows_per_group: int) -> int: ... diff --git a/python/python/lance/lance/indices/__init__.pyi b/python/python/lance/lance/indices/__init__.pyi index 3369b61c619..152ea1d10b2 100644 --- a/python/python/lance/lance/indices/__init__.pyi +++ b/python/python/lance/lance/indices/__init__.pyi @@ -17,27 +17,10 @@ from typing import Optional import pyarrow as pa -from ...dataset import Index - class IndexConfig: index_type: str config: str -class IndexSegment: - uuid: str - fragment_ids: set[int] - index_version: int - - def __repr__(self) -> str: ... - -class IndexSegmentPlan: - staging_index_uuid: str - segment: IndexSegment - partial_indices: list[Index] - estimated_bytes: int - - def __repr__(self) -> str: ... - def train_ivf_model( dataset, column: str, diff --git a/python/python/tests/test_vector_index.py b/python/python/tests/test_vector_index.py index b20ffc8cf7a..70d3c6e8b81 100644 --- a/python/python/tests/test_vector_index.py +++ b/python/python/tests/test_vector_index.py @@ -2148,9 +2148,6 @@ def build_distributed_vector_index( ) ) - segments = ( - dataset.create_index_segment_builder().with_segments(segments).build_all() - ) return dataset.commit_existing_index_segments(f"{column}_idx", column, segments) @@ -2522,7 +2519,6 @@ def test_metadata_merge_pq_success(tmp_path): ivf_centroids=pre["ivf_centroids"], pq_codebook=pre["pq_codebook"], ) - segments = ds.create_index_segment_builder().with_segments(segments).build_all() ds = _commit_segments_helper(ds, segments, "vector") q = np.random.rand(128).astype(np.float32) results = ds.to_table(nearest={"column": "vector", "q": q, "k": 10}) @@ -2561,7 +2557,6 @@ def test_distributed_workflow_merge_and_search(tmp_path): ivf_centroids=pre["ivf_centroids"], pq_codebook=pre["pq_codebook"], ) - segments = ds.create_index_segment_builder().with_segments(segments).build_all() ds = _commit_segments_helper(ds, segments, "vector") q = np.random.rand(128).astype(np.float32) results = ds.to_table(nearest={"column": "vector", "q": q, "k": 10}) @@ -2597,7 +2592,6 @@ def test_vector_merge_two_shards_success_flat(tmp_path): ivf_centroids=preprocessed["ivf_centroids"], pq_codebook=preprocessed["pq_codebook"], ) - segments = ds.create_index_segment_builder().with_segments(segments).build_all() ds = _commit_segments_helper(ds, segments, column="vector") q = np.random.rand(128).astype(np.float32) result = ds.to_table(nearest={"column": "vector", "q": q, "k": 5}) @@ -2650,7 +2644,6 @@ def test_distributed_ivf_parameterized(tmp_path, index_type, num_sub_vectors): ds.create_index_uncommitted(**kwargs1), ds.create_index_uncommitted(**kwargs2), ] - segments = ds.create_index_segment_builder().with_segments(segments).build_all() ds = _commit_segments_helper(ds, segments, "vector") q = np.random.rand(128).astype(np.float32) @@ -2711,20 +2704,16 @@ def test_merge_two_shards_parameterized(tmp_path, index_type, num_sub_vectors): kwargs2["pq_codebook"] = pre["pq_codebook"] segment2 = ds.create_index_uncommitted(**kwargs2) - segments = ( - ds.create_index_segment_builder() - .with_segments([segment1, segment2]) - .build_all() - ) - ds = _commit_segments_helper(ds, segments, column="vector") + merged_segment = ds.merge_existing_index_segments([segment1, segment2]) + ds = _commit_segments_helper(ds, [merged_segment], column="vector") q = np.random.rand(128).astype(np.float32) results = ds.to_table(nearest={"column": "vector", "q": q, "k": 5}) assert 0 < len(results) <= 5 -def test_index_segment_builder_builds_vector_segments(tmp_path): - ds = _make_sample_dataset_base(tmp_path, "segment_builder_ds", 2000, 128) +def test_merge_existing_index_segments_builds_vector_segment(tmp_path): + ds = _make_sample_dataset_base(tmp_path, "merge_existing_segments_ds", 2000, 128) frags = ds.get_fragments() assert len(frags) >= 2 builder = IndicesBuilder(ds, "vector") @@ -2751,20 +2740,115 @@ def test_index_segment_builder_builds_vector_segments(tmp_path): for fragment in frags[:2] ] - segment_builder = ds.create_index_segment_builder().with_segments(segments) - plans = segment_builder.plan() - assert len(plans) == 2 - assert all(len(plan.segments) == 1 for plan in plans) - - segments = segment_builder.build_all() - assert len(segments) == 2 - ds = ds.commit_existing_index_segments("vector_idx", "vector", segments) + merged_segment = ds.merge_existing_index_segments(segments) + assert merged_segment.fragment_ids is not None + assert sorted(merged_segment.fragment_ids) == sorted( + [fragment.fragment_id for fragment in frags[:2]] + ) + ds = ds.commit_existing_index_segments("vector_idx", "vector", [merged_segment]) q = np.random.rand(128).astype(np.float32) results = ds.to_table(nearest={"column": "vector", "q": q, "k": 5}) assert 0 < len(results) <= 5 +def test_distributed_ivf_pq_order_invariance(tmp_path: Path): + """Ensure distributed IVF_PQ build is invariant to shard build order.""" + ds = _make_sample_dataset_base(tmp_path, "dist_ds", 2000, 128) + + # Global IVF+PQ training once; artifacts are reused across shard orders. + builder = IndicesBuilder(ds, "vector") + pre = builder.prepare_global_ivf_pq( + num_partitions=4, + num_subvectors=16, + distance_type="l2", + sample_rate=7, + ) + + # Copy the dataset twice so index manifests do not clash and we can vary + # the shard build order independently on identical data. + ds_order_12 = lance.write_dataset( + ds.to_table(), tmp_path / "pq_order_node1_node2", max_rows_per_file=500 + ) + ds_order_21 = lance.write_dataset( + ds.to_table(), tmp_path / "pq_order_node2_node1", max_rows_per_file=500 + ) + + # For each copy, derive two shard groups from its own fragments. + frags_12 = ds_order_12.get_fragments() + if len(frags_12) < 2: + pytest.skip("Need at least 2 fragments for distributed indexing (order_12)") + mid_12 = len(frags_12) // 2 + node1_12 = [f.fragment_id for f in frags_12[:mid_12]] + node2_12 = [f.fragment_id for f in frags_12[mid_12:]] + if not node1_12 or not node2_12: + pytest.skip("Failed to split fragments into two non-empty groups (order_12)") + + frags_21 = ds_order_21.get_fragments() + if len(frags_21) < 2: + pytest.skip("Need at least 2 fragments for distributed indexing (order_21)") + mid_21 = len(frags_21) // 2 + node1_21 = [f.fragment_id for f in frags_21[:mid_21]] + node2_21 = [f.fragment_id for f in frags_21[mid_21:]] + if not node1_21 or not node2_21: + pytest.skip("Failed to split fragments into two non-empty groups (order_21)") + + def build_distributed_ivf_pq(ds_copy, shard_order): + try: + segments = _build_segments( + ds_copy, + "vector", + "IVF_PQ", + shard_order, + index_name="vector_idx", + num_partitions=4, + num_sub_vectors=16, + ivf_centroids=pre["ivf_centroids"], + pq_codebook=pre["pq_codebook"], + ) + return _commit_segments_helper(ds_copy, segments, column="vector") + except ValueError as e: + raise e + + ds_12 = build_distributed_ivf_pq(ds_order_12, [node1_12, node2_12]) + ds_21 = build_distributed_ivf_pq(ds_order_21, [node2_21, node1_21]) + + # Sample queries once from the original dataset and reuse for both index builds + # to check order invariance under distributed PQ training and merging. + k = 10 + sample_tbl = ds.sample(10, columns=["vector"]) + queries = [ + np.asarray(v, dtype=np.float32) for v in sample_tbl["vector"].to_pylist() + ] + + def collect_ids_and_distances(ds_with_index): + ids_per_query = [] + dists_per_query = [] + for q in queries: + tbl = ds_with_index.to_table( + columns=["id", "_distance"], + nearest={ + "column": "vector", + "q": q, + "k": k, + "nprobes": 16, + "refine_factor": 100, + }, + ) + ids_per_query.append([int(x) for x in tbl["id"].to_pylist()]) + dists_per_query.append(tbl["_distance"].to_numpy()) + return ids_per_query, dists_per_query + + ids_12, dists_12 = collect_ids_and_distances(ds_12) + ids_21, dists_21 = collect_ids_and_distances(ds_21) + + # TopK ids must match exactly and distances must be numerically stable across + # different shard build orders (allow tiny floating error). + assert ids_12 == ids_21 + for a, b in zip(dists_12, dists_21): + assert np.allclose(a, b, atol=1e-6) + + def test_fts_filter_vector_search(tmp_path): # Create dataset with vector and text columns ids = list(range(1, 301)) diff --git a/python/src/dataset.rs b/python/src/dataset.rs index f441c00fae4..e1f219b8f9b 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -87,7 +87,7 @@ use lance_table::io::commit::external_manifest::ExternalManifestCommitHandler; use crate::error::PythonErrorExt; use crate::file::object_store_from_uri_or_path; use crate::fragment::FileFragment; -use crate::indices::{PyIndexConfig, PyIndexDescription, PyIndexSegment, PyIndexSegmentPlan}; +use crate::indices::{PyIndexConfig, PyIndexDescription}; use crate::namespace::extract_namespace_arc; use crate::rt; use crate::scanner::ScanStatistics; @@ -323,78 +323,6 @@ impl MergeInsertBuilder { } } -#[pyclass(name = "IndexSegmentBuilder", module = "lance", subclass)] -#[derive(Clone)] -pub struct PyIndexSegmentBuilder { - dataset: Arc, - segments: Vec, - target_segment_bytes: Option, -} - -impl PyIndexSegmentBuilder { - fn builder(&self) -> ::IndexSegmentBuilder<'_> { - let mut builder = self - .dataset - .create_index_segment_builder() - .with_segments(self.segments.clone()); - if let Some(target_segment_bytes) = self.target_segment_bytes { - builder = builder.with_target_segment_bytes(target_segment_bytes); - } - builder - } -} - -#[pymethods] -impl PyIndexSegmentBuilder { - fn with_segments<'a>( - mut slf: PyRefMut<'a, Self>, - segments: &Bound<'_, PyAny>, - ) -> PyResult> { - let mut indices = Vec::new(); - for item in segments.try_iter()? { - indices.push(item?.extract::>()?.0); - } - slf.segments = indices; - Ok(slf) - } - - fn with_target_segment_bytes<'a>( - mut slf: PyRefMut<'a, Self>, - bytes: u64, - ) -> PyResult> { - slf.target_segment_bytes = Some(bytes); - Ok(slf) - } - - fn plan(&self, py: Python<'_>) -> PyResult>> { - let plans = rt() - .block_on(Some(py), self.builder().plan())? - .infer_error()?; - plans - .into_iter() - .map(|plan| Py::new(py, PyIndexSegmentPlan::from_inner(plan))) - .collect() - } - - fn build(&self, py: Python<'_>, plan: &Bound<'_, PyAny>) -> PyResult> { - let plan = plan.extract::>()?; - let segment = rt() - .block_on(Some(py), self.builder().build(&plan.inner))? - .infer_error()?; - Py::new(py, PyIndexSegment::from_inner(segment)) - } - - fn build_all(&self, py: Python<'_>) -> PyResult>> { - let segments = rt() - .block_on(Some(py), self.builder().build_all())? - .infer_error()?; - segments - .into_iter() - .map(|segment| Py::new(py, PyIndexSegment::from_inner(segment))) - .collect() - } -} - impl MergeInsertBuilder { fn build_stats<'a>(stats: &MergeStats, py: Python<'a>) -> PyResult> { let dict = PyDict::new(py); @@ -2093,28 +2021,34 @@ impl Dataset { Ok(PyLance(index_metadata)) } - fn create_index_segment_builder(&self) -> PyResult { - Ok(PyIndexSegmentBuilder { - dataset: self.ds.clone(), - segments: Vec::new(), - target_segment_bytes: None, - }) + fn merge_existing_index_segments( + &self, + segments: Vec>, + ) -> PyResult> { + let merged = rt() + .block_on( + None, + self.ds + .merge_existing_index_segments(segments.into_iter().map(|s| s.0).collect()), + )? + .infer_error()?; + Ok(PyLance(merged)) } fn commit_existing_index_segments( &mut self, index_name: &str, column: &str, - segments: Vec>, + segments: Vec>, ) -> PyResult<()> { let mut new_self = self.ds.as_ref().clone(); - let segments = segments - .into_iter() - .map(|segment| segment.inner.clone()) - .collect(); rt().block_on( None, - new_self.commit_existing_index_segments(index_name, column, segments), + new_self.commit_existing_index_segments( + index_name, + column, + segments.into_iter().map(|segment| segment.0).collect(), + ), )? .infer_error()?; self.ds = Arc::new(new_self); diff --git a/python/src/indices.rs b/python/src/indices.rs index cea7f2a968a..62f3c0c64ec 100644 --- a/python/src/indices.rs +++ b/python/src/indices.rs @@ -10,9 +10,9 @@ use arrow_array::{Array, FixedSizeListArray}; use arrow_data::ArrayData; use chrono::{DateTime, Utc}; use lance::dataset::Dataset as LanceDataset; +use lance::index::DatasetIndexExt; use lance::index::vector::ivf::builder::write_vector_storage; use lance::index::vector::pq::build_pq_model_in_fragments; -use lance::index::{DatasetIndexExt, IndexSegment, IndexSegmentPlan}; use lance::io::ObjectStore; use lance_index::progress::NoopIndexBuildProgress; use lance_index::vector::ivf::shuffler::{IvfShuffler, shuffle_vectors}; @@ -21,6 +21,7 @@ use lance_index::vector::{ pq::{PQBuildParams, ProductQuantizer}, }; use lance_linalg::distance::DistanceType; +use lance_table::format::{IndexMetadata, list_index_files_with_sizes}; use pyo3::Bound; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -34,7 +35,7 @@ use pyo3::{ use lance::index::DatasetIndexInternalExt; use crate::fragment::FileFragment; -use crate::utils::{PyJson, PyLance}; +use crate::utils::PyJson; use crate::{ dataset::Dataset, error::PythonErrorExt, file::object_store_from_uri_or_path_no_options, rt, }; @@ -60,82 +61,6 @@ impl PyIndexConfig { } } -#[pyclass(name = "IndexSegment", module = "lance.indices")] -#[derive(Debug, Clone)] -pub struct PyIndexSegment { - pub(crate) inner: IndexSegment, -} - -impl PyIndexSegment { - pub(crate) fn from_inner(inner: IndexSegment) -> Self { - Self { inner } - } -} - -#[pymethods] -impl PyIndexSegment { - #[getter] - fn uuid(&self) -> String { - self.inner.uuid().to_string() - } - - #[getter] - fn fragment_ids(&self) -> HashSet { - self.inner.fragment_bitmap().iter().collect() - } - - #[getter] - fn index_version(&self) -> i32 { - self.inner.index_version() - } - - fn __repr__(&self) -> String { - format!( - "IndexSegment(uuid={}, fragment_ids={:?}, index_version={})", - self.uuid(), - self.fragment_ids(), - self.index_version() - ) - } -} - -#[pyclass(name = "IndexSegmentPlan", module = "lance.indices")] -#[derive(Debug, Clone)] -pub struct PyIndexSegmentPlan { - pub(crate) inner: IndexSegmentPlan, -} - -impl PyIndexSegmentPlan { - pub(crate) fn from_inner(inner: IndexSegmentPlan) -> Self { - Self { inner } - } -} - -#[pymethods] -impl PyIndexSegmentPlan { - #[getter] - fn segment(&self) -> PyIndexSegment { - PyIndexSegment::from_inner(self.inner.segment().clone()) - } - - #[getter] - fn segments(&self) -> Vec> { - self.inner.segments().iter().cloned().map(PyLance).collect() - } - - #[getter] - fn estimated_bytes(&self) -> u64 { - self.inner.estimated_bytes() - } - fn __repr__(&self) -> String { - format!( - "IndexSegmentPlan(segments={}, estimated_bytes={})", - self.inner.segments().len(), - self.estimated_bytes() - ) - } -} - #[pyclass(name = "IvfModel", module = "lance.indices")] #[derive(Debug, Clone)] pub struct PyIvfModel { @@ -505,21 +430,28 @@ async fn do_load_shuffled_vectors( .infer_error()?; let mut ds = dataset.ds.as_ref().clone(); - ds.commit_existing_index_segments( - index_name, - column, - vec![IndexSegment::new( - index_id, - ds.fragments().iter().map(|f| f.id as u32), - Arc::new( - prost_types::Any::from_msg(&lance_table::format::pb::VectorIndexDetails::default()) - .unwrap(), - ), - IndexType::IvfPq.version(), - )], - ) - .await - .infer_error()?; + let index_dir = ds.indices_dir().child(index_id.to_string()); + let files = list_index_files_with_sizes(ds.object_store(), &index_dir) + .await + .infer_error()?; + let metadata = IndexMetadata { + uuid: index_id, + name: index_name.to_string(), + fields: vec![ds.schema().field(column).unwrap().id], + dataset_version: ds.manifest.version, + fragment_bitmap: Some(ds.fragments().iter().map(|f| f.id as u32).collect()), + index_details: Some(Arc::new( + prost_types::Any::from_msg(&lance_table::format::pb::VectorIndexDetails::default()) + .unwrap(), + )), + index_version: IndexType::IvfPq.version(), + created_at: Some(Utc::now()), + base_id: None, + files: Some(files), + }; + ds.commit_existing_index_segments(index_name, column, vec![metadata]) + .await + .infer_error()?; Ok(()) } @@ -711,8 +643,6 @@ pub fn register_indices(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { indices.add_wrapped(wrap_pyfunction!(load_shuffled_vectors))?; indices.add_class::()?; indices.add_class::()?; - indices.add_class::()?; - indices.add_class::()?; indices.add_class::()?; indices.add_class::()?; indices.add_wrapped(wrap_pyfunction!(get_ivf_model))?; diff --git a/python/src/lib.rs b/python/src/lib.rs index 9730f2ba1c5..ee5eaf96a16 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -45,9 +45,7 @@ use dataset::io_stats::IoStats; use dataset::optimize::{ PyCompaction, PyCompactionMetrics, PyCompactionPlan, PyCompactionTask, PyRewriteResult, }; -use dataset::{ - DatasetBasePath, MergeInsertBuilder, PyFullTextQuery, PyIndexSegmentBuilder, PySearchFilter, -}; +use dataset::{DatasetBasePath, MergeInsertBuilder, PyFullTextQuery, PySearchFilter}; use env_logger::{Builder, Env}; use file::{ LanceBufferDescriptor, LanceColumnMetadata, LanceFileMetadata, LanceFileReader, @@ -254,7 +252,6 @@ fn lance(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; - m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 6b244bcacef..6c09ed002c6 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -2791,7 +2791,7 @@ impl Dataset { IndexType::IvfFlat | IndexType::IvfPq | IndexType::IvfSq | IndexType::Vector => { Err(Error::invalid_input( "Vector distributed indexing no longer supports merge_index_metadata; \ - build segments, use create_index_segment_builder(), \ + build segments, optionally merge groups with merge_existing_index_segments(...), \ and commit with commit_existing_index_segments(...)" .to_string(), )) diff --git a/rust/lance/src/dataset/index.rs b/rust/lance/src/dataset/index.rs index fded774b151..856f7361892 100644 --- a/rust/lance/src/dataset/index.rs +++ b/rust/lance/src/dataset/index.rs @@ -179,8 +179,8 @@ mod tests { use super::*; use crate::dataset::WriteParams; + use crate::index::DatasetIndexExt; use crate::index::vector::VectorIndexParams; - use crate::index::{DatasetIndexExt, IndexSegment}; use lance_datagen::{BatchCount, RowCount, array}; use lance_index::IndexType; use lance_linalg::distance::MetricType; @@ -242,18 +242,16 @@ mod tests { } let segments = vec![ - IndexSegment::new( - first_segment_uuid, - [target_fragments[0].id() as u32], - built_index.index_details.clone().unwrap(), - built_index.index_version, - ), - IndexSegment::new( - second_segment_uuid, - [target_fragments[1].id() as u32], - built_index.index_details.clone().unwrap(), - built_index.index_version, - ), + IndexMetadata { + uuid: first_segment_uuid, + fragment_bitmap: Some(std::iter::once(target_fragments[0].id() as u32).collect()), + ..built_index.clone() + }, + IndexMetadata { + uuid: second_segment_uuid, + fragment_bitmap: Some(std::iter::once(target_fragments[1].id() as u32).collect()), + ..built_index + }, ]; dataset diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index b9fc14f72cc..de318750fe3 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -80,7 +80,7 @@ use crate::dataset::index::LanceIndexStoreExt; use crate::dataset::optimize::RemappedIndex; use crate::dataset::optimize::remapping::RemapResult; use crate::dataset::transaction::{Operation, Transaction, TransactionBuilder}; -pub use crate::index::api::{DatasetIndexExt, IndexSegment, IndexSegmentPlan}; +pub use crate::index::api::DatasetIndexExt; use crate::index::frag_reuse::{load_frag_reuse_index_details, open_frag_reuse_index}; use crate::index::mem_wal::open_mem_wal_index; pub use crate::index::prefilter::{FilterLoader, PreFilter}; @@ -90,7 +90,7 @@ use crate::{Error, Result, dataset::Dataset}; pub use create::CreateIndexBuilder; pub use lance_index::IndexDescription; -fn validate_index_segments(index_name: &str, segments: &[IndexSegment]) -> Result<()> { +fn validate_segment_metadata(index_name: &str, segments: &[IndexMetadata]) -> Result<()> { if segments.is_empty() { return Err(Error::invalid_input( "CreateIndex: at least one index segment is required".to_string(), @@ -100,55 +100,30 @@ fn validate_index_segments(index_name: &str, segments: &[IndexSegment]) -> Resul let mut seen_segment_ids = HashSet::with_capacity(segments.len()); let mut covered_fragments = RoaringBitmap::new(); for segment in segments { - if !seen_segment_ids.insert(segment.uuid()) { + if !seen_segment_ids.insert(segment.uuid) { return Err(Error::invalid_input(format!( "CreateIndex: duplicate segment uuid {} for index '{}'", - segment.uuid(), - index_name + segment.uuid, index_name ))); } - if !covered_fragments.is_disjoint(segment.fragment_bitmap()) { + let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { + Error::invalid_input(format!( + "CreateIndex: segment {} is missing fragment coverage", + segment.uuid + )) + })?; + if !covered_fragments.is_disjoint(fragment_bitmap) { return Err(Error::invalid_input(format!( "CreateIndex: overlapping fragment coverage in segment set for index '{}'", index_name ))); } - covered_fragments |= segment.fragment_bitmap().clone(); + covered_fragments |= fragment_bitmap.clone(); } Ok(()) } -pub(crate) async fn build_index_metadata_from_segments( - dataset: &Dataset, - index_name: &str, - field_id: i32, - segments: Vec, -) -> Result> { - validate_index_segments(index_name, &segments)?; - - let mut new_indices = Vec::with_capacity(segments.len()); - for segment in segments { - let (uuid, fragment_bitmap, index_details, index_version) = segment.into_parts(); - let index_dir = dataset.indices_dir().child(uuid.to_string()); - let files = list_index_files_with_sizes(&dataset.object_store, &index_dir).await?; - new_indices.push(IndexMetadata { - uuid, - name: index_name.to_string(), - fields: vec![field_id], - dataset_version: dataset.manifest.version, - fragment_bitmap: Some(fragment_bitmap), - index_details: Some(index_details), - index_version, - created_at: Some(chrono::Utc::now()), - base_id: None, - files: Some(files), - }); - } - - Ok(new_indices) -} - // Cache keys for different index types #[derive(Debug, Clone)] pub struct ScalarIndexCacheKey<'a> { @@ -660,7 +635,6 @@ impl IndexDescription for IndexDescriptionImpl { #[async_trait] impl DatasetIndexExt for Dataset { type IndexBuilder<'a> = CreateIndexBuilder<'a>; - type IndexSegmentBuilder<'a> = create::IndexSegmentBuilder<'a>; /// Create a builder for creating an index on columns. /// @@ -708,10 +682,6 @@ impl DatasetIndexExt for Dataset { CreateIndexBuilder::new(self, columns, index_type, params) } - fn create_index_segment_builder<'a>(&'a self) -> create::IndexSegmentBuilder<'a> { - create::IndexSegmentBuilder::new(self) - } - #[instrument(skip_all)] async fn create_index( &mut self, @@ -859,26 +829,78 @@ impl DatasetIndexExt for Dataset { } } + async fn merge_existing_index_segments( + &self, + source_segments: Vec, + ) -> Result { + validate_segment_metadata("uncommitted", &source_segments)?; + let field_id = *source_segments[0].fields.first().ok_or_else(|| { + Error::invalid_input(format!( + "CreateIndex: segment {} is missing field ids", + source_segments[0].uuid + )) + })?; + if source_segments + .iter() + .any(|segment| segment.fields != [field_id]) + { + return Err(Error::invalid_input( + "merge_existing_index_segments requires segments with identical fields".to_string(), + )); + } + if !source_segments.iter().all(|segment| { + segment.index_details.as_ref().map_or_else( + || { + segment + .files + .as_ref() + .is_some_and(|files| files.iter().any(|file| file.path == INDEX_FILE_NAME)) + }, + |details| details.type_url.ends_with("VectorIndexDetails"), + ) + }) { + return Err(Error::invalid_input( + "merge_existing_index_segments currently only supports vector segments".to_string(), + )); + } + + let mut merged_segment = crate::index::vector::ivf::merge_segments( + self.object_store(), + &self.indices_dir(), + source_segments, + ) + .await?; + merged_segment.dataset_version = self.manifest.version; + merged_segment.fields = vec![field_id]; + Ok(merged_segment) + } + async fn commit_existing_index_segments( &mut self, index_name: &str, column: &str, - segments: Vec, + segments: Vec, ) -> Result<()> { - if segments.is_empty() { - return Err(Error::invalid_input( - "CreateIndex: at least one index segment is required".to_string(), - )); - } - let Some(field) = self.schema().field(column) else { return Err(Error::index(format!( "CreateIndex: column '{column}' does not exist" ))); }; - let new_indices = - build_index_metadata_from_segments(self, index_name, field.id, segments).await?; + validate_segment_metadata(index_name, &segments)?; + + let mut new_indices = Vec::with_capacity(segments.len()); + for mut segment in segments { + if segment.fields != [field.id] { + return Err(Error::invalid_input(format!( + "CreateIndex: segment {} was built for fields {:?}, expected [{}]", + segment.uuid, segment.fields, field.id + ))); + } + segment.name = index_name.to_string(); + segment.dataset_version = self.manifest.version; + new_indices.push(segment); + } let transaction = Transaction::new( self.manifest.version, @@ -2113,6 +2135,40 @@ mod tests { use rstest::rstest; use std::collections::HashSet; + async fn write_vector_segment_metadata( + dataset: &Dataset, + index_name: &str, + field_id: i32, + uuid: Uuid, + fragment_bitmap: impl IntoIterator, + payload: &[u8], + ) -> IndexMetadata { + let index_path = dataset + .indices_dir() + .child(uuid.to_string()) + .child(INDEX_FILE_NAME); + dataset + .object_store() + .put(&index_path, payload) + .await + .unwrap(); + IndexMetadata { + uuid, + name: index_name.to_string(), + fields: vec![field_id], + dataset_version: dataset.manifest.version, + fragment_bitmap: Some(fragment_bitmap.into_iter().collect()), + index_details: Some(Arc::new(vector_index_details())), + index_version: IndexType::Vector.version(), + created_at: Some(chrono::Utc::now()), + base_id: None, + files: Some(vec![lance_table::format::IndexFile { + path: INDEX_FILE_NAME.to_string(), + size_bytes: payload.len() as u64, + }]), + } + } + #[tokio::test] async fn test_recreate_index() { const DIM: i32 = 8; @@ -5293,36 +5349,25 @@ mod tests { .await .unwrap(); - let seg0 = IndexSegment::new( + let field_id = dataset.schema().field("vector").unwrap().id; + let seg0 = write_vector_segment_metadata( + &dataset, + "vector_idx", + field_id, Uuid::new_v4(), - std::iter::once(0_u32), - Arc::new(vector_index_details()), - IndexType::Vector.version(), - ); - let seg1 = IndexSegment::new( + [0_u32], + b"seg0", + ) + .await; + let seg1 = write_vector_segment_metadata( + &dataset, + "vector_idx", + field_id, Uuid::new_v4(), - std::iter::once(1_u32), - Arc::new(vector_index_details()), - IndexType::Vector.version(), - ); - let seg0_path = dataset - .indices_dir() - .child(seg0.uuid().to_string()) - .child(INDEX_FILE_NAME); - let seg1_path = dataset - .indices_dir() - .child(seg1.uuid().to_string()) - .child(INDEX_FILE_NAME); - dataset - .object_store() - .put(&seg0_path, b"seg0") - .await - .unwrap(); - dataset - .object_store() - .put(&seg1_path, b"seg1") - .await - .unwrap(); + [1_u32], + b"seg1", + ) + .await; dataset .commit_existing_index_segments( @@ -5338,7 +5383,7 @@ mod tests { let committed_uuids = committed.iter().map(|idx| idx.uuid).collect::>(); assert_eq!( committed_uuids, - HashSet::from([seg0.uuid(), seg1.uuid()]), + HashSet::from([seg0.uuid, seg1.uuid]), "all committed segment uuids should be preserved" ); assert_eq!( @@ -5379,12 +5424,16 @@ mod tests { let mut dataset = Dataset::write(reader, test_uri, None).await.unwrap(); - let base = IndexSegment::new( + let field_id = dataset.schema().field("vector").unwrap().id; + let base = write_vector_segment_metadata( + &dataset, + "vector_idx", + field_id, Uuid::new_v4(), - std::iter::once(0_u32), - Arc::new(vector_index_details()), - IndexType::Vector.version(), - ); + [0_u32], + b"base", + ) + .await; let err = dataset .commit_existing_index_segments( @@ -5392,12 +5441,10 @@ mod tests { "vector", vec![ base.clone(), - IndexSegment::new( - base.uuid(), - std::iter::once(1_u32), - Arc::new(vector_index_details()), - IndexType::Vector.version(), - ), + IndexMetadata { + fragment_bitmap: Some(std::iter::once(1_u32).collect()), + ..base + }, ], ) .await @@ -5446,25 +5493,28 @@ mod tests { let mut dataset = Dataset::write(reader, test_uri, None).await.unwrap(); + let field_id = dataset.schema().field("vector").unwrap().id; + let seg0 = write_vector_segment_metadata( + &dataset, + "vector_idx", + field_id, + Uuid::new_v4(), + [0_u32, 1_u32], + b"seg0", + ) + .await; + let seg1 = write_vector_segment_metadata( + &dataset, + "vector_idx", + field_id, + Uuid::new_v4(), + [1_u32], + b"seg1", + ) + .await; + let err = dataset - .commit_existing_index_segments( - "vector_idx", - "vector", - vec![ - IndexSegment::new( - Uuid::new_v4(), - [0_u32, 1_u32], - Arc::new(vector_index_details()), - IndexType::Vector.version(), - ), - IndexSegment::new( - Uuid::new_v4(), - [1_u32], - Arc::new(vector_index_details()), - IndexType::Vector.version(), - ), - ], - ) + .commit_existing_index_segments("vector_idx", "vector", vec![seg0, seg1]) .await .unwrap_err(); assert!(err.to_string().contains("overlapping fragment coverage")); diff --git a/rust/lance/src/index/api.rs b/rust/lance/src/index/api.rs index f8e7ee7d012..b42c10ff1dc 100644 --- a/rust/lance/src/index/api.rs +++ b/rust/lance/src/index/api.rs @@ -7,133 +7,13 @@ use async_trait::async_trait; use datafusion::execution::SendableRecordBatchStream; use lance_index::{IndexParams, IndexType, optimize::OptimizeOptions}; use lance_table::format::IndexMetadata; -use roaring::RoaringBitmap; -use uuid::Uuid; use crate::{Error, Result}; -/// A single physical segment of a logical index. -/// -/// Each segment is stored independently and will become one manifest entry when committed. -/// The logical index identity (name / target column / dataset version) is provided separately -/// by the commit API. -#[derive(Debug, Clone, PartialEq)] -pub struct IndexSegment { - /// Unique ID of the physical segment. - uuid: Uuid, - /// The fragments covered by this segment. - fragment_bitmap: RoaringBitmap, - /// Metadata specific to the index type. - index_details: Arc, - /// The on-disk index version for this segment. - index_version: i32, -} - -impl IndexSegment { - /// Create a fully described segment with the given UUID, fragment coverage, and index - /// metadata. - pub fn new( - uuid: Uuid, - fragment_bitmap: I, - index_details: Arc, - index_version: i32, - ) -> Self - where - I: IntoIterator, - { - Self { - uuid, - fragment_bitmap: fragment_bitmap.into_iter().collect(), - index_details, - index_version, - } - } - - /// Return the UUID of this segment. - pub fn uuid(&self) -> Uuid { - self.uuid - } - - /// Return the fragment coverage of this segment. - pub fn fragment_bitmap(&self) -> &RoaringBitmap { - &self.fragment_bitmap - } - - /// Return the serialized index details for this segment. - pub fn index_details(&self) -> &Arc { - &self.index_details - } - - /// Return the on-disk index version for this segment. - pub fn index_version(&self) -> i32 { - self.index_version - } - - /// Consume the segment and return its component parts. - pub fn into_parts(self) -> (Uuid, RoaringBitmap, Arc, i32) { - ( - self.uuid, - self.fragment_bitmap, - self.index_details, - self.index_version, - ) - } -} - -/// A plan for building one physical segment from one or more existing -/// vector index segments. -#[derive(Debug, Clone, PartialEq)] -pub struct IndexSegmentPlan { - segment: IndexSegment, - segments: Vec, - estimated_bytes: u64, - requested_index_type: Option, -} - -impl IndexSegmentPlan { - /// Create a plan for one built segment. - pub fn new( - segment: IndexSegment, - segments: Vec, - estimated_bytes: u64, - requested_index_type: Option, - ) -> Self { - Self { - segment, - segments, - estimated_bytes, - requested_index_type, - } - } - - /// Return the segment metadata that should be committed after this plan is built. - pub fn segment(&self) -> &IndexSegment { - &self.segment - } - - /// Return the input segment metadata that should be combined into the segment. - pub fn segments(&self) -> &[IndexMetadata] { - &self.segments - } - - /// Return the estimated number of bytes covered by this plan. - pub fn estimated_bytes(&self) -> u64 { - self.estimated_bytes - } - - /// Return the requested logical index type, if one was supplied to the planner. - pub fn requested_index_type(&self) -> Option { - self.requested_index_type - } -} - /// Extends [`crate::Dataset`] with secondary index APIs. #[async_trait] pub trait DatasetIndexExt { type IndexBuilder<'a> - where - Self: 'a; - type IndexSegmentBuilder<'a> where Self: 'a; @@ -148,18 +28,6 @@ pub trait DatasetIndexExt { params: &'a dyn IndexParams, ) -> Self::IndexBuilder<'a>; - /// Create a builder for building physical index segments from uncommitted - /// vector index outputs. - /// - /// The caller supplies the uncommitted index metadata returned by - /// `execute_uncommitted()` so the builder can plan segment grouping without - /// rediscovering fragment coverage. - /// - /// This is the canonical entry point for distributed vector segment build. - /// After building the physical segments, publish them as a - /// logical index with [`Self::commit_existing_index_segments`]. - fn create_index_segment_builder<'a>(&'a self) -> Self::IndexSegmentBuilder<'a>; - /// Create indices on columns. /// /// Upon finish, a new dataset version is generated. @@ -251,12 +119,19 @@ pub trait DatasetIndexExt { /// Find an index with the given name and return its serialized statistics. async fn index_statistics(&self, index_name: &str) -> Result; + /// Merge one caller-defined group of existing uncommitted index segments into a + /// single segment. + async fn merge_existing_index_segments( + &self, + segments: Vec, + ) -> Result; + /// Commit one or more existing physical index segments as a logical index. async fn commit_existing_index_segments( &mut self, index_name: &str, column: &str, - segments: Vec, + segments: Vec, ) -> Result<()>; async fn read_index_partition( @@ -266,29 +141,3 @@ pub trait DatasetIndexExt { with_vector: bool, ) -> Result; } - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use super::{IndexSegment, IndexSegmentPlan}; - use lance_index::IndexType; - use uuid::Uuid; - - #[test] - fn test_index_segment_plan_accessors() { - let uuid = Uuid::new_v4(); - let segment = IndexSegment::new(uuid, [1_u32, 3], Arc::new(prost_types::Any::default()), 7); - let plan = IndexSegmentPlan::new(segment.clone(), vec![], 128, Some(IndexType::BTree)); - - assert_eq!(segment.uuid(), uuid); - assert_eq!( - segment.fragment_bitmap().iter().collect::>(), - vec![1, 3] - ); - assert_eq!(segment.index_version(), 7); - assert_eq!(plan.segment().uuid(), uuid); - assert_eq!(plan.estimated_bytes(), 128); - assert_eq!(plan.requested_index_type(), Some(IndexType::BTree)); - } -} diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index 7403e2b24c9..73407ef8d69 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -8,7 +8,7 @@ use crate::{ transaction::{Operation, TransactionBuilder}, }, index::{ - DatasetIndexExt, DatasetIndexInternalExt, build_index_metadata_from_segments, + DatasetIndexExt, DatasetIndexInternalExt, scalar::build_scalar_index, vector::{ LANCE_VECTOR_INDEX, VectorIndexParams, build_distributed_vector_index, @@ -17,7 +17,7 @@ use crate::{ vector_index_details, }, }; -use futures::future::{BoxFuture, try_join_all}; +use futures::future::BoxFuture; use lance_core::datatypes::format_field_path; use lance_index::progress::{IndexBuildProgress, NoopIndexBuildProgress}; use lance_index::{IndexParams, IndexType, scalar::CreatedIndex}; @@ -32,8 +32,6 @@ use uuid::Uuid; use arrow_array::RecordBatchReader; -use super::{IndexSegment, IndexSegmentPlan}; - /// Generate default index name from field path. /// /// Joins field names with `.` to create the base index name. @@ -476,42 +474,15 @@ impl<'a> CreateIndexBuilder<'a> { } else { vec![] }; - let transaction = if uses_segment_commit_path(self.index_type) { - let field_id = *new_idx.fields.first().ok_or_else(|| { - Error::internal(format!( - "Index '{}' is missing field ids after build", - new_idx.name - )) - })?; - let segments = self - .dataset - .create_index_segment_builder() - .with_segments(vec![new_idx.clone()]) - .build_all() - .await?; - let new_indices = - build_index_metadata_from_segments(self.dataset, &new_idx.name, field_id, segments) - .await?; - TransactionBuilder::new( - new_idx.dataset_version, - Operation::CreateIndex { - new_indices, - removed_indices, - }, - ) - .transaction_properties(self.transaction_properties.clone()) - .build() - } else { - TransactionBuilder::new( - new_idx.dataset_version, - Operation::CreateIndex { - new_indices: vec![new_idx], - removed_indices, - }, - ) - .transaction_properties(self.transaction_properties.clone()) - .build() - }; + let transaction = TransactionBuilder::new( + new_idx.dataset_version, + Operation::CreateIndex { + new_indices: vec![new_idx], + removed_indices, + }, + ) + .transaction_properties(self.transaction_properties.clone()) + .build(); self.dataset .apply_commit(transaction, &Default::default(), &Default::default()) @@ -533,20 +504,6 @@ impl<'a> CreateIndexBuilder<'a> { } } -fn uses_segment_commit_path(index_type: IndexType) -> bool { - matches!( - index_type, - IndexType::Vector - | IndexType::IvfPq - | IndexType::IvfSq - | IndexType::IvfFlat - | IndexType::IvfRq - | IndexType::IvfHnswFlat - | IndexType::IvfHnswPq - | IndexType::IvfHnswSq - ) -} - impl<'a> IntoFuture for CreateIndexBuilder<'a> { type Output = Result; type IntoFuture = BoxFuture<'a, Result>; @@ -556,82 +513,6 @@ impl<'a> IntoFuture for CreateIndexBuilder<'a> { } } -/// Build physical index segments from previously-written vector segment outputs. -/// -/// Use [`DatasetIndexExt::create_index_segment_builder`] and then either: -/// -/// - call [`Self::plan`] and orchestrate individual segment builds externally, or -/// - call [`Self::build_all`] to build all segments on the current node. -/// -/// This builder only builds physical segments. Publishing those segments as -/// a logical index still requires [`DatasetIndexExt::commit_existing_index_segments`]. -/// Together these two APIs form the canonical distributed vector segment build workflow. -#[derive(Clone)] -pub struct IndexSegmentBuilder<'a> { - dataset: &'a Dataset, - segments: Vec, - target_segment_bytes: Option, -} - -impl<'a> IndexSegmentBuilder<'a> { - pub(crate) fn new(dataset: &'a Dataset) -> Self { - Self { - dataset, - segments: Vec::new(), - target_segment_bytes: None, - } - } - - /// Provide the segment metadata returned by `execute_uncommitted()`. - /// - /// These segments must already exist in storage and must not have been - /// published into a logical index yet. - pub fn with_segments(mut self, segments: Vec) -> Self { - self.segments = segments; - self - } - - /// Set the target size, in bytes, for merged physical segments. - /// - /// When set, input segments will be grouped into larger physical segments - /// up to approximately this size. When unset, each input segment becomes - /// one physical segment. - pub fn with_target_segment_bytes(mut self, bytes: u64) -> Self { - self.target_segment_bytes = Some(bytes); - self - } - - /// Plan how input segments should be grouped into physical segments. - pub async fn plan(&self) -> Result> { - if self.segments.is_empty() { - return Err(Error::invalid_input( - "IndexSegmentBuilder requires at least one segment; \ - call with_segments(...) with execute_uncommitted() outputs" - .to_string(), - )); - } - - crate::index::vector::ivf::plan_segments(&self.segments, None, self.target_segment_bytes) - .await - } - - /// Build one segment from a previously-generated plan. - pub async fn build(&self, plan: &IndexSegmentPlan) -> Result { - crate::index::vector::ivf::build_segment( - self.dataset.object_store(), - &self.dataset.indices_dir(), - plan, - ) - .await - } - - /// Plan and build all segments from the provided inputs. - pub async fn build_all(&self) -> Result> { - let plans = self.plan().await?; - try_join_all(plans.iter().map(|plan| self.build(plan))).await - } -} - #[cfg(test)] mod tests { use super::*; @@ -1152,27 +1033,8 @@ mod tests { input_segments.push(segment); } - let segments = dataset - .create_index_segment_builder() - .with_segments(input_segments.clone()) - .build_all() - .await - .unwrap(); - assert_eq!(segments.len(), fragments.len()); - let mut built_segment_ids = segments - .iter() - .map(|segment| segment.uuid()) - .collect::>(); - built_segment_ids.sort(); - let mut input_segment_ids = input_segments - .iter() - .map(|segment| segment.uuid) - .collect::>(); - input_segment_ids.sort(); - assert_eq!(built_segment_ids, input_segment_ids); - dataset - .commit_existing_index_segments("vector_idx", "vector", segments) + .commit_existing_index_segments("vector_idx", "vector", input_segments) .await .unwrap(); @@ -1202,7 +1064,7 @@ mod tests { } #[tokio::test] - async fn test_index_segment_builder_vector_commits_multi_segment_logical_index() { + async fn test_merge_existing_index_segments_vector_commits_single_logical_index() { let tmpdir = TempStrDir::default(); let dataset_uri = format!("file://{}", tmpdir.as_str()); @@ -1247,21 +1109,18 @@ mod tests { input_segments.push(segment); } - let segments = dataset - .create_index_segment_builder() - .with_segments(input_segments) - .build_all() + let segment = dataset + .merge_existing_index_segments(input_segments) .await .unwrap(); - assert_eq!(segments.len(), 2); dataset - .commit_existing_index_segments("vector_idx", "vector", segments) + .commit_existing_index_segments("vector_idx", "vector", vec![segment]) .await .unwrap(); let indices = dataset.load_indices_by_name("vector_idx").await.unwrap(); - assert_eq!(indices.len(), 2); + assert_eq!(indices.len(), 1); let mut committed_fragment_sets = indices .iter() .map(|metadata| { @@ -1274,7 +1133,7 @@ mod tests { }) .collect::>(); committed_fragment_sets.sort(); - assert_eq!(committed_fragment_sets, vec![vec![0], vec![1]]); + assert_eq!(committed_fragment_sets, vec![vec![0, 1]]); let query_batch = dataset .scan() @@ -1298,6 +1157,65 @@ mod tests { assert!(result.num_rows() > 0); } + #[tokio::test] + async fn test_merge_existing_index_segments_accepts_python_round_tripped_metadata() { + let tmpdir = TempStrDir::default(); + let dataset_uri = format!("file://{}", tmpdir.as_str()); + + let reader = gen_batch() + .col("id", lance_datagen::array::step::()) + .col( + "vector", + lance_datagen::array::rand_vec::(lance_datagen::Dimension::from(16)), + ) + .into_reader_rows( + lance_datagen::RowCount::from(256), + lance_datagen::BatchCount::from(4), + ); + let mut dataset = Dataset::write( + reader, + &dataset_uri, + Some(WriteParams { + max_rows_per_file: 64, + mode: WriteMode::Overwrite, + ..Default::default() + }), + ) + .await + .unwrap(); + + let fragments = dataset.get_fragments(); + assert!(fragments.len() >= 2); + let params = VectorIndexParams::with_ivf_flat_params( + DistanceType::L2, + prepare_vector_ivf(&dataset, "vector").await, + ); + let mut input_segments = Vec::new(); + + for fragment in fragments.iter().take(2) { + let mut segment = + CreateIndexBuilder::new(&mut dataset, &["vector"], IndexType::Vector, ¶ms) + .name("vector_idx".to_string()) + .fragments(vec![fragment.id() as u32]) + .execute_uncommitted() + .await + .unwrap(); + segment.index_details = None; + input_segments.push(segment); + } + + let merged_segment = dataset + .merge_existing_index_segments(input_segments) + .await + .unwrap(); + assert!( + merged_segment + .fragment_bitmap + .as_ref() + .is_some_and(|bitmap| bitmap.iter().collect::>() == vec![0, 1]) + ); + } + #[tokio::test] async fn test_commit_existing_index_supports_local_hnsw_segments() { let tmpdir = TempStrDir::default(); @@ -1332,24 +1250,17 @@ mod tests { HnswBuildParams::default(), ); - CreateIndexBuilder::new(&mut dataset, &["vector"], IndexType::Vector, ¶ms) - .name("vector_idx".to_string()) - .index_uuid(uuid.to_string()) - .execute_uncommitted() - .await - .unwrap(); + let segment = + CreateIndexBuilder::new(&mut dataset, &["vector"], IndexType::Vector, ¶ms) + .name("vector_idx".to_string()) + .index_uuid(uuid.to_string()) + .execute_uncommitted() + .await + .unwrap(); + assert_eq!(segment.uuid, uuid); dataset - .commit_existing_index_segments( - "vector_idx", - "vector", - vec![IndexSegment::new( - uuid, - dataset.fragment_bitmap.as_ref().clone(), - Arc::new(vector_index_details()), - IndexType::IvfHnswFlat.version(), - )], - ) + .commit_existing_index_segments("vector_idx", "vector", vec![segment]) .await .unwrap(); diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 3282256d1b6..ee298df6316 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -89,21 +89,18 @@ use lance_io::{ }; use lance_linalg::distance::{DistanceType, Dot, L2, MetricType}; use lance_linalg::{distance::Normalize, kernels::normalize_fsl_owned}; -use lance_table::format::IndexMetadata as TableIndexMetadata; +use lance_table::format::{IndexMetadata as TableIndexMetadata, list_index_files_with_sizes}; use log::{info, warn}; use object_store::path::Path; use prost::Message; use roaring::RoaringBitmap; use serde::Serialize; use serde_json::json; -use std::collections::HashSet; use std::{any::Any, collections::HashMap, sync::Arc}; use tokio::sync::mpsc; use tracing::instrument; use uuid::Uuid; -use crate::index::{IndexSegment, IndexSegmentPlan}; - pub mod builder; pub mod io; pub mod v2; @@ -1906,181 +1903,66 @@ async fn write_ivf_hnsw_file( Ok(()) } -/// Distributed vector segment build uses three storage-level concepts: -/// -/// - A **segment** is a worker output written by `execute_uncommitted()`. It -/// already lives at its final storage path under `indices//`, -/// but it is not yet published in the manifest. -/// - A **physical segment** is an `IndexSegment` that can be committed into the -/// manifest with `commit_existing_index_segments(...)`. -/// - A **logical index** is the user-visible index identified by name; it may -/// contain one or more physical segments. -/// -/// The segment-build path is therefore: -/// -/// 1. workers build segments -/// 2. the caller groups those segments into one or more physical segments -/// 3. each grouped segment is built from its selected inputs -/// 4. the resulting physical segments are committed as one logical index -/// -/// Each plan says: -/// - which source segments should be consumed together -/// - what the physical segment metadata should look like -/// -/// The planner returns a `Vec` so callers can decide whether -/// to execute the work serially or fan it out externally. -/// -/// This function does not touch storage. It only: -/// - validates that the caller-supplied segment contract is self-consistent -/// - enforces that source fragment coverage is disjoint -/// - groups source segments into physical segments according to -/// `target_segment_bytes` -/// -/// The grouping rule is intentionally simple: -/// - `target_segment_bytes = None`: keep the existing segment boundary, so each -/// input segment becomes one physical segment -/// - `target_segment_bytes = Some(limit)`: greedily pack consecutive source -/// segments until the next source would exceed `limit` -pub(crate) async fn plan_segments( - segments: &[TableIndexMetadata], - requested_index_type: Option, - target_segment_bytes: Option, -) -> Result> { - if let Some(index_type) = requested_index_type - && !matches!( - index_type, - IndexType::IvfFlat - | IndexType::IvfPq - | IndexType::IvfSq - | IndexType::IvfRq - | IndexType::IvfHnswFlat - | IndexType::IvfHnswPq - | IndexType::IvfHnswSq - | IndexType::Vector - ) - { - return Err(Error::invalid_input(format!( - "Unsupported distributed vector segment build type: {}", - index_type - ))); - } - - if let Some(0) = target_segment_bytes { - return Err(Error::invalid_input( - "target_segment_bytes must be greater than zero".to_string(), - )); - } - +/// Merge one caller-defined group of source segments into a single segment. +pub(crate) async fn merge_segments( + object_store: &ObjectStore, + indices_dir: &Path, + segments: Vec, +) -> Result { if segments.is_empty() { return Err(Error::index("No segment metadata was provided".to_string())); } - - let mut sorted_segments = segments.to_vec(); - sorted_segments.sort_by_key(|index| index.uuid); - let mut expected_segment_ids = HashSet::with_capacity(sorted_segments.len()); - for segment in &sorted_segments { - if !expected_segment_ids.insert(segment.uuid) { - return Err(Error::index(format!( - "Distributed vector segment '{}' was provided more than once", - segment.uuid - ))); - } + if segments.len() == 1 { + return Ok(segments.into_iter().next().unwrap()); } - let mut covered_fragments = RoaringBitmap::new(); - for segment in &sorted_segments { - let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { + let mut merged_segment = segments[0].clone(); + let mut fragment_bitmap = RoaringBitmap::new(); + for segment in &segments { + let source_fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { Error::index(format!( "Segment '{}' is missing fragment coverage", segment.uuid )) })?; - if covered_fragments.intersection_len(fragment_bitmap) > 0 { - return Err(Error::index( - "Distributed vector shards have overlapping fragment coverage".to_string(), - )); - } - covered_fragments |= fragment_bitmap.clone(); - } - - if target_segment_bytes.is_none() { - return sorted_segments - .into_iter() - .map(|segment| build_segment_plan(vec![segment], requested_index_type)) - .collect(); - } - - let target_segment_bytes = target_segment_bytes.unwrap(); - let mut plans = Vec::new(); - let mut current_group = Vec::new(); - let mut current_bytes = 0_u64; - - for segment in sorted_segments { - let source_bytes = estimate_source_index_bytes(&segment); - if !current_group.is_empty() - && current_bytes.saturating_add(source_bytes) > target_segment_bytes - { - plans.push(build_segment_plan( - std::mem::take(&mut current_group), - requested_index_type, - )?); - current_bytes = 0; - } - current_bytes = current_bytes.saturating_add(source_bytes); - current_group.push(segment); - } - - if !current_group.is_empty() { - plans.push(build_segment_plan(current_group, requested_index_type)?); - } - - Ok(plans) -} - -/// Build one planned segment into its output directory. -/// -/// Single-source plans are already materialized and return immediately. For -/// multi-source plans, this function writes a new merged physical segment under -/// `indices//`. -pub(crate) async fn build_segment( - object_store: &ObjectStore, - indices_dir: &Path, - segment_plan: &IndexSegmentPlan, -) -> Result { - let built_segment = segment_plan.segment().clone(); - let segments = segment_plan.segments(); - debug_assert!( - !segments.is_empty(), - "segment plans must have at least one source segment" - ); - - if segments.len() == 1 && segments[0].uuid == built_segment.uuid() { - return Ok(built_segment); + fragment_bitmap |= source_fragment_bitmap.clone(); } - let final_dir = indices_dir.child(built_segment.uuid().to_string()); - merge_segments_to_dir(object_store, indices_dir, &final_dir, segment_plan).await?; + let index_version = infer_source_index_version(&segments)?; + let segment_uuid = Uuid::new_v4(); + let final_dir = indices_dir.child(segment_uuid.to_string()); + merge_segments_to_dir(object_store, indices_dir, &final_dir, &segments).await?; + let files = list_index_files_with_sizes(object_store, &final_dir).await?; - Ok(built_segment) + merged_segment = TableIndexMetadata { + uuid: segment_uuid, + fragment_bitmap: Some(fragment_bitmap), + index_details: Some(Arc::new(crate::index::vector_index_details())), + index_version, + created_at: Some(chrono::Utc::now()), + base_id: None, + files: Some(files), + ..merged_segment + }; + Ok(merged_segment) } /// Merge the selected input segments into `final_dir`. /// -/// Callers must only invoke this helper for multi-source plans. It reads the -/// selected input segments directly from `indices//` and writes -/// the merged auxiliary/index files into `final_dir`. +/// The caller defines the source segment group explicitly. This helper reads +/// those input segments directly from `indices//` and writes the +/// merged auxiliary/index files into `final_dir`. async fn merge_segments_to_dir( object_store: &ObjectStore, indices_dir: &Path, final_dir: &Path, - segment_plan: &IndexSegmentPlan, + segments: &[TableIndexMetadata], ) -> Result<()> { reset_final_segment_dir(object_store, final_dir).await?; - let segments = segment_plan.segments(); debug_assert!( segments.len() > 1, - "merge helper should only be used for multi-source plans" + "merge helper should only be used for multi-source groups" ); let aux_paths = segments @@ -2106,64 +1988,12 @@ async fn merge_segments_to_dir( final_dir, ) .await?; - write_root_vector_index_from_auxiliary( - object_store, - final_dir, - segment_plan.requested_index_type(), - &source_index_paths, - ) - .await?; + write_root_vector_index_from_auxiliary(object_store, final_dir, None, &source_index_paths) + .await?; Ok(()) } -/// Collapse one group of source segments into a single physical-segment plan. -fn build_segment_plan( - group: Vec, - requested_index_type: Option, -) -> Result { - debug_assert!(!group.is_empty()); - let first = &group[0]; - let mut fragment_bitmap = RoaringBitmap::new(); - let mut estimated_bytes = 0_u64; - let mut segments = Vec::with_capacity(group.len()); - - for segment in &group { - let source_fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { - Error::index(format!( - "Segment '{}' is missing fragment coverage", - segment.uuid - )) - })?; - fragment_bitmap |= source_fragment_bitmap.clone(); - estimated_bytes = estimated_bytes.saturating_add(estimate_source_index_bytes(segment)); - segments.push(segment.clone()); - } - - let segment_uuid = if group.len() == 1 { - first.uuid - } else { - Uuid::new_v4() - }; - let index_version = match requested_index_type { - Some(index_type) => index_type.version(), - None => infer_source_index_version(&group)?, - }; - let segment = IndexSegment::new( - segment_uuid, - fragment_bitmap, - Arc::new(crate::index::vector_index_details()), - index_version, - ); - - Ok(IndexSegmentPlan::new( - segment, - segments, - estimated_bytes, - requested_index_type, - )) -} - fn infer_source_index_version(group: &[TableIndexMetadata]) -> Result { debug_assert!(!group.is_empty()); let first = group[0].index_version; @@ -2175,14 +2005,6 @@ fn infer_source_index_version(group: &[TableIndexMetadata]) -> Result { Ok(first) } -fn estimate_source_index_bytes(index_metadata: &TableIndexMetadata) -> u64 { - index_metadata - .files - .as_ref() - .map(|files| files.iter().map(|file| file.size_bytes).sum()) - .unwrap_or(0) -} - /// Best-effort reset of one target directory before rewriting it. async fn reset_final_segment_dir(object_store: &ObjectStore, final_dir: &Path) -> Result<()> { match object_store.remove_dir_all(final_dir.clone()).await { diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index ceeee44f839..9143a5b12fc 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -634,9 +634,7 @@ mod tests { use crate::dataset::{InsertBuilder, UpdateBuilder, WriteMode, WriteParams}; use crate::index::DatasetIndexExt; use crate::index::DatasetIndexInternalExt; - use crate::index::IndexSegment; use crate::index::vector::ivf::v2::IvfPq; - use crate::index::vector::ivf::{build_segment, plan_segments}; use crate::utils::test::copy_test_data_to_tmp; use crate::{ Dataset, @@ -1588,8 +1586,8 @@ mod tests { let segments = build_segments_for_fragment_groups(dataset, fragment_groups, ¶ms, index_name).await; - let built_segments = build_distributed_segments(dataset, &segments, None, index_name).await; - assert!(!built_segments.is_empty()); + let committed_segments = build_distributed_segments(dataset, segments, index_name).await; + assert!(!committed_segments.is_empty()); } fn assert_centroids_equal(reference: &serde_json::Value, candidate: &serde_json::Value) { @@ -1665,32 +1663,17 @@ mod tests { assert_eq!(sizes_a, sizes_b, "aggregated partition sizes mismatch"); } - /// Execute the internal segment workflow used by the - /// regression tests: plan segment groups from caller-provided segment - /// metadata, build each segment, and publish them as one logical index. + /// Commit caller-defined segment groups as one logical index. async fn build_distributed_segments( dataset: &mut Dataset, - segments: &[IndexMetadata], - target_segment_bytes: Option, + segments: Vec, index_name: &str, - ) -> Vec { - let segment_plans = plan_segments(segments, None, target_segment_bytes) - .await - .unwrap(); - let mut built_segments = Vec::with_capacity(segment_plans.len()); - for plan in &segment_plans { - built_segments.push( - build_segment(dataset.object_store(), &dataset.indices_dir(), plan) - .await - .unwrap(), - ); - } + ) -> Vec { dataset - .commit_existing_index_segments(index_name, "vector", built_segments.clone()) + .commit_existing_index_segments(index_name, "vector", segments.clone()) .await .unwrap(); - - built_segments + segments } #[tokio::test] @@ -1901,12 +1884,12 @@ mod tests { INDEX_NAME, ) .await; - let segments = build_distributed_segments(&mut ds_split, &segments, None, INDEX_NAME).await; + let segments = build_distributed_segments(&mut ds_split, segments, INDEX_NAME).await; assert_eq!(segments.len(), expected_segment_count); for segment in &segments { let segment_index = ds_split .indices_dir() - .child(segment.uuid().to_string()) + .child(segment.uuid.to_string()) .child(crate::index::INDEX_FILE_NAME); assert!( ds_split @@ -2056,29 +2039,15 @@ mod tests { ) .await; - let shard_plan = plan_segments(&segments, None, None).await.unwrap(); - let shard_count = shard_plan.len(); - assert!(shard_count >= 4); - // Use the max segment size as the base for the target. Segment sizes can - // vary slightly because compression is data-dependent and the random - // vectors in each fragment compress differently. Segments are sorted by - // UUID (random), so shard_plan[0] is not guaranteed to be the largest. - let max_shard_bytes = shard_plan - .iter() - .map(|p| p.estimated_bytes()) - .max() - .unwrap(); - let target_segment_bytes = max_shard_bytes.saturating_mul(2); - - let grouped_plan = plan_segments(&segments, None, Some(target_segment_bytes)) - .await - .unwrap(); - assert!(grouped_plan.len() < shard_count); - assert!(grouped_plan.iter().any(|plan| plan.segments().len() > 1)); - let mut expected_fragment_coverage = grouped_plan + assert!(segments.len() >= 4); + let grouped_inputs = segments + .chunks(2) + .map(|group| group.to_vec()) + .collect::>(); + let mut expected_fragment_coverage = grouped_inputs .iter() - .map(|plan| { - plan.segments() + .map(|group| { + group .iter() .flat_map(|partial| { partial @@ -2093,17 +2062,26 @@ mod tests { .collect::>(); expected_fragment_coverage.sort(); - let grouped_segments = build_distributed_segments( - &mut ds_split, - &segments, - Some(target_segment_bytes), - INDEX_NAME, + let grouped_segments = futures::future::try_join_all( + grouped_inputs + .into_iter() + .map(|group| ds_split.merge_existing_index_segments(group)), ) - .await; - assert_eq!(grouped_segments.len(), grouped_plan.len()); + .await + .unwrap(); + let grouped_segments = + build_distributed_segments(&mut ds_split, grouped_segments, INDEX_NAME).await; + assert_eq!(grouped_segments.len(), expected_fragment_coverage.len()); let mut actual_fragment_coverage = grouped_segments .iter() - .map(|segment| segment.fragment_bitmap().iter().collect::>()) + .map(|segment| { + segment + .fragment_bitmap + .as_ref() + .unwrap() + .iter() + .collect::>() + }) .collect::>(); actual_fragment_coverage.sort(); assert_eq!( @@ -2197,7 +2175,10 @@ mod tests { segments.push(segment); } - let err = plan_segments(&segments, None, None).await.unwrap_err(); + let err = dataset + .merge_existing_index_segments(segments) + .await + .unwrap_err(); assert!(err.to_string().contains("overlapping fragment coverage")); } @@ -2229,19 +2210,6 @@ mod tests { segments.push(segment); } - let plans = plan_segments(&segments, None, Some(1)).await.unwrap(); - assert_eq!(plans.len(), fragments.iter().take(2).count()); - - let mut segments = Vec::with_capacity(plans.len()); - for plan in &plans { - segments.push( - build_segment(dataset.object_store(), &dataset.indices_dir(), plan) - .await - .unwrap(), - ); - } - assert_eq!(segments.len(), plans.len()); - dataset .commit_existing_index_segments("vector_idx", "vector", segments) .await From 21ae66b2e74802d3aca0073ec0a9c1250db114fe Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Wed, 1 Apr 2026 06:04:20 -0700 Subject: [PATCH 118/206] feat: add an arrow-stats crate with the ability to calculate basic stats on arrays (#5967) We currently have stats spread out in a few places and they all require Datafusion which is a kind of heavy dependency. With the addition of column statistics (https://github.com/lance-format/lance/pull/5639) we were going to get yet another implementation. Instead I have made a simple standalone stats crate which does not require Datafusion. --------- Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 13 + Cargo.toml | 2 + rust/arrow-stats/Cargo.toml | 26 + rust/arrow-stats/README.md | 62 + rust/arrow-stats/proptest-regressions/lib.txt | 8 + rust/arrow-stats/src/lib.rs | 1294 +++++++++++++++++ rust/arrow-stats/src/nan.rs | 32 + 7 files changed, 1437 insertions(+) create mode 100644 rust/arrow-stats/Cargo.toml create mode 100644 rust/arrow-stats/README.md create mode 100644 rust/arrow-stats/proptest-regressions/lib.txt create mode 100644 rust/arrow-stats/src/lib.rs create mode 100644 rust/arrow-stats/src/nan.rs diff --git a/Cargo.lock b/Cargo.lock index 1389b0f6a97..766225a438e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4720,6 +4720,19 @@ dependencies = [ "rstest", ] +[[package]] +name = "lance-arrow-stats" +version = "57.0.0" +dependencies = [ + "arrow-array", + "arrow-schema", + "arrow-select", + "half", + "lance-arrow-scalar", + "proptest", + "rstest", +] + [[package]] name = "lance-bitpacking" version = "5.0.0-beta.2" diff --git a/Cargo.toml b/Cargo.toml index 527ad080889..5a69fdd24d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "rust/compression/fsst", "rust/compression/bitpacking", "rust/arrow-scalar", + "rust/arrow-stats", ] exclude = ["python", "java/lance-jni"] # Python package needs to be built by maturin. @@ -74,6 +75,7 @@ approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } lance-arrow-scalar = { version = "=57.0.0", path = "./rust/arrow-scalar" } +lance-arrow-stats = { version = "=57.0.0", path = "./rust/arrow-stats" } arrow-arith = "57.0.0" arrow-array = "57.0.0" arrow-buffer = "57.0.0" diff --git a/rust/arrow-stats/Cargo.toml b/rust/arrow-stats/Cargo.toml new file mode 100644 index 00000000000..e57ea681034 --- /dev/null +++ b/rust/arrow-stats/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "lance-arrow-stats" +version = "57.0.0" +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Statistics accumulator for Arrow arrays (min, max, null_count, nan_count)" +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true +readme = "README.md" + +[dependencies] +arrow-array = { workspace = true } +arrow-schema = { workspace = true } +lance-arrow-scalar = { workspace = true } +half = { workspace = true } + +[dev-dependencies] +arrow-select = { workspace = true } +proptest = { workspace = true } +rstest = { workspace = true } + +[lints] +workspace = true diff --git a/rust/arrow-stats/README.md b/rust/arrow-stats/README.md new file mode 100644 index 00000000000..553f6de2e69 --- /dev/null +++ b/rust/arrow-stats/README.md @@ -0,0 +1,62 @@ +# lance-arrow-stats + +Statistics accumulator for [Apache Arrow](https://arrow.apache.org/) arrays. + +Computes min, max, null count, NaN count, and buffer memory usage over one or +more batches of Arrow data. Designed for use in Lance's columnar storage layer +where page-level statistics drive predicate pushdown and query planning. + +## Usage + +```rust +use arrow_array::{Int32Array, ArrayRef}; +use lance_arrow_stats::StatisticsAccumulator; +use arrow_schema::DataType; +use std::sync::Arc; + +let mut acc = StatisticsAccumulator::new(&DataType::Int32); + +let batch: ArrayRef = Arc::new(Int32Array::from(vec![Some(3), None, Some(1), Some(4)])); +acc.update(&batch).unwrap(); + +let stats = acc.finish(); +assert_eq!(stats.null_count, 1); +``` + +## Tracked Statistics + +| Statistic | Description | +| --------------- | -------------------------------------------------------- | +| `min` | Minimum non-null, non-NaN value (`ArrowScalar`) | +| `max` | Maximum non-null, non-NaN value (`ArrowScalar`) | +| `null_count` | Total number of null values | +| `nan_count` | Total NaN values (float and float-list types only) | +| `item_nulls` | Null items inside list entries (list types only) | +| `buffer_memory` | Total Arrow buffer memory in bytes | + +## Supported Types + +- **Numeric** — Int8–Int64, UInt8–UInt64, Float16/32/64 +- **Temporal** — Date32/64, Time32/64, Timestamp, Duration +- **Boolean** +- **String** — Utf8, LargeUtf8 +- **Binary** — Binary, LargeBinary +- **List** — List, LargeList, FixedSizeList (computes stats over items) + +Dictionary, run-end encoded, and view types are accepted but min/max will be +`None`. + +## Merging + +Accumulators of the same data type can be merged, which is useful for combining +statistics computed in parallel across different pages or files: + +```rust +use lance_arrow_stats::StatisticsAccumulator; +use arrow_schema::DataType; + +let mut a = StatisticsAccumulator::new(&DataType::Float32); +let mut b = StatisticsAccumulator::new(&DataType::Float32); +// ... update each with different batches ... +a.merge(&b).unwrap(); +``` diff --git a/rust/arrow-stats/proptest-regressions/lib.txt b/rust/arrow-stats/proptest-regressions/lib.txt new file mode 100644 index 00000000000..8794b465e9c --- /dev/null +++ b/rust/arrow-stats/proptest-regressions/lib.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 81b0445f36fa8f491c1fb3162f51b61c8be140d5b2a1e792c42b4bdb7f1b6a62 # shrinks to values = [0.0, -0.0] +cc 8651fce939497f33c6dafd842937d95965af97833bfbbd10df30d5ea00dbd07d # shrinks to values = [Some(0.0), Some(-0.0)] diff --git a/rust/arrow-stats/src/lib.rs b/rust/arrow-stats/src/lib.rs new file mode 100644 index 00000000000..5c00a015749 --- /dev/null +++ b/rust/arrow-stats/src/lib.rs @@ -0,0 +1,1294 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Statistics accumulator for streams of Arrow arrays. +//! +//! Tracks min, max, null_count, and optional nan_count across batches of arrays sharing +//! the same [`DataType`]. Uses [`ArrowScalar`] for extrema tracking. +//! +//! # Example +//! +//! ``` +//! use std::sync::Arc; +//! use arrow_array::{ArrayRef, Int32Array}; +//! use arrow_schema::DataType; +//! use lance_arrow_stats::StatisticsAccumulator; +//! +//! let mut acc = StatisticsAccumulator::new(&DataType::Int32); +//! let array: ArrayRef = Arc::new(Int32Array::from(vec![Some(1), None, Some(3)])); +//! acc.update(&array).unwrap(); +//! +//! let stats = acc.finish(); +//! assert_eq!(stats.null_count, 1); +//! assert!(stats.nan_count.is_none()); +//! ``` +//! +//! # Data Type Support +//! +//! All basic types are supported. Every data type supports `null_count` and `buffer_memory`. +//! The `nan_count` field is `Some` only for floating-point types (including lists of floats) +//! and `None` for all other types. +//! +//! # List Types +//! +//! List types are supported. The `item_nulls` field will be set to the number of null items within list entries. +//! This will be `Some` only for list types. +//! +//! # String Types / Binary Types +//! +//! String & binary types are supported. Binary comparison will be used for string types to calculate the min and the +//! max. This works for ASCII but may be surprising for special characters. For example, "é" would sort after "z". +//! +//! In addition, string view and binary view types are supported. +//! +//! # Unsupported Types +//! +//! Special encodings (dictionary, run end encoded, view types) are not currently fully supported. The min and max will +//! be set to `None` for these types. The `nan_count` will also be `None` unless the underlying +//! type is a floating-point type. +//! +//! Structs are not currently supported. + +mod nan; + +use arrow_array::cast::AsArray; +use arrow_array::types::*; +use arrow_array::{Array, ArrayRef}; +use arrow_schema::{ArrowError, DataType}; +use lance_arrow_scalar::ArrowScalar; + +use nan::count_nans; + +type Result = std::result::Result; + +/// Returns true if the data type can contain NaN values (float primitives +/// or list types whose items are floats). +fn can_have_nan(data_type: &DataType) -> bool { + match data_type { + DataType::Float16 | DataType::Float32 | DataType::Float64 => true, + DataType::List(f) | DataType::LargeList(f) => can_have_nan(f.data_type()), + DataType::FixedSizeList(f, _) => can_have_nan(f.data_type()), + _ => false, + } +} + +/// Accumulated statistics for a stream of Arrow arrays of a single [`DataType`]. +#[derive(Debug, Clone)] +pub struct StatisticsAccumulator { + data_type: DataType, + min: Option, + max: Option, + null_count: u64, + /// Count of NaN values. `Some` only for floating-point types (or lists of floats). + nan_count: Option, + /// Number of null items within list entries. `Some` only for list types. + item_nulls: Option, + buffer_memory: u64, +} + +/// Snapshot of accumulated statistics. +#[derive(Debug, Clone)] +pub struct Statistics { + pub min: Option, + pub max: Option, + pub null_count: u64, + /// Count of NaN values. `None` for non-floating-point types. + pub nan_count: Option, + /// Number of null items within list entries. `None` for non-list types. + pub item_nulls: Option, + /// Total buffer memory in bytes across all arrays seen by this accumulator. + pub buffer_memory: u64, +} + +impl StatisticsAccumulator { + /// Create a new accumulator for arrays of the given data type. + pub fn new(data_type: &DataType) -> Self { + let item_nulls = match data_type { + DataType::List(_) | DataType::LargeList(_) | DataType::FixedSizeList(_, _) => Some(0), + _ => None, + }; + let nan_count = if can_have_nan(data_type) { + Some(0) + } else { + None + }; + Self { + data_type: data_type.clone(), + min: None, + max: None, + null_count: 0, + nan_count, + item_nulls, + buffer_memory: 0, + } + } + + /// Returns the data type this accumulator expects. + pub fn data_type(&self) -> &DataType { + &self.data_type + } + + /// Update with a new batch of values. + pub fn update(&mut self, array: &ArrayRef) -> Result<()> { + if array.data_type() != &self.data_type { + return Err(ArrowError::InvalidArgumentError(format!( + "Type mismatch: expected {:?}, got {:?}", + self.data_type, + array.data_type() + ))); + } + + if array.is_empty() { + return Ok(()); + } + + self.buffer_memory += array.get_buffer_memory_size() as u64; + self.null_count += array.null_count() as u64; + + match array.data_type() { + DataType::List(_) => { + let list = array.as_list::(); + self.update_items( + (0..list.len()) + .filter(|&i| !list.is_null(i)) + .map(|i| list.value(i)), + ) + } + DataType::LargeList(_) => { + let list = array.as_list::(); + self.update_items( + (0..list.len()) + .filter(|&i| !list.is_null(i)) + .map(|i| list.value(i)), + ) + } + DataType::FixedSizeList(_, _) => { + let list = array.as_fixed_size_list(); + self.update_items( + (0..list.len()) + .filter(|&i| !list.is_null(i)) + .map(|i| list.value(i)), + ) + } + _ => { + if let Some(ref mut nan_count) = self.nan_count { + *nan_count += count_nans(array); + } + let (batch_min, batch_max) = find_min_max(array)?; + self.update_min(batch_min); + self.update_max(batch_max); + Ok(()) + } + } + } + + /// Process items from list entries, updating min/max, nan_count, and item_nulls. + fn update_items(&mut self, items: impl Iterator) -> Result<()> { + for item_array in items { + self.update_item(&item_array)?; + } + Ok(()) + } + + /// Process a single item array. If it is itself a list type, recurse into + /// its non-null entries; otherwise treat it as a leaf and compute min/max. + fn update_item(&mut self, item_array: &ArrayRef) -> Result<()> { + if item_array.is_empty() { + return Ok(()); + } + if let Some(ref mut item_nulls) = self.item_nulls { + *item_nulls += item_array.null_count() as u64; + } + match item_array.data_type() { + DataType::List(_) => { + let list = item_array.as_list::(); + for i in 0..list.len() { + if !list.is_null(i) { + self.update_item(&list.value(i))?; + } + } + } + DataType::LargeList(_) => { + let list = item_array.as_list::(); + for i in 0..list.len() { + if !list.is_null(i) { + self.update_item(&list.value(i))?; + } + } + } + DataType::FixedSizeList(_, _) => { + let list = item_array.as_fixed_size_list(); + for i in 0..list.len() { + if !list.is_null(i) { + self.update_item(&list.value(i))?; + } + } + } + _ => { + if let Some(ref mut nan_count) = self.nan_count { + *nan_count += count_nans(item_array); + } + let (batch_min, batch_max) = find_min_max(item_array)?; + self.update_min(batch_min); + self.update_max(batch_max); + } + } + Ok(()) + } + + fn update_min(&mut self, batch_min: Option) { + if let Some(new_min) = batch_min { + self.min = Some(match self.min.take() { + Some(cur) if cur <= new_min => cur, + _ => new_min, + }); + } + } + + fn update_max(&mut self, batch_max: Option) { + if let Some(new_max) = batch_max { + self.max = Some(match self.max.take() { + Some(cur) if cur >= new_max => cur, + _ => new_max, + }); + } + } + + /// Merge another accumulator into this one. + pub fn merge(&mut self, other: &Self) -> Result<()> { + if self.data_type != other.data_type { + return Err(ArrowError::InvalidArgumentError(format!( + "Type mismatch: expected {:?}, got {:?}", + self.data_type, other.data_type + ))); + } + + self.null_count += other.null_count; + if let (Some(a), Some(b)) = (&mut self.nan_count, other.nan_count) { + *a += b; + } + self.buffer_memory += other.buffer_memory; + + if let (Some(a), Some(b)) = (&mut self.item_nulls, other.item_nulls) { + *a += b; + } + + if let Some(ref other_min) = other.min { + self.min = Some(match self.min.take() { + Some(cur) if cur <= *other_min => cur, + _ => other_min.clone(), + }); + } + + if let Some(ref other_max) = other.max { + self.max = Some(match self.max.take() { + Some(cur) if cur >= *other_max => cur, + _ => other_max.clone(), + }); + } + + Ok(()) + } + + /// Consume the accumulator and return a statistics snapshot. + pub fn finish(self) -> Statistics { + Statistics { + min: self.min, + max: self.max, + null_count: self.null_count, + nan_count: self.nan_count, + item_nulls: self.item_nulls, + buffer_memory: self.buffer_memory, + } + } + + /// Return a snapshot of the current statistics without consuming the accumulator. + pub fn statistics(&self) -> Statistics { + Statistics { + min: self.min.clone(), + max: self.max.clone(), + null_count: self.null_count, + nan_count: self.nan_count, + item_nulls: self.item_nulls, + buffer_memory: self.buffer_memory, + } + } + + /// Reset all statistics back to initial state. + pub fn reset(&mut self) { + self.min = None; + self.max = None; + self.null_count = 0; + if let Some(ref mut nan_count) = self.nan_count { + *nan_count = 0; + } + if let Some(ref mut item_nulls) = self.item_nulls { + *item_nulls = 0; + } + self.buffer_memory = 0; + } +} + +macro_rules! find_extrema_primitive { + ($array:expr, $arrow_type:ty) => {{ + let typed = $array.as_primitive::<$arrow_type>(); + let mut min_idx: Option = None; + let mut max_idx: Option = None; + let mut min_val = None; + let mut max_val = None; + for i in 0..typed.len() { + if typed.is_null(i) { + continue; + } + let v = typed.value(i); + if min_val.is_none() || v < *min_val.as_ref().unwrap() { + min_val = Some(v); + min_idx = Some(i); + } + if max_val.is_none() || v > *max_val.as_ref().unwrap() { + max_val = Some(v); + max_idx = Some(i); + } + } + (min_idx, max_idx) + }}; +} + +macro_rules! find_extrema_float { + ($array:expr, $arrow_type:ty) => {{ + let typed = $array.as_primitive::<$arrow_type>(); + let mut min_idx: Option = None; + let mut max_idx: Option = None; + let mut min_val = None; + let mut max_val = None; + for i in 0..typed.len() { + if typed.is_null(i) { + continue; + } + let v = typed.value(i); + if v.is_nan() { + continue; + } + // Use total_cmp for a consistent total ordering that + // distinguishes -0.0 from 0.0 (matching ArrowScalar's Ord). + if min_val.is_none() + || v.total_cmp(min_val.as_ref().unwrap()) == std::cmp::Ordering::Less + { + min_val = Some(v); + min_idx = Some(i); + } + if max_val.is_none() + || v.total_cmp(max_val.as_ref().unwrap()) == std::cmp::Ordering::Greater + { + max_val = Some(v); + max_idx = Some(i); + } + } + (min_idx, max_idx) + }}; +} + +macro_rules! find_extrema_bytes { + ($array:expr, $cast:ident :: < $offset:ty >) => {{ + let typed = $array.$cast::<$offset>(); + let mut min_idx: Option = None; + let mut max_idx: Option = None; + let mut min_val = None; + let mut max_val = None; + for i in 0..typed.len() { + if typed.is_null(i) { + continue; + } + let v = typed.value(i); + if min_val.is_none() || v < min_val.unwrap() { + min_val = Some(v); + min_idx = Some(i); + } + if max_val.is_none() || v > max_val.unwrap() { + max_val = Some(v); + max_idx = Some(i); + } + } + (min_idx, max_idx) + }}; +} + +fn find_min_max(array: &ArrayRef) -> Result<(Option, Option)> { + let (min_idx, max_idx) = find_min_max_indices(array)?; + + let min_scalar = min_idx + .map(|i| ArrowScalar::try_new(array, i)) + .transpose()?; + let max_scalar = max_idx + .map(|i| ArrowScalar::try_new(array, i)) + .transpose()?; + + Ok((min_scalar, max_scalar)) +} + +fn find_min_max_indices(array: &ArrayRef) -> Result<(Option, Option)> { + use DataType::*; + + let result = match array.data_type() { + // Integer types + Int8 => find_extrema_primitive!(array, Int8Type), + Int16 => find_extrema_primitive!(array, Int16Type), + Int32 => find_extrema_primitive!(array, Int32Type), + Int64 => find_extrema_primitive!(array, Int64Type), + UInt8 => find_extrema_primitive!(array, UInt8Type), + UInt16 => find_extrema_primitive!(array, UInt16Type), + UInt32 => find_extrema_primitive!(array, UInt32Type), + UInt64 => find_extrema_primitive!(array, UInt64Type), + + // Float types (skip NaN) + Float16 => find_extrema_float!(array, Float16Type), + Float32 => find_extrema_float!(array, Float32Type), + Float64 => find_extrema_float!(array, Float64Type), + + // Temporal types + Date32 => find_extrema_primitive!(array, Date32Type), + Date64 => find_extrema_primitive!(array, Date64Type), + Time32(arrow_schema::TimeUnit::Second) => { + find_extrema_primitive!(array, Time32SecondType) + } + Time32(arrow_schema::TimeUnit::Millisecond) => { + find_extrema_primitive!(array, Time32MillisecondType) + } + Time64(arrow_schema::TimeUnit::Microsecond) => { + find_extrema_primitive!(array, Time64MicrosecondType) + } + Time64(arrow_schema::TimeUnit::Nanosecond) => { + find_extrema_primitive!(array, Time64NanosecondType) + } + Timestamp(arrow_schema::TimeUnit::Second, _) => { + find_extrema_primitive!(array, TimestampSecondType) + } + Timestamp(arrow_schema::TimeUnit::Millisecond, _) => { + find_extrema_primitive!(array, TimestampMillisecondType) + } + Timestamp(arrow_schema::TimeUnit::Microsecond, _) => { + find_extrema_primitive!(array, TimestampMicrosecondType) + } + Timestamp(arrow_schema::TimeUnit::Nanosecond, _) => { + find_extrema_primitive!(array, TimestampNanosecondType) + } + Duration(arrow_schema::TimeUnit::Second) => { + find_extrema_primitive!(array, DurationSecondType) + } + Duration(arrow_schema::TimeUnit::Millisecond) => { + find_extrema_primitive!(array, DurationMillisecondType) + } + Duration(arrow_schema::TimeUnit::Microsecond) => { + find_extrema_primitive!(array, DurationMicrosecondType) + } + Duration(arrow_schema::TimeUnit::Nanosecond) => { + find_extrema_primitive!(array, DurationNanosecondType) + } + + // Boolean + Boolean => { + let typed = array.as_boolean(); + let mut min_idx: Option = None; + let mut max_idx: Option = None; + let mut min_val: Option = None; + let mut max_val: Option = None; + for i in 0..typed.len() { + if typed.is_null(i) { + continue; + } + let v = typed.value(i); + if min_val.is_none() || (!v && min_val.unwrap()) { + min_val = Some(v); + min_idx = Some(i); + } + if max_val.is_none() || (v && !max_val.unwrap()) { + max_val = Some(v); + max_idx = Some(i); + } + } + (min_idx, max_idx) + } + + // String types + Utf8 => find_extrema_bytes!(array, as_string::), + LargeUtf8 => find_extrema_bytes!(array, as_string::), + + // Binary types + Binary => find_extrema_bytes!(array, as_binary::), + LargeBinary => find_extrema_bytes!(array, as_binary::), + + // For unsupported types we skip min/max (and nan_count). + // null_count and buffer_memory are already tracked above. + _ => return Ok((None, None)), + }; + + Ok(result) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use arrow_array::*; + use arrow_schema::DataType; + use rstest::rstest; + + use super::*; + + #[test] + fn test_empty_array() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Int32Array::from(Vec::::new())); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert!(stats.min.is_none()); + assert!(stats.max.is_none()); + assert_eq!(stats.null_count, 0); + assert_eq!(stats.nan_count, None); + } + + #[test] + fn test_all_nulls() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Int32Array::from(vec![None, None, None])); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert!(stats.min.is_none()); + assert!(stats.max.is_none()); + assert_eq!(stats.null_count, 3); + } + + #[test] + fn test_single_value() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Int32Array::from(vec![42])); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(stats.min.as_ref().unwrap(), stats.max.as_ref().unwrap()); + assert_eq!(format!("{}", stats.min.unwrap()), "42"); + } + + #[test] + fn test_basic_int_stats() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 5, 3, 2, 4])); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "5"); + assert_eq!(stats.null_count, 0); + assert_eq!(stats.nan_count, None); + } + + #[test] + fn test_with_nulls() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Int32Array::from(vec![Some(1), None, Some(3)])); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "3"); + assert_eq!(stats.null_count, 1); + } + + #[test] + fn test_float_nan_excluded() { + let mut acc = StatisticsAccumulator::new(&DataType::Float64); + let array: ArrayRef = Arc::new(Float64Array::from(vec![1.0, f64::NAN, 3.0])); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1.0"); + assert_eq!(format!("{}", stats.max.unwrap()), "3.0"); + assert_eq!(stats.nan_count, Some(1)); + } + + #[test] + fn test_all_nan() { + let mut acc = StatisticsAccumulator::new(&DataType::Float64); + let array: ArrayRef = Arc::new(Float64Array::from(vec![f64::NAN, f64::NAN])); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert!(stats.min.is_none()); + assert!(stats.max.is_none()); + assert_eq!(stats.nan_count, Some(2)); + } + + #[test] + fn test_null_and_nan() { + let mut acc = StatisticsAccumulator::new(&DataType::Float64); + let array: ArrayRef = Arc::new(Float64Array::from(vec![None, Some(f64::NAN)])); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert!(stats.min.is_none()); + assert!(stats.max.is_none()); + assert_eq!(stats.null_count, 1); + assert_eq!(stats.nan_count, Some(1)); + } + + #[test] + fn test_multiple_updates() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let a1: ArrayRef = Arc::new(Int32Array::from(vec![5, 3])); + let a2: ArrayRef = Arc::new(Int32Array::from(vec![Some(1), None, Some(7)])); + acc.update(&a1).unwrap(); + acc.update(&a2).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "7"); + assert_eq!(stats.null_count, 1); + } + + #[test] + fn test_merge() { + let mut acc1 = StatisticsAccumulator::new(&DataType::Int32); + let a1: ArrayRef = Arc::new(Int32Array::from(vec![1, 5])); + acc1.update(&a1).unwrap(); + + let mut acc2 = StatisticsAccumulator::new(&DataType::Int32); + let a2: ArrayRef = Arc::new(Int32Array::from(vec![Some(3), None, Some(10)])); + acc2.update(&a2).unwrap(); + + acc1.merge(&acc2).unwrap(); + let stats = acc1.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "10"); + assert_eq!(stats.null_count, 1); + } + + #[test] + fn test_merge_type_mismatch() { + let acc1 = StatisticsAccumulator::new(&DataType::Int32); + let acc2 = StatisticsAccumulator::new(&DataType::Float64); + let mut acc1 = acc1; + assert!(acc1.merge(&acc2).is_err()); + } + + #[test] + fn test_type_mismatch_error() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Float64Array::from(vec![1.0])); + assert!(acc.update(&array).is_err()); + } + + #[test] + fn test_reset() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Int32Array::from(vec![Some(1), None, Some(3)])); + acc.update(&array).unwrap(); + acc.reset(); + let stats = acc.finish(); + assert!(stats.min.is_none()); + assert!(stats.max.is_none()); + assert_eq!(stats.null_count, 0); + assert_eq!(stats.nan_count, None); + } + + #[test] + fn test_string_stats() { + let mut acc = StatisticsAccumulator::new(&DataType::Utf8); + let array: ArrayRef = Arc::new(StringArray::from(vec!["apple", "cherry", "banana"])); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "apple"); + assert_eq!(format!("{}", stats.max.unwrap()), "cherry"); + } + + #[test] + fn test_boolean_stats() { + let mut acc = StatisticsAccumulator::new(&DataType::Boolean); + let array: ArrayRef = Arc::new(BooleanArray::from(vec![true, false])); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "false"); + assert_eq!(format!("{}", stats.max.unwrap()), "true"); + } + + #[rstest] + #[case::i32( + DataType::Int32, + Arc::new(Int32Array::from(vec![3, 1, 2])) as ArrayRef, + "1", "3" + )] + #[case::i64( + DataType::Int64, + Arc::new(Int64Array::from(vec![30, 10, 20])) as ArrayRef, + "10", "30" + )] + #[case::u32( + DataType::UInt32, + Arc::new(UInt32Array::from(vec![3, 1, 2])) as ArrayRef, + "1", "3" + )] + #[case::u64( + DataType::UInt64, + Arc::new(UInt64Array::from(vec![30, 10, 20])) as ArrayRef, + "10", "30" + )] + #[case::f32( + DataType::Float32, + Arc::new(Float32Array::from(vec![3.0f32, 1.0, 2.0])) as ArrayRef, + "1.0", "3.0" + )] + #[case::f64( + DataType::Float64, + Arc::new(Float64Array::from(vec![3.0f64, 1.0, 2.0])) as ArrayRef, + "1.0", "3.0" + )] + fn test_rstest_primitives( + #[case] dt: DataType, + #[case] array: ArrayRef, + #[case] expected_min: &str, + #[case] expected_max: &str, + ) { + let mut acc = StatisticsAccumulator::new(&dt); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), expected_min); + assert_eq!(format!("{}", stats.max.unwrap()), expected_max); + } + + #[test] + fn test_statistics_does_not_consume() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3])); + acc.update(&array).unwrap(); + let s1 = acc.statistics(); + let s2 = acc.statistics(); + assert_eq!(format!("{}", s1.min.unwrap()), "1"); + assert_eq!(format!("{}", s2.max.unwrap()), "3"); + } + + #[test] + fn test_merge_into_empty() { + let mut acc1 = StatisticsAccumulator::new(&DataType::Int32); + let mut acc2 = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Int32Array::from(vec![5, 10])); + acc2.update(&array).unwrap(); + + acc1.merge(&acc2).unwrap(); + let stats = acc1.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "5"); + assert_eq!(format!("{}", stats.max.unwrap()), "10"); + } + + #[test] + fn test_buffer_memory() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let a1: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3])); + let a2: ArrayRef = Arc::new(Int32Array::from(vec![4, 5])); + let expected = a1.get_buffer_memory_size() + a2.get_buffer_memory_size(); + acc.update(&a1).unwrap(); + acc.update(&a2).unwrap(); + let stats = acc.finish(); + assert_eq!(stats.buffer_memory, expected as u64); + assert!(stats.buffer_memory > 0); + } + + #[test] + fn test_buffer_memory_reset() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3])); + acc.update(&array).unwrap(); + assert!(acc.statistics().buffer_memory > 0); + acc.reset(); + assert_eq!(acc.statistics().buffer_memory, 0); + } + + #[test] + fn test_non_list_item_nulls_is_none() { + let mut acc = StatisticsAccumulator::new(&DataType::Int32); + let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3])); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(stats.item_nulls, None); + } + + mod list_tests { + use super::*; + use arrow_array::builder::{Int32Builder, LargeListBuilder, ListBuilder}; + use arrow_schema::Field; + + fn list_data_type() -> DataType { + DataType::List(Arc::new(Field::new("item", DataType::Int32, true))) + } + + fn large_list_data_type() -> DataType { + DataType::LargeList(Arc::new(Field::new("item", DataType::Int32, true))) + } + + /// Build a ListArray from a slice of optional lists of optional i32. + fn build_list_array(rows: &[Option<&[Option]>]) -> ArrayRef { + let mut builder = ListBuilder::new(Int32Builder::new()); + for row in rows { + match row { + Some(items) => { + for item in *items { + match item { + Some(v) => builder.values().append_value(*v), + None => builder.values().append_null(), + } + } + builder.append(true); + } + None => builder.append(false), + } + } + Arc::new(builder.finish()) + } + + /// Build a LargeListArray from a slice of optional lists of optional i32. + fn build_large_list_array(rows: &[Option<&[Option]>]) -> ArrayRef { + let mut builder = LargeListBuilder::new(Int32Builder::new()); + for row in rows { + match row { + Some(items) => { + for item in *items { + match item { + Some(v) => builder.values().append_value(*v), + None => builder.values().append_null(), + } + } + builder.append(true); + } + None => builder.append(false), + } + } + Arc::new(builder.finish()) + } + + #[test] + fn test_list_basic() { + // [[1, 5], [3, 2, 4]] + let array = build_list_array(&[ + Some(&[Some(1), Some(5)]), + Some(&[Some(3), Some(2), Some(4)]), + ]); + let mut acc = StatisticsAccumulator::new(&list_data_type()); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "5"); + assert_eq!(stats.null_count, 0); + assert_eq!(stats.item_nulls, Some(0)); + assert_eq!(stats.nan_count, None); + } + + #[test] + fn test_list_with_null_items() { + // [[1, null, 5], [null, 3]] + let array = + build_list_array(&[Some(&[Some(1), None, Some(5)]), Some(&[None, Some(3)])]); + let mut acc = StatisticsAccumulator::new(&list_data_type()); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "5"); + assert_eq!(stats.null_count, 0); + assert_eq!(stats.item_nulls, Some(2)); + } + + #[test] + fn test_list_with_null_lists() { + // [[1, 2], null, [3]] + let array = build_list_array(&[Some(&[Some(1), Some(2)]), None, Some(&[Some(3)])]); + let mut acc = StatisticsAccumulator::new(&list_data_type()); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "3"); + assert_eq!(stats.null_count, 1); + assert_eq!(stats.item_nulls, Some(0)); + } + + #[test] + fn test_list_with_null_lists_and_null_items() { + // [[1, null], null, [null, 3]] + let array = build_list_array(&[Some(&[Some(1), None]), None, Some(&[None, Some(3)])]); + let mut acc = StatisticsAccumulator::new(&list_data_type()); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "3"); + assert_eq!(stats.null_count, 1); + assert_eq!(stats.item_nulls, Some(2)); + } + + #[test] + fn test_list_all_null_lists() { + let array = build_list_array(&[None, None]); + let mut acc = StatisticsAccumulator::new(&list_data_type()); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert!(stats.min.is_none()); + assert!(stats.max.is_none()); + assert_eq!(stats.null_count, 2); + assert_eq!(stats.item_nulls, Some(0)); + } + + #[test] + fn test_list_empty_lists() { + // [[], [1], []] + let array = build_list_array(&[Some(&[]), Some(&[Some(1)]), Some(&[])]); + let mut acc = StatisticsAccumulator::new(&list_data_type()); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "1"); + assert_eq!(stats.null_count, 0); + assert_eq!(stats.item_nulls, Some(0)); + } + + #[test] + fn test_list_all_items_null() { + // [[null, null]] + let array = build_list_array(&[Some(&[None, None])]); + let mut acc = StatisticsAccumulator::new(&list_data_type()); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert!(stats.min.is_none()); + assert!(stats.max.is_none()); + assert_eq!(stats.null_count, 0); + assert_eq!(stats.item_nulls, Some(2)); + } + + #[test] + fn test_list_multiple_updates() { + let a1 = build_list_array(&[Some(&[Some(5), Some(3)])]); + let a2 = build_list_array(&[Some(&[Some(1), None]), None, Some(&[Some(7)])]); + let mut acc = StatisticsAccumulator::new(&list_data_type()); + acc.update(&a1).unwrap(); + acc.update(&a2).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "7"); + assert_eq!(stats.null_count, 1); + assert_eq!(stats.item_nulls, Some(1)); + } + + #[test] + fn test_list_merge() { + let a1 = build_list_array(&[Some(&[Some(1), Some(5)])]); + let a2 = build_list_array(&[Some(&[Some(3), None]), None, Some(&[Some(10)])]); + + let mut acc1 = StatisticsAccumulator::new(&list_data_type()); + acc1.update(&a1).unwrap(); + let mut acc2 = StatisticsAccumulator::new(&list_data_type()); + acc2.update(&a2).unwrap(); + + acc1.merge(&acc2).unwrap(); + let stats = acc1.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "10"); + assert_eq!(stats.null_count, 1); + assert_eq!(stats.item_nulls, Some(1)); + } + + #[test] + fn test_list_reset() { + let array = build_list_array(&[Some(&[Some(1), None])]); + let mut acc = StatisticsAccumulator::new(&list_data_type()); + acc.update(&array).unwrap(); + acc.reset(); + let stats = acc.finish(); + assert!(stats.min.is_none()); + assert!(stats.max.is_none()); + assert_eq!(stats.null_count, 0); + assert_eq!(stats.item_nulls, Some(0)); + } + + #[test] + fn test_large_list() { + let array = + build_large_list_array(&[Some(&[Some(10), None, Some(1)]), None, Some(&[Some(5)])]); + let mut acc = StatisticsAccumulator::new(&large_list_data_type()); + acc.update(&array).unwrap(); + let stats = acc.finish(); + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "10"); + assert_eq!(stats.null_count, 1); + assert_eq!(stats.item_nulls, Some(1)); + } + + /// Build a List> array from nested slices. + /// + /// Each outer Option represents an outer list entry (None = null outer list). + /// Each inner Option<&[Option]> represents an inner list entry + /// (None = null inner list). + #[allow(clippy::type_complexity)] + fn build_nested_list_array(rows: &[Option<&[Option<&[Option]>]>]) -> ArrayRef { + let inner_builder = ListBuilder::new(Int32Builder::new()); + let mut builder = ListBuilder::new(inner_builder); + for row in rows { + match row { + Some(inner_lists) => { + let inner_builder = builder.values(); + for inner_list in *inner_lists { + match inner_list { + Some(items) => { + for item in *items { + match item { + Some(v) => { + inner_builder.values().append_value(*v); + } + None => { + inner_builder.values().append_null(); + } + } + } + inner_builder.append(true); + } + None => { + inner_builder.append(false); + } + } + } + builder.append(true); + } + None => builder.append(false), + } + } + Arc::new(builder.finish()) + } + + fn nested_list_data_type() -> DataType { + DataType::List(Arc::new(Field::new( + "item", + DataType::List(Arc::new(Field::new("item", DataType::Int32, true))), + true, + ))) + } + + #[test] + fn test_nested_list() { + // [[[1, 2], [3]], null, [[null, 5], null, [6]]] + let array = build_nested_list_array(&[ + Some(&[Some(&[Some(1), Some(2)][..]), Some(&[Some(3)])]), + None, + Some(&[Some(&[None, Some(5)]), None, Some(&[Some(6)])]), + ]); + + let mut acc = StatisticsAccumulator::new(&nested_list_data_type()); + acc.update(&array).unwrap(); + let stats = acc.finish(); + + // min/max should be computed across all leaf int32 values + assert_eq!(format!("{}", stats.min.unwrap()), "1"); + assert_eq!(format!("{}", stats.max.unwrap()), "6"); + // null_count: only the one null outer list + assert_eq!(stats.null_count, 1); + // item_nulls: 1 null int32 + 1 null inner list = 2 + assert_eq!(stats.item_nulls, Some(2)); + } + } + + mod proptests { + use super::*; + use arrow_select::take::take; + use proptest::prelude::*; + + /// Shuffle an array by applying a random permutation via the `take` kernel. + fn shuffle(array: &ArrayRef, permutation: &[usize]) -> ArrayRef { + let indices = + UInt32Array::from(permutation.iter().map(|&i| i as u32).collect::>()); + take(array.as_ref(), &indices, None).unwrap() + } + + /// Compute stats for an array, returning (min, max) as Option. + fn compute_stats(array: &ArrayRef) -> (Option, Option) { + let mut acc = StatisticsAccumulator::new(array.data_type()); + acc.update(array).unwrap(); + let stats = acc.finish(); + (stats.min, stats.max) + } + + macro_rules! prop_test_full { + ($name:ident, $array_ty:ty, $elem_strategy:expr) => { + proptest! { + #[test] + fn $name( + values in proptest::collection::vec($elem_strategy, 1..100usize), + ) { + let len = values.len(); + let array: ArrayRef = Arc::new(<$array_ty>::from(values)); + let (orig_min, orig_max) = compute_stats(&array); + + // min <= max when both exist + if let (Some(mn), Some(mx)) = (&orig_min, &orig_max) { + prop_assert!(mn <= mx, "min {:?} > max {:?}", mn, mx); + } + + // Reverse the array as a simple permutation + let rev_indices: Vec = (0..len).rev().collect(); + let reversed = shuffle(&array, &rev_indices); + let (rev_min, rev_max) = compute_stats(&reversed); + prop_assert_eq!(&orig_min, &rev_min, "min changed after reverse"); + prop_assert_eq!(&orig_max, &rev_max, "max changed after reverse"); + } + } + }; + } + + macro_rules! prop_test_nullable_full { + ($name:ident, $array_ty:ty, $elem_strategy:expr) => { + proptest! { + #[test] + fn $name( + values in proptest::collection::vec( + proptest::option::of($elem_strategy), 1..100usize + ), + ) { + let len = values.len(); + let array: ArrayRef = Arc::new(<$array_ty>::from(values)); + let (orig_min, orig_max) = compute_stats(&array); + + if let (Some(mn), Some(mx)) = (&orig_min, &orig_max) { + prop_assert!(mn <= mx, "min {:?} > max {:?}", mn, mx); + } + + let rev_indices: Vec = (0..len).rev().collect(); + let reversed = shuffle(&array, &rev_indices); + let (rev_min, rev_max) = compute_stats(&reversed); + prop_assert_eq!(&orig_min, &rev_min, "min changed after reverse"); + prop_assert_eq!(&orig_max, &rev_max, "max changed after reverse"); + + // Also verify null_count and nan_count are invariant + let mut acc_orig = StatisticsAccumulator::new(array.data_type()); + acc_orig.update(&array).unwrap(); + let mut acc_rev = StatisticsAccumulator::new(array.data_type()); + acc_rev.update(&reversed).unwrap(); + prop_assert_eq!( + acc_orig.statistics().null_count, + acc_rev.statistics().null_count, + "null_count changed after shuffle" + ); + prop_assert_eq!( + acc_orig.statistics().nan_count, + acc_rev.statistics().nan_count, + "nan_count changed after shuffle" + ); + } + } + }; + } + + // --- Integer types --- + prop_test_full!(prop_i32, Int32Array, any::()); + prop_test_full!(prop_i64, Int64Array, any::()); + prop_test_full!(prop_u32, UInt32Array, any::()); + prop_test_full!(prop_u64, UInt64Array, any::()); + prop_test_full!(prop_i8, Int8Array, any::()); + prop_test_full!(prop_i16, Int16Array, any::()); + prop_test_full!(prop_u8, UInt8Array, any::()); + prop_test_full!(prop_u16, UInt16Array, any::()); + + // --- Nullable integer types --- + prop_test_nullable_full!(prop_i32_nullable, Int32Array, any::()); + prop_test_nullable_full!(prop_i64_nullable, Int64Array, any::()); + prop_test_nullable_full!(prop_u32_nullable, UInt32Array, any::()); + + // --- Float types (with NaN) --- + prop_test_full!(prop_f32, Float32Array, any::()); + prop_test_full!(prop_f64, Float64Array, any::()); + prop_test_nullable_full!(prop_f64_nullable, Float64Array, any::()); + + // --- String type --- + prop_test_full!(prop_string, StringArray, "[a-z]{0,20}"); + prop_test_nullable_full!(prop_string_nullable, StringArray, "[a-z]{0,20}"); + + // --- Boolean type --- + prop_test_full!(prop_bool, BooleanArray, any::()); + prop_test_nullable_full!(prop_bool_nullable, BooleanArray, any::()); + + // --- Random permutation shuffle test (uses prop_shuffle) --- + proptest! { + #[test] + fn prop_random_permutation_i32( + values in proptest::collection::vec( + proptest::option::of(any::()), 1..100usize + ), + ) { + let len = values.len(); + let array: ArrayRef = Arc::new(Int32Array::from(values)); + let (orig_min, orig_max) = compute_stats(&array); + + if let (Some(mn), Some(mx)) = (&orig_min, &orig_max) { + prop_assert!(mn <= mx); + } + + // Create and shuffle a permutation + let mut perm: Vec = (0..len).collect(); + // Deterministic "shuffle" using a reversal + rotation + perm.reverse(); + if len > 1 { + perm.rotate_left(len / 2); + } + + let shuffled = shuffle(&array, &perm); + let (shuf_min, shuf_max) = compute_stats(&shuffled); + prop_assert_eq!(&orig_min, &shuf_min); + prop_assert_eq!(&orig_max, &shuf_max); + } + } + + proptest! { + /// Verify that splitting an array into two chunks and merging + /// the accumulators gives the same result as processing the + /// whole array at once. + #[test] + fn prop_merge_consistent_i32( + values in proptest::collection::vec( + proptest::option::of(any::()), 2..100usize + ), + ) { + let array: ArrayRef = Arc::new(Int32Array::from(values.clone())); + let split = values.len() / 2; + + let mut full_acc = StatisticsAccumulator::new(&DataType::Int32); + full_acc.update(&array).unwrap(); + + let left: ArrayRef = Arc::new(Int32Array::from(values[..split].to_vec())); + let right: ArrayRef = Arc::new(Int32Array::from(values[split..].to_vec())); + let mut left_acc = StatisticsAccumulator::new(&DataType::Int32); + left_acc.update(&left).unwrap(); + let mut right_acc = StatisticsAccumulator::new(&DataType::Int32); + right_acc.update(&right).unwrap(); + left_acc.merge(&right_acc).unwrap(); + + let full_stats = full_acc.finish(); + let merged_stats = left_acc.finish(); + + prop_assert_eq!(&full_stats.min, &merged_stats.min); + prop_assert_eq!(&full_stats.max, &merged_stats.max); + prop_assert_eq!(full_stats.null_count, merged_stats.null_count); + } + } + } + + #[test] + fn test_unsupported_type_tracks_null_count_and_memory() { + use arrow_array::builder::{Int32Builder, StructBuilder}; + use arrow_schema::Field; + + let fields = vec![Field::new("a", DataType::Int32, true)]; + let mut builder = StructBuilder::new(fields, vec![Box::new(Int32Builder::new()) as _]); + for _ in 0..3 { + builder + .field_builder::(0) + .unwrap() + .append_null(); + builder.append_null(); + } + let struct_array: ArrayRef = Arc::new(builder.finish()); + + let dt = struct_array.data_type().clone(); + let mut acc = StatisticsAccumulator::new(&dt); + acc.update(&struct_array).unwrap(); + + let stats = acc.finish(); + assert!(stats.min.is_none()); + assert!(stats.max.is_none()); + assert_eq!(stats.null_count, 3); + assert_eq!(stats.nan_count, None); + assert!(stats.buffer_memory > 0); + } +} diff --git a/rust/arrow-stats/src/nan.rs b/rust/arrow-stats/src/nan.rs new file mode 100644 index 00000000000..b8b2fd3e89c --- /dev/null +++ b/rust/arrow-stats/src/nan.rs @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use arrow_array::cast::AsArray; +use arrow_array::types::{Float16Type, Float32Type, Float64Type}; +use arrow_array::{Array, ArrayRef}; + +macro_rules! count_nans_typed { + ($array:expr, $arrow_type:ty) => {{ + let typed = $array.as_primitive::<$arrow_type>(); + let mut count = 0u64; + for i in 0..typed.len() { + if !typed.is_null(i) && typed.value(i).is_nan() { + count += 1; + } + } + count + }}; +} + +/// Count the number of non-null NaN values in an array. +/// +/// Returns 0 for non-float types. +pub fn count_nans(array: &ArrayRef) -> u64 { + use arrow_schema::DataType::*; + match array.data_type() { + Float16 => count_nans_typed!(array, Float16Type), + Float32 => count_nans_typed!(array, Float32Type), + Float64 => count_nans_typed!(array, Float64Type), + _ => 0, + } +} From 932d4aa3861278ebb6b49c261218ab5488698ca2 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Wed, 1 Apr 2026 07:36:39 -0700 Subject: [PATCH 119/206] feat: support DataFusion Expr in DeleteBuilder (#6343) Previously, `DeleteBuilder` only accepted SQL string predicates. Callers who already had a `datafusion::logical_expr::Expr` (e.g. from query planning) had to serialize it to a string only for it to be reparsed inside. This adds `DeleteBuilder::from_expr(dataset, expr)` as a companion to the existing `DeleteBuilder::new(dataset, sql)`, following the dual-input pattern already used in `Scanner` (`filter` vs `filter_expr`). Internally both paths store an `ExprFilter` and dispatch to the appropriate scanner method. The `Operation::Delete { predicate }` field serializes the expr via `expr.to_string()` when needed for transaction logging. Closes #6339 ## Test plan - [x] New `test_delete_with_expr_filter` test verifies expr-based deletion matches the equivalent SQL path - [x] All existing delete tests pass unchanged Co-authored-by: Claude Sonnet 4.6 --- rust/lance/src/dataset/write/delete.rs | 81 ++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/rust/lance/src/dataset/write/delete.rs b/rust/lance/src/dataset/write/delete.rs index 2f7314925e6..c8d369dab63 100644 --- a/rust/lance/src/dataset/write/delete.rs +++ b/rust/lance/src/dataset/write/delete.rs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use crate::dataset::rowids::get_row_id_index; +use crate::dataset::scanner::ExprFilter; use crate::{ Dataset, dataset::transaction::{Operation, Transaction}, @@ -104,17 +105,27 @@ async fn apply_deletions( #[derive(Debug, Clone)] pub struct DeleteBuilder { dataset: Arc, - predicate: String, + filter: ExprFilter, conflict_retries: u32, retry_timeout: Duration, } impl DeleteBuilder { - /// Create a new DeleteBuilder + /// Create a new DeleteBuilder with a SQL predicate string pub fn new(dataset: Arc, predicate: impl Into) -> Self { Self { dataset, - predicate: predicate.into(), + filter: ExprFilter::Sql(predicate.into()), + conflict_retries: 10, + retry_timeout: Duration::from_secs(30), + } + } + + /// Create a new DeleteBuilder with a DataFusion expression filter + pub fn from_expr(dataset: Arc, expr: Expr) -> Self { + Self { + dataset, + filter: ExprFilter::Datafusion(expr), conflict_retries: 10, retry_timeout: Duration::from_secs(30), } @@ -136,7 +147,7 @@ impl DeleteBuilder { pub async fn execute(self) -> Result { let job = DeleteJob { dataset: self.dataset.clone(), - predicate: self.predicate, + filter: self.filter, }; let config = RetryConfig { @@ -152,7 +163,7 @@ impl DeleteBuilder { #[derive(Debug, Clone)] struct DeleteJob { dataset: Arc, - predicate: String, + filter: ExprFilter, } /// Data returned by delete operation @@ -170,10 +181,18 @@ impl RetryExecutor for DeleteJob { async fn execute_impl(&self) -> Result { // Create a single scanner for the entire dataset let mut scanner = self.dataset.scan(); - scanner - .with_row_id() - .project(&[ROW_ID])? - .filter(&self.predicate)?; + scanner.with_row_id().project(&[ROW_ID])?; + match &self.filter { + ExprFilter::Sql(s) => { + scanner.filter(s)?; + } + ExprFilter::Datafusion(expr) => { + scanner.filter_expr(expr.clone()); + } + ExprFilter::Substrait(_) => { + unreachable!("Substrait filters are not supported in DeleteBuilder") + } + } // Check if the filter optimized to true (delete everything) or false (delete nothing) let (updated_fragments, deleted_fragment_ids, affected_rows, num_deleted_rows) = @@ -247,10 +266,17 @@ impl RetryExecutor for DeleteJob { async fn commit(&self, dataset: Arc, data: Self::Data) -> Result { let num_deleted_rows = data.num_deleted_rows; + let predicate = match &self.filter { + ExprFilter::Sql(s) => s.clone(), + ExprFilter::Datafusion(expr) => expr.to_string(), + ExprFilter::Substrait(_) => { + unreachable!("Substrait filters are not supported in DeleteBuilder") + } + }; let operation = Operation::Delete { updated_fragments: data.updated_fragments, deleted_fragment_ids: data.deleted_fragment_ids, - predicate: self.predicate.clone(), + predicate, }; let transaction = Transaction::new(dataset.manifest.version, operation, None); @@ -821,7 +847,7 @@ mod tests { let dataset_arc = Arc::new(dataset); let delete_job = DeleteJob { dataset: dataset_arc.clone(), - predicate: "true".to_string(), + filter: ExprFilter::Sql("true".to_string()), }; let delete_data = delete_job.execute_impl().await.unwrap(); @@ -865,4 +891,37 @@ mod tests { // All rows should be deleted, including the updated ones assert_eq!(final_result.new_dataset.count_rows(None).await.unwrap(), 0); } + + #[tokio::test] + async fn test_delete_with_expr_filter() { + use datafusion::prelude::{col, lit}; + + let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new( + "i", + DataType::UInt32, + false, + )])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(UInt32Array::from_iter_values(0..100u32))], + ) + .unwrap(); + + let mut dataset = InsertBuilder::new("memory://") + .execute(vec![batch]) + .await + .unwrap(); + + // Delete rows where i < 10 using an Expr filter + let expr = col("i").lt(lit(10u32)); + let result = DeleteBuilder::from_expr(Arc::new(dataset.clone()), expr) + .execute() + .await + .unwrap(); + + assert_eq!(result.num_deleted_rows, 10); + + dataset.checkout_latest().await.unwrap(); + assert_eq!(dataset.count_rows(None).await.unwrap(), 90); + } } From b08528e5a9a5e08636b7b3a5fee5c201f32987a3 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 1 Apr 2026 23:56:53 +0800 Subject: [PATCH 120/206] fix: preserve index details in python index metadata (#6279) This PR fixes Python index metadata round-tripping so `Index.index_details` is preserved when metadata crosses the Python/Rust boundary. Without this, segment metadata returned from `create_index_uncommitted(...)` can lose `index_details` when passed back through Python, which breaks `merge_existing_index_segments(...)` and makes committed index metadata less faithful. --- python/python/lance/dataset.py | 1 + python/python/tests/test_commit_index.py | 45 ++++++++++++++++++++++++ python/python/tests/test_vector_index.py | 1 + python/src/transaction.rs | 14 +++++++- 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index fda7ddd5d0d..8c1016c1504 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -4426,6 +4426,7 @@ class Index: created_at: Optional[datetime] = None base_id: Optional[int] = None files: Optional[List["IndexFile"]] = None + index_details: Optional[tuple[str, bytes]] = None class AutoCleanupConfig(TypedDict): diff --git a/python/python/tests/test_commit_index.py b/python/python/tests/test_commit_index.py index 5505c248c00..4bd1d4b1c26 100644 --- a/python/python/tests/test_commit_index.py +++ b/python/python/tests/test_commit_index.py @@ -179,3 +179,48 @@ def test_commit_index_with_files(dataset_with_index, test_table, tmp_path): files_by_path = {f.path: f.size_bytes for f in committed_index.files} assert files_by_path["index.idx"] == 1024 assert files_by_path["auxiliary.bin"] == 2048 + + +def test_commit_index_with_index_details(dataset_with_index, test_table, tmp_path): + """Test that index_details round-trip through Python transaction bindings.""" + from lance.dataset import Index + + original_txn = dataset_with_index.get_transactions(1)[0] + original_index = original_txn.operation.new_indices[0] + assert original_index.index_details is not None + + index_id = original_index.uuid + dataset_without_index = lance.write_dataset( + test_table, tmp_path / "dataset_without_index" + ) + + src_index_dir = Path(dataset_with_index.uri) / "_indices" / index_id + dest_index_dir = Path(dataset_without_index.uri) / "_indices" / index_id + shutil.copytree(src_index_dir, dest_index_dir) + + field_id = _get_field_id_by_name(dataset_without_index.lance_schema, "meta") + index = Index( + uuid=index_id, + name="meta_idx", + fields=[field_id], + dataset_version=dataset_without_index.version, + fragment_ids=set( + [f.fragment_id for f in dataset_without_index.get_fragments()] + ), + index_version=0, + index_details=original_index.index_details, + ) + + create_index_op = lance.LanceOperation.CreateIndex( + new_indices=[index], + removed_indices=[], + ) + dataset_without_index = lance.LanceDataset.commit( + dataset_without_index.uri, + create_index_op, + read_version=dataset_without_index.version, + ) + + committed_txn = dataset_without_index.get_transactions(1)[0] + committed_index = committed_txn.operation.new_indices[0] + assert committed_index.index_details == original_index.index_details diff --git a/python/python/tests/test_vector_index.py b/python/python/tests/test_vector_index.py index 70d3c6e8b81..b000a99bd0e 100644 --- a/python/python/tests/test_vector_index.py +++ b/python/python/tests/test_vector_index.py @@ -2739,6 +2739,7 @@ def test_merge_existing_index_segments_builds_vector_segment(tmp_path): ) for fragment in frags[:2] ] + assert all(segment.index_details is not None for segment in segments) merged_segment = ds.merge_existing_index_segments(segments) assert merged_segment.fragment_ids is not None diff --git a/python/src/transaction.rs b/python/src/transaction.rs index 1c29ec416c0..eae5b49a15d 100644 --- a/python/src/transaction.rs +++ b/python/src/transaction.rs @@ -84,6 +84,12 @@ impl FromPyObject<'_> for PyLance { .getattr("files")? .extract::>>>()? .map(|v| v.into_iter().map(|f| f.0).collect()); + let index_details = match ob.getattr("index_details") { + Ok(details) => details + .extract::)>>()? + .map(|(type_url, value)| Arc::new(prost_types::Any { type_url, value })), + Err(_) => None, + }; Ok(Self(IndexMetadata { uuid: Uuid::parse_str(&uuid).map_err(|e| PyValueError::new_err(e.to_string()))?, @@ -91,7 +97,7 @@ impl FromPyObject<'_> for PyLance { fields, dataset_version, fragment_bitmap, - index_details: None, + index_details, index_version, created_at, base_id, @@ -133,6 +139,11 @@ impl<'py> IntoPyObject<'py> for PyLance<&IndexMetadata> { .as_ref() .map(|f| export_vec(py, f.as_slice())) .transpose()?; + let index_details = self + .0 + .index_details + .as_ref() + .map(|details| (details.type_url.clone(), details.value.clone())); let cls = namespace .getattr("Index") @@ -147,6 +158,7 @@ impl<'py> IntoPyObject<'py> for PyLance<&IndexMetadata> { created_at, base_id, files, + index_details, )) } } From 175a492ffa71143091e2c31c58d3d699e01bc0b0 Mon Sep 17 00:00:00 2001 From: Wyatt Alt Date: Wed, 1 Apr 2026 12:15:51 -0700 Subject: [PATCH 121/206] perf: reduce bitmap index build to 1 bitmap in RAM (#6371) Bitmap index build and update previously materialized every unique value's bitmap in a HashMap before writing to disk. For high-cardinality columns (e.g. URLs with millions of unique values across thousands of fragments) this caused OOM during both initial build and append. Replace the materialize-then-write pipeline with a streaming merge that processes one value at a time: - Request sorted input via TrainingOrdering::Values so the scanner delivers (value, row_id) pairs in value order - Stream through the sorted data, accumulating row_ids for the current value and flushing each bitmap to disk as soon as the value changes - During append, merge-join the sorted new data with the old index's BTreeMap keys, loading each old bitmap on demand and dropping it after write Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-index/src/scalar/bitmap.rs | 269 +++++++++++++++++++- rust/lance-index/src/scalar/lance_format.rs | 29 ++- 2 files changed, 282 insertions(+), 16 deletions(-) diff --git a/rust/lance-index/src/scalar/bitmap.rs b/rust/lance-index/src/scalar/bitmap.rs index c5a71ed6305..dd490265e0c 100644 --- a/rust/lance-index/src/scalar/bitmap.rs +++ b/rust/lance-index/src/scalar/bitmap.rs @@ -606,8 +606,7 @@ impl ScalarIndex for BitmapIndex { dest_store: &dyn IndexStore, _old_data_filter: Option, ) -> Result { - let state = self.load_bitmap_index_state().await?; - BitmapIndexPlugin::do_train_bitmap_index(new_data, state, dest_store).await?; + BitmapIndexPlugin::streaming_build_and_write(new_data, Some(self), dest_store).await?; Ok(CreatedIndex { index_details: prost_types::Any::from_msg(&pbold::BitmapIndexDetails::default()) @@ -618,7 +617,7 @@ impl ScalarIndex for BitmapIndex { } fn update_criteria(&self) -> UpdateCriteria { - UpdateCriteria::only_new_data(TrainingCriteria::new(TrainingOrdering::None).with_row_id()) + UpdateCriteria::only_new_data(TrainingCriteria::new(TrainingOrdering::Values).with_row_id()) } fn derive_index_params(&self) -> Result { @@ -626,6 +625,79 @@ impl ScalarIndex for BitmapIndex { } } +/// Buffers serialized (key, bitmap) pairs and flushes them as record batches +/// to the index file, respecting the MAX_BITMAP_ARRAY_LENGTH limit. +struct BitmapBatchWriter { + file: Box, + keys: Vec, + serialized: Vec>, + bytes: usize, + num_bitmaps: usize, +} + +impl BitmapBatchWriter { + fn new(file: Box) -> Self { + Self { + file, + keys: Vec::new(), + serialized: Vec::new(), + bytes: 0, + num_bitmaps: 0, + } + } + + /// Serialize and buffer a single (key, bitmap) pair, flushing the current + /// batch to disk if adding it would exceed MAX_BITMAP_ARRAY_LENGTH. + async fn emit(&mut self, key: ScalarValue, bitmap: &RowAddrTreeMap) -> Result<()> { + let mut buf = Vec::new(); + bitmap.serialize_into(&mut buf).unwrap(); + let size = buf.len(); + + if self.bytes + size > MAX_BITMAP_ARRAY_LENGTH { + self.flush().await?; + } + + self.keys.push(key); + self.serialized.push(buf); + self.bytes += size; + self.num_bitmaps += 1; + Ok(()) + } + + /// Write the current batch to disk. + async fn flush(&mut self) -> Result<()> { + if self.keys.is_empty() { + return Ok(()); + } + let keys_array = + ScalarValue::iter_to_array(self.keys.drain(..).collect::>().into_iter()) + .unwrap(); + let total_size: usize = self.serialized.iter().map(|b| b.len()).sum(); + let mut binary_builder = BinaryBuilder::with_capacity(self.serialized.len(), total_size); + for b in self.serialized.drain(..) { + binary_builder.append_value(&b); + } + let bitmaps_array = Arc::new(binary_builder.finish()) as Arc; + let batch = BitmapIndexPlugin::get_batch_from_arrays(keys_array, bitmaps_array)?; + self.file.write_record_batch(batch).await?; + self.bytes = 0; + Ok(()) + } + + /// Flush any remaining data, write index statistics, and finalize the file. + async fn finish(mut self) -> Result<()> { + self.flush().await?; + let stats_json = serde_json::to_string(&BitmapStatistics { + num_bitmaps: self.num_bitmaps, + }) + .map_err(|e| Error::internal(format!("failed to serialize bitmap statistics: {e}")))?; + let mut metadata = HashMap::new(); + metadata.insert(INDEX_STATS_METADATA_KEY.to_string(), stats_json); + self.file.finish_with_metadata(metadata).await?; + Ok(()) + } +} + #[derive(Debug, Default)] pub struct BitmapIndexPlugin; @@ -758,23 +830,174 @@ impl BitmapIndexPlugin { Ok((state, value_type)) } - async fn do_train_bitmap_index( - data_source: SendableRecordBatchStream, - state: HashMap, + pub async fn train_bitmap_index( + data: SendableRecordBatchStream, index_store: &dyn IndexStore, ) -> Result<()> { - let (state, value_type) = Self::build_bitmap_index_state(data_source, state).await?; - Self::write_bitmap_index(state, index_store, &value_type).await + Self::streaming_build_and_write(data, None, index_store).await } - pub async fn train_bitmap_index( - data: SendableRecordBatchStream, + /// Builds and writes a bitmap index in a streaming fashion from value-sorted + /// input. Only one value's bitmap is in memory at a time, reducing peak memory + /// from O(unique_values * avg_bitmap) to O(largest_single_bitmap). + /// + /// If `old_index` is provided, its existing bitmaps are merged with the new + /// data via a sorted merge-join (the old index_map is a BTreeMap, already + /// sorted by value). + async fn streaming_build_and_write( + mut data_source: SendableRecordBatchStream, + old_index: Option<&BitmapIndex>, index_store: &dyn IndexStore, ) -> Result<()> { - // mapping from item to list of the row ids where it is present - let dictionary: HashMap = HashMap::new(); + let value_type = data_source.schema().field(0).data_type().clone(); + + let schema = Arc::new(Schema::new(vec![ + Field::new("keys", value_type.clone(), true), + Field::new("bitmaps", DataType::Binary, true), + ])); + + let index_file = index_store + .new_index_file(BITMAP_LOOKUP_NAME, schema) + .await?; + let mut writer = BitmapBatchWriter::new(index_file); + + // Collect old index keys (already in memory as BTreeMap keys — this is + // just a Vec of references, not a copy of the bitmaps themselves). + let old_keys: Vec = old_index + .map(|idx| idx.index_map.keys().cloned().collect()) + .unwrap_or_default(); + let mut old_pos: usize = 0; + + // Current value being accumulated from the new data stream. + let mut current_key: Option = None; + let mut current_bitmap = RowAddrTreeMap::default(); + // Track whether we emitted a null bitmap (old index stores nulls + // separately in null_map, not in index_map). + let mut emitted_null = false; - Self::do_train_bitmap_index(data, dictionary, index_store).await + while let Some(batch) = data_source.try_next().await? { + let values = batch.column_by_name(VALUE_COLUMN_NAME).expect_ok()?; + let row_ids = batch.column_by_name(ROW_ID).expect_ok()?; + debug_assert_eq!(row_ids.data_type(), &DataType::UInt64); + let row_id_column = row_ids.as_any().downcast_ref::().unwrap(); + + for i in 0..values.len() { + let row_id = row_id_column.value(i); + let key = ScalarValue::try_from_array(values.as_ref(), i)?; + + match ¤t_key { + Some(cur) if *cur == key => { + current_bitmap.insert(row_id); + } + _ => { + // Value changed — flush the previous run. + if let Some(prev_key) = current_key.take() { + let mut prev_bitmap = std::mem::take(&mut current_bitmap); + Self::finish_run( + prev_key, + &mut prev_bitmap, + old_index, + &old_keys, + &mut old_pos, + &mut emitted_null, + &mut writer, + ) + .await?; + } + current_key = Some(key); + current_bitmap = RowAddrTreeMap::default(); + current_bitmap.insert(row_id); + } + } + } + } + + // Flush the last accumulated run from new data. + if let Some(last_key) = current_key.take() { + let mut last_bitmap = std::mem::take(&mut current_bitmap); + Self::finish_run( + last_key, + &mut last_bitmap, + old_index, + &old_keys, + &mut old_pos, + &mut emitted_null, + &mut writer, + ) + .await?; + } + + // Emit any remaining old-only entries. + if let Some(idx) = old_index { + while old_pos < old_keys.len() { + let old_bitmap = idx.load_bitmap(&old_keys[old_pos], None).await?; + writer + .emit(old_keys[old_pos].0.clone(), &old_bitmap) + .await?; + old_pos += 1; + } + } + + // Emit old null bitmap if we didn't already merge it with new nulls. + if !emitted_null + && let Some(idx) = old_index + && !idx.null_map.is_empty() + { + let null_key = new_null_array(&value_type, 1); + let null_key = ScalarValue::try_from_array(null_key.as_ref(), 0)?; + writer.emit(null_key, &idx.null_map).await?; + } + + writer.finish().await?; + + Ok(()) + } + + /// Flush a completed value-run from the new data stream, emitting any + /// old-only entries that sort before it and merging the old bitmap if the + /// key exists in both old and new. + async fn finish_run( + key: ScalarValue, + bitmap: &mut RowAddrTreeMap, + old_index: Option<&BitmapIndex>, + old_keys: &[OrderableScalarValue], + old_pos: &mut usize, + emitted_null: &mut bool, + writer: &mut BitmapBatchWriter, + ) -> Result<()> { + if key.is_null() { + // Null values are stored separately in the old index's null_map. + if let Some(idx) = old_index + && !idx.null_map.is_empty() + { + *bitmap |= &*idx.null_map; + } + *emitted_null = true; + writer.emit(key, bitmap).await?; + } else if let Some(idx) = old_index { + let orderable = OrderableScalarValue(key.clone()); + + // Emit old-only entries that sort before this key. + while *old_pos < old_keys.len() && old_keys[*old_pos] < orderable { + let old_bitmap = idx.load_bitmap(&old_keys[*old_pos], None).await?; + writer + .emit(old_keys[*old_pos].0.clone(), &old_bitmap) + .await?; + *old_pos += 1; + } + + // If the old index also has this key, merge its bitmap. + if *old_pos < old_keys.len() && old_keys[*old_pos] == orderable { + let old_bitmap = idx.load_bitmap(&old_keys[*old_pos], None).await?; + *bitmap |= &*old_bitmap; + *old_pos += 1; + } + + writer.emit(key, bitmap).await?; + } else { + writer.emit(key, bitmap).await?; + } + Ok(()) } /// Remaps every bitmap in a materialized bitmap-index state using row-id mappings. @@ -816,7 +1039,7 @@ impl ScalarIndexPlugin for BitmapIndexPlugin { )); } Ok(Box::new(DefaultTrainingRequest::new( - TrainingCriteria::new(TrainingOrdering::None).with_row_id(), + TrainingCriteria::new(TrainingOrdering::Values).with_row_id(), ))) } @@ -894,6 +1117,22 @@ mod tests { use crate::scalar::lance_format::LanceIndexStore; use arrow_array::{RecordBatch, StringArray, UInt64Array, record_batch}; use arrow_schema::{DataType, Field, Schema}; + + /// Sort a (value, row_id) RecordBatch by the value column so that unit tests + /// match the ordering the production scanner applies via TrainingOrdering::Values. + fn sort_batch_by_value(batch: &RecordBatch) -> RecordBatch { + use arrow::compute::SortOptions; + let values = batch.column(0); + let row_ids = batch.column(1); + let options = SortOptions { + descending: false, + nulls_first: true, + }; + let indices = arrow::compute::sort_to_indices(values, Some(options), None).unwrap(); + let sorted_values = arrow::compute::take(values.as_ref(), &indices, None).unwrap(); + let sorted_row_ids = arrow::compute::take(row_ids.as_ref(), &indices, None).unwrap(); + RecordBatch::try_new(batch.schema(), vec![sorted_values, sorted_row_ids]).unwrap() + } use datafusion::physical_plan::stream::RecordBatchStreamAdapter; use futures::stream; use lance_core::utils::mask::RowSetOps; @@ -933,6 +1172,7 @@ mod tests { ) .unwrap(); + let batch = sort_batch_by_value(&batch); let stream = stream::once(async move { Ok(batch) }); let stream = Box::pin(RecordBatchStreamAdapter::new(schema, stream)); @@ -1207,6 +1447,7 @@ mod tests { ) .unwrap(); + let batch = sort_batch_by_value(&batch); let stream = stream::once(async move { Ok(batch) }); let stream = Box::pin(RecordBatchStreamAdapter::new(schema, stream)); diff --git a/rust/lance-index/src/scalar/lance_format.rs b/rust/lance-index/src/scalar/lance_format.rs index 76f904a1a64..42bef5d7244 100644 --- a/rust/lance-index/src/scalar/lance_format.rs +++ b/rust/lance-index/src/scalar/lance_format.rs @@ -380,6 +380,7 @@ mod tests { use arrow_schema::Schema as ArrowSchema; use arrow_schema::{DataType, Field, TimeUnit}; use arrow_select::take::TakeOptions; + use datafusion::physical_plan::stream::RecordBatchStreamAdapter; use datafusion_common::ScalarValue; use futures::FutureExt; use lance_core::ROW_ID; @@ -986,13 +987,37 @@ mod tests { index_store: &Arc, data: impl RecordBatchReader + Send + Sync + 'static, ) { - let data = lance_datafusion::utils::reader_to_stream(Box::new(data)); + // Sort the data by value column (nulls first) to match the production + // scanner behavior (TrainingOrdering::Values). + let schema = data.schema(); + let batches: Vec<_> = data + .into_iter() + .collect::, _>>() + .unwrap(); + let combined = arrow::compute::concat_batches(&schema, &batches).unwrap(); + let options = arrow::compute::SortOptions { + descending: false, + nulls_first: true, + }; + let indices = + arrow::compute::sort_to_indices(combined.column(0), Some(options), None).unwrap(); + let sorted_columns: Vec<_> = combined + .columns() + .iter() + .map(|col| arrow::compute::take(col.as_ref(), &indices, None).unwrap()) + .collect(); + let sorted_batch = RecordBatch::try_new(schema.clone(), sorted_columns).unwrap(); + let stream = Box::pin(RecordBatchStreamAdapter::new( + schema, + futures::stream::once(async move { Ok(sorted_batch) }), + )); + let request = BitmapIndexPlugin .new_training_request("{}", &Field::new(VALUE_COLUMN_NAME, DataType::Int32, false)) .unwrap(); BitmapIndexPlugin .train_index( - data, + stream, index_store.as_ref(), request, None, From 21d830a0e67487cb3755610f492875cb75be8d81 Mon Sep 17 00:00:00 2001 From: Lei Xu Date: Wed, 1 Apr 2026 13:41:37 -0700 Subject: [PATCH 122/206] feat: support bf16 from pytorch dataset (#6342) # Summary Support round-trip to use bf16 from PyTorch Co-authored-by: Claude Opus 4.6 --- python/python/lance/torch/data.py | 60 ++++++++++++- python/python/tests/torch_tests/test_data.py | 95 +++++++++++++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/python/python/lance/torch/data.py b/python/python/lance/torch/data.py index dc09cde3dc4..759b5b2f50a 100644 --- a/python/python/lance/torch/data.py +++ b/python/python/lance/torch/data.py @@ -52,6 +52,52 @@ def __call__( ) -> Union[dict[str, torch.Tensor], torch.Tensor]: ... +def _is_bfloat16_type(t: pa.DataType) -> bool: + """Check if a PyArrow type is the lance bfloat16 extension type.""" + return isinstance(t, pa.ExtensionType) and t.extension_name == "lance.bfloat16" + + +def _bf16_to_tensor(arr: pa.Array) -> torch.Tensor: + """Convert a bfloat16 extension array to a torch.bfloat16 tensor. + + Reinterprets the raw bytes as uint16 and views as bfloat16, + since they share the same 2-byte memory layout. + Null values are replaced with NaN. + """ + storage = arr.storage if isinstance(arr.type, pa.ExtensionType) else arr + buf = storage.buffers()[1] + offset = storage.offset * 2 # 2 bytes per bf16 value + try: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="The given buffer is not writable", + category=UserWarning, + ) + tensor = torch.frombuffer( + memoryview(buf), + dtype=torch.uint16, + count=len(storage), + offset=offset, + ).view(torch.bfloat16) + except (AttributeError, RuntimeError, TypeError): + np_uint16 = np.frombuffer( + buf, dtype=np.uint16, count=len(storage), offset=offset + ) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="The given NumPy array is not writable", + category=UserWarning, + ) + tensor = torch.from_numpy(np_uint16).view(torch.bfloat16) + if arr.null_count > 0: + tensor = tensor.clone() + null_mask = torch.from_numpy(arr.is_null().to_numpy(zero_copy_only=False)) + tensor[null_mask] = float("nan") + return tensor + + # Convert an Arrow FSL array into a 2D torch tensor def _fsl_to_tensor(arr: pa.FixedSizeListArray, dimension: int) -> torch.Tensor: # Note: FixedSizeListArray.values does not take offset/len into account and @@ -104,6 +150,14 @@ def _to_tensor( or pa.types.is_integer(arr.type.value_type) ): tensor = _fsl_to_tensor(arr, arr.type.list_size) + elif pa.types.is_fixed_size_list(arr.type) and _is_bfloat16_type( + arr.type.value_type + ): + values = arr.values + start = arr.offset * arr.type.list_size + num_vals = len(arr) * arr.type.list_size + values = values.slice(start, num_vals) + tensor = _bf16_to_tensor(values).view(-1, arr.type.list_size) elif ( pa.types.is_integer(arr.type) or pa.types.is_floating(arr.type) @@ -113,13 +167,15 @@ def _to_tensor( if uint64_as_int64 and tensor.dtype == torch.uint64: tensor = tensor.to(torch.int64) + elif _is_bfloat16_type(arr.type): + tensor = _bf16_to_tensor(arr) elif hf_converter is not None: tensor = hf_converter.to_pytorch(col, arr) if tensor is None: raise ValueError( - "Only support FixedSizeList or " - + f"numeric values, got: {arr.type}" + "Only support FixedSizeList or " + + f"numeric/bfloat16 values, got: {arr.type}" ) del arr diff --git a/python/python/tests/torch_tests/test_data.py b/python/python/tests/torch_tests/test_data.py index 38b9439802f..734ca3ce558 100644 --- a/python/python/tests/torch_tests/test_data.py +++ b/python/python/tests/torch_tests/test_data.py @@ -12,7 +12,11 @@ from lance.sampler import ShardedBatchSampler, ShardedFragmentSampler torch = pytest.importorskip("torch") -from lance.torch.data import LanceDataset, SafeLanceDataset # noqa: E402 +from lance.torch.data import ( # noqa: E402 + LanceDataset, + SafeLanceDataset, + _bf16_to_tensor, +) def test_iter_over_dataset_fixed_shape_tensor(tmp_path): @@ -326,6 +330,95 @@ def to_tensor_fn(batch, *args, **kwargs): assert first["val"].shape == (4, 100) +def test_iter_over_dataset_bfloat16(tmp_path): + """Test that bfloat16 vector columns convert to torch.bfloat16 tensors.""" + ml_dtypes = pytest.importorskip("ml_dtypes") + from lance.arrow import BFloat16Array + + dim = 32 + num_rows = 128 + # Create random bfloat16 vectors via float32 → bfloat16 cast + f32_data = np.random.random(num_rows * dim).astype("f") + bf16_data = f32_data.astype(ml_dtypes.bfloat16) + + # Build a FixedSizeList column + inner = BFloat16Array.from_numpy(bf16_data) + fsl = pa.FixedSizeListArray.from_arrays(inner, dim) + ids = pa.array(range(num_rows), type=pa.int32()) + tbl = pa.Table.from_arrays([ids, fsl], ["ids", "vec"]) + + ds = lance.write_dataset(tbl, tmp_path / "data.lance", max_rows_per_group=32) + + torch_ds = LanceDataset(ds, batch_size=16, columns=["ids", "vec"]) + + total_rows = 0 + for batch in torch_ds: + assert set(batch.keys()) == {"ids", "vec"} + assert batch["vec"].dtype == torch.bfloat16 + assert batch["vec"].shape[1] == dim + assert batch["ids"].dtype == torch.int32 + total_rows += batch["vec"].shape[0] + assert total_rows == num_rows + + +def test_scalar_bfloat16_column(tmp_path): + """Test that a scalar bfloat16 column converts to torch.bfloat16 tensor.""" + ml_dtypes = pytest.importorskip("ml_dtypes") + from lance.arrow import BFloat16Array + + num_rows = 64 + f32_data = np.random.random(num_rows).astype("f") + bf16_data = f32_data.astype(ml_dtypes.bfloat16) + + arr = BFloat16Array.from_numpy(bf16_data) + tbl = pa.Table.from_arrays([arr], ["val"]) + + ds = lance.write_dataset(tbl, tmp_path / "data.lance") + + torch_ds = LanceDataset(ds, batch_size=16, columns=["val"]) + + total_rows = 0 + for batch in torch_ds: + assert batch.dtype == torch.bfloat16 + total_rows += batch.shape[0] + assert total_rows == num_rows + + +def test_bf16_to_tensor_zero_copy_without_nulls(): + """Non-null bf16 arrays should alias the Arrow data buffer.""" + ml_dtypes = pytest.importorskip("ml_dtypes") + from lance.arrow import BFloat16Array + + values = np.array([1.0, 2.0, 3.0, 4.0], dtype=ml_dtypes.bfloat16) + arr = BFloat16Array.from_numpy(values).slice(1, 2) + + tensor = _bf16_to_tensor(arr) + + assert tensor.dtype == torch.bfloat16 + assert torch.equal( + tensor.to(torch.float32), + torch.tensor([2.0, 3.0], dtype=torch.float32), + ) + assert ( + tensor.data_ptr() == arr.storage.buffers()[1].address + arr.storage.offset * 2 + ) + + +def test_bf16_to_tensor_clones_when_nulls_present(): + """Null replacement requires a writable tensor, so the Arrow buffer is cloned.""" + arr = lance.arrow.bfloat16_array([1.0, None, 3.0]) + + tensor = _bf16_to_tensor(arr) + + assert tensor.dtype == torch.bfloat16 + assert ( + tensor.data_ptr() != arr.storage.buffers()[1].address + arr.storage.offset * 2 + ) + assert tensor[0].to(torch.float32).item() == pytest.approx(1.0) + assert torch.isnan(tensor[1]) + assert tensor[2].to(torch.float32).item() == pytest.approx(3.0) + + def test_safe_lance_dataset_worker_uses_dataset_options(tmp_path: Path): """Worker processes must reopen the dataset with dataset_options. From 1a1094d9fe51f859a62e491f0a30cd0fb4bcb7b5 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Wed, 1 Apr 2026 15:47:28 -0700 Subject: [PATCH 123/206] feat: object store decides scheduler type (#6373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `ObjectStore::prefers_lite_scheduler()` that returns `true` for `file+uring://` stores, so the lite scheduler is used automatically without needing the env var - Change `SchedulerConfig::use_lite_scheduler` from `bool` to `Option` — `Some(true/false)` overrides, `None` defers to the object store's preference - `LANCE_USE_LITE_SCHEDULER` env var still works as an override when explicitly set ## Test plan - [x] `cargo check -p lance-io --tests --benches` compiles cleanly - [x] `cargo test -p lance-io` — all 148 tests pass - [x] `cargo clippy -p lance-io --tests --benches -- -D warnings` — no warnings 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- rust/lance-io/benches/scheduler.rs | 28 +++++----- rust/lance-io/src/object_store.rs | 12 ++++- rust/lance-io/src/scheduler.rs | 84 ++++++++++++++++++++++++++---- 3 files changed, 98 insertions(+), 26 deletions(-) diff --git a/rust/lance-io/benches/scheduler.rs b/rust/lance-io/benches/scheduler.rs index 82687867e7a..46d8ac830e3 100644 --- a/rust/lance-io/benches/scheduler.rs +++ b/rust/lance-io/benches/scheduler.rs @@ -22,14 +22,14 @@ use pprof::criterion::{Output, PProfProfiler}; struct FullReadParams { io_parallelism: u32, page_size: u64, - use_lite_scheduler: bool, + use_lite_scheduler: Option, } impl Display for FullReadParams { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "full_read,parallel={},read_size={},use_lite_scheduler={}", + "full_read,parallel={},read_size={},use_lite_scheduler={:?}", self.io_parallelism, self.page_size, self.use_lite_scheduler ) } @@ -74,7 +74,7 @@ fn bench_full_read(c: &mut Criterion) { let runtime = Runtime::new().unwrap(); let (obj_store, tmp_file) = runtime.block_on(create_data(DATA_SIZE)); - for use_lite_scheduler in [false, true] { + for use_lite_scheduler in [Some(false), Some(true)] { for io_parallelism in [1, 16] { for page_size in [4096, 1024 * 1024] { let params = FullReadParams { @@ -101,10 +101,10 @@ fn bench_full_read(c: &mut Criterion) { unsafe { std::env::set_var("IO_THREADS", io_parallelism.to_string()); } - let mut config = SchedulerConfig::default_for_testing(); - if use_lite_scheduler { - config = config.with_lite_scheduler(); - } + let config = SchedulerConfig { + use_lite_scheduler, + ..SchedulerConfig::default_for_testing() + }; runtime.block_on(async { let scheduler = ScanScheduler::new(obj_store, config); let file_scheduler = scheduler @@ -142,7 +142,7 @@ struct RandomReadParams { io_parallelism: u32, item_size: u32, indices: Arc>, - use_lite_scheduler: bool, + use_lite_scheduler: Option, noisy_runtime: bool, } @@ -150,7 +150,7 @@ impl Display for RandomReadParams { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "random_read,parallel={},item_size={},use_lite_scheduler={},noisy={}", + "random_read,parallel={},item_size={},use_lite_scheduler={:?},noisy={}", self.io_parallelism, self.item_size, self.use_lite_scheduler, self.noisy_runtime ) } @@ -187,7 +187,7 @@ fn bench_random_read(c: &mut Criterion) { )); for noisy_runtime in [false, true] { - for use_lite_scheduler in [false, true] { + for use_lite_scheduler in [Some(false), Some(true)] { for io_parallelism in [1, 16] { for item_size in [4096, 32 * 1024] { let runtime = Runtime::new().unwrap(); @@ -240,10 +240,10 @@ fn bench_random_read(c: &mut Criterion) { } } - let mut config = SchedulerConfig::default_for_testing(); - if use_lite_scheduler { - config = config.with_lite_scheduler(); - } + let config = SchedulerConfig { + use_lite_scheduler, + ..SchedulerConfig::default_for_testing() + }; let scheduler = ScanScheduler::new(obj_store, config); let file_scheduler = scheduler .open_file(&tmp_file, &CachedFileSize::unknown()) diff --git a/rust/lance-io/src/object_store.rs b/rust/lance-io/src/object_store.rs index 6fe5c152046..e565cf031cd 100644 --- a/rust/lance-io/src/object_store.rs +++ b/rust/lance-io/src/object_store.rs @@ -534,11 +534,19 @@ impl ObjectStore { /// Returns true if the object store pointed to a local file system. pub fn is_local(&self) -> bool { - self.scheme == "file" + self.scheme == "file" || self.scheme == "file+uring" } pub fn is_cloud(&self) -> bool { - self.scheme != "file" && self.scheme != "memory" + !self.is_local() && self.scheme != "memory" + } + + /// Whether this object store prefers the lite scheduler. + /// + /// The lite scheduler is designed for backends like io_uring where + /// tasks should only be polled when the consumer polls them. + pub fn prefers_lite_scheduler(&self) -> bool { + self.scheme == "file+uring" } pub fn scheme(&self) -> &str { diff --git a/rust/lance-io/src/scheduler.rs b/rust/lance-io/src/scheduler.rs index 7199c4d1b91..d5c4f44f7fa 100644 --- a/rust/lance-io/src/scheduler.rs +++ b/rust/lance-io/src/scheduler.rs @@ -15,6 +15,7 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use tokio::sync::Notify; +use lance_core::utils::parse::str_is_truthy; use lance_core::{Error, Result}; use crate::object_store::ObjectStore; @@ -505,15 +506,21 @@ pub struct SchedulerConfig { /// This controls back pressure. If data is not processed quickly enough then this /// buffer will fill up and the I/O loop will pause until the buffer is drained. pub io_buffer_size_bytes: u64, - /// Whether to use the new lite scheduler - pub use_lite_scheduler: bool, + /// Whether to use the lite scheduler. + /// + /// - `Some(true)` forces the lite scheduler (e.g. from env var or programmatic). + /// - `Some(false)` forces the standard scheduler. + /// - `None` defers to the object store's preference (see [`ObjectStore::prefers_lite_scheduler`]). + pub use_lite_scheduler: Option, } impl SchedulerConfig { pub fn new(io_buffer_size_bytes: u64) -> Self { Self { io_buffer_size_bytes, - use_lite_scheduler: std::env::var("LANCE_USE_LITE_SCHEDULER").is_ok(), + use_lite_scheduler: std::env::var("LANCE_USE_LITE_SCHEDULER") + .ok() + .map(|v| str_is_truthy(v.trim())), } } @@ -521,7 +528,7 @@ impl SchedulerConfig { pub fn default_for_testing() -> Self { Self { io_buffer_size_bytes: 256 * 1024 * 1024, - use_lite_scheduler: false, + use_lite_scheduler: None, } } @@ -533,7 +540,7 @@ impl SchedulerConfig { pub fn with_lite_scheduler(self) -> Self { Self { - use_lite_scheduler: true, + use_lite_scheduler: Some(true), ..self } } @@ -548,7 +555,10 @@ impl ScanScheduler { /// * config - configuration settings for the scheduler pub fn new(object_store: Arc, config: SchedulerConfig) -> Arc { let io_capacity = object_store.io_parallelism(); - let io_queue = if config.use_lite_scheduler { + let use_lite = config + .use_lite_scheduler + .unwrap_or_else(|| object_store.prefers_lite_scheduler()); + let io_queue = if use_lite { let io_queue = Arc::new(lite::IoQueue::new( io_capacity as u64, config.io_buffer_size_bytes, @@ -743,6 +753,11 @@ impl ScanScheduler { pub fn stats(&self) -> ScanStats { ScanStats::new(self.stats.as_ref()) } + + #[cfg(test)] + fn uses_lite_scheduler(&self) -> bool { + matches!(self.io_queue, IoQueueType::Lite(_)) + } } impl Drop for ScanScheduler { @@ -1120,7 +1135,7 @@ mod tests { let config = SchedulerConfig { io_buffer_size_bytes: 1024 * 1024, - use_lite_scheduler: false, + use_lite_scheduler: None, }; let scan_scheduler = ScanScheduler::new(obj_store, config); @@ -1211,7 +1226,7 @@ mod tests { let config = SchedulerConfig { io_buffer_size_bytes: 10, - use_lite_scheduler: false, + use_lite_scheduler: None, }; let scan_scheduler = ScanScheduler::new(obj_store.clone(), config); @@ -1286,7 +1301,7 @@ mod tests { // Ensure deadlock prevention timeout can be disabled let config = SchedulerConfig { io_buffer_size_bytes: 10, - use_lite_scheduler: false, + use_lite_scheduler: None, }; let scan_scheduler = ScanScheduler::new(obj_store, config); @@ -1374,6 +1389,55 @@ mod tests { assert_eq!(fut3.await.unwrap()[0].len(), 100); } + #[tokio::test] + async fn test_object_store_selects_scheduler() { + // A memory:// store should use the standard scheduler when config is None + let memory_store = Arc::new(ObjectStore::memory()); + assert!(!memory_store.prefers_lite_scheduler()); + let config = SchedulerConfig { + io_buffer_size_bytes: 256 * 1024 * 1024, + use_lite_scheduler: None, + }; + let scheduler = ScanScheduler::new(memory_store.clone(), config); + assert!(!scheduler.uses_lite_scheduler()); + + // A file+uring:// store should use the lite scheduler when config is None + let uring_store = Arc::new(ObjectStore::new( + Arc::new(InMemory::new()), + Url::parse("file+uring:///tmp").unwrap(), + None, + None, + false, + false, + 8, + DEFAULT_DOWNLOAD_RETRY_COUNT, + None, + )); + assert!(uring_store.prefers_lite_scheduler()); + let config = SchedulerConfig { + io_buffer_size_bytes: 256 * 1024 * 1024, + use_lite_scheduler: None, + }; + let scheduler = ScanScheduler::new(uring_store.clone(), config); + assert!(scheduler.uses_lite_scheduler()); + + // Explicit Some(false) overrides a file+uring:// store's preference + let config = SchedulerConfig { + io_buffer_size_bytes: 256 * 1024 * 1024, + use_lite_scheduler: Some(false), + }; + let scheduler = ScanScheduler::new(uring_store, config); + assert!(!scheduler.uses_lite_scheduler()); + + // Explicit Some(true) overrides a memory:// store's preference + let config = SchedulerConfig { + io_buffer_size_bytes: 256 * 1024 * 1024, + use_lite_scheduler: Some(true), + }; + let scheduler = ScanScheduler::new(memory_store, config); + assert!(scheduler.uses_lite_scheduler()); + } + #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn stress_backpressure() { // This test ensures that the backpressure mechanism works correctly with @@ -1389,7 +1453,7 @@ mod tests { // Only one request will be allowed in let config = SchedulerConfig { io_buffer_size_bytes: 1, - use_lite_scheduler: false, + use_lite_scheduler: None, }; let scan_scheduler = ScanScheduler::new(obj_store.clone(), config); let file_scheduler = scan_scheduler From d13d7d88bb30176903f20556d07ebabcc1c41f51 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Thu, 2 Apr 2026 01:35:34 -0700 Subject: [PATCH 124/206] refactor!: cleanup namespace related APIs (#6186) 1. Remove `storage_options_provider` in python and java, because to make managed verisoning work, we have updated the codebase to pass namespace and table ID into the python and java binding layer. It becomes unnecessary for us to do a language specific `storage_options_provider` and then bind that to rust, because we can directly construct the rust `StorageOptionsProvider` using binded namespace client. 2. rename the following: `namespace` to `namespace_client`, `namespace_impl` to `namespace_client_impl`, `namespace_properties` to `namespace_client_properties`, `namespace` which means the namespace path to `namespace_path`. This is done for all code in rust, python, java. This rename is based on community feedback, and aims at clarifying the concept of Namespace Client SDK and its implementations vs the namespace path like `["ns1", "ns2"]`. 3. add `vend_input_storage_options` and `ops_metrics_enabled` so that we can now use DirectoryNamespace directory for testing all these changes made, without the need to rely on an extra tracking namespace. Update all tests accordingly to use the new feature. 4. fix the known bug that python and java binding for non-native namespace client implementation is not fully working with managed versioning due to binding level model conversion. --------- Co-authored-by: Claude Co-authored-by: Happy --- .github/workflows/python.yml | 2 +- java/lance-jni/src/blocking_dataset.rs | 218 +++-- java/lance-jni/src/fragment.rs | 132 ++- java/lance-jni/src/lib.rs | 1 - java/lance-jni/src/namespace.rs | 314 ++++--- java/lance-jni/src/storage_options.rs | 180 +--- java/lance-jni/src/transaction.rs | 58 +- java/lance-jni/src/utils.rs | 31 +- java/pom.xml | 4 +- .../main/java/org/lance/CommitBuilder.java | 57 +- java/src/main/java/org/lance/Dataset.java | 134 +-- java/src/main/java/org/lance/Fragment.java | 84 +- .../java/org/lance/OpenDatasetBuilder.java | 100 ++- java/src/main/java/org/lance/ReadOptions.java | 24 - .../java/org/lance/WriteDatasetBuilder.java | 145 ++-- .../java/org/lance/WriteFragmentBuilder.java | 42 +- .../org/lance/io/StorageOptionsProvider.java | 104 --- .../lance/namespace/DirectoryNamespace.java | 41 + .../LanceNamespaceStorageOptionsProvider.java | 123 --- .../java/org/lance/namespace/RestAdapter.java | 33 +- .../org/lance/namespace/RestNamespace.java | 31 + .../lance/CustomNamespaceIntegrationTest.java | 40 + ...=> DirectoryNamespaceIntegrationTest.java} | 446 +++++----- .../org/lance/namespace/CustomNamespace.java | 340 ++++++++ .../lance/namespace/CustomNamespaceTest.java | 55 ++ .../namespace/DirectoryNamespaceTest.java | 274 +++--- python/pyproject.toml | 2 +- python/python/lance/__init__.py | 66 +- python/python/lance/dataset.py | 145 ++-- python/python/lance/file.py | 52 +- python/python/lance/fragment.py | 59 +- python/python/lance/io.py | 134 +-- python/python/lance/lance/__init__.pyi | 17 +- python/python/lance/namespace.py | 209 ++--- python/python/lance/tf/data.py | 22 +- python/python/tests/test_namespace_dir.py | 607 ++++++++------ .../tests/test_namespace_integration.py | 665 ++++++++------- python/python/tests/test_namespace_rest.py | 24 +- python/python/tests/test_tf.py | 4 +- python/src/dataset.rs | 173 ++-- python/src/file.rs | 61 +- python/src/namespace.rs | 326 +++---- python/src/storage_options.rs | 199 +---- python/uv.lock | 8 +- rust/lance-datafusion/src/substrait.rs | 36 + .../src/object_store/storage_options.rs | 49 +- rust/lance-namespace-impls/src/dir.rs | 793 ++++++++++++++---- rust/lance-namespace-impls/src/lib.rs | 4 +- rust/lance-namespace-impls/src/rest.rs | 108 +++ rust/lance/src/dataset.rs | 28 +- rust/lance/src/dataset/builder.rs | 46 +- .../lance/src/io/commit/namespace_manifest.rs | 15 +- test_debug.py | 50 ++ 53 files changed, 3888 insertions(+), 3027 deletions(-) delete mode 100644 java/src/main/java/org/lance/io/StorageOptionsProvider.java delete mode 100644 java/src/main/java/org/lance/namespace/LanceNamespaceStorageOptionsProvider.java create mode 100644 java/src/test/java/org/lance/CustomNamespaceIntegrationTest.java rename java/src/test/java/org/lance/{NamespaceIntegrationTest.java => DirectoryNamespaceIntegrationTest.java} (80%) create mode 100644 java/src/test/java/org/lance/namespace/CustomNamespace.java create mode 100644 java/src/test/java/org/lance/namespace/CustomNamespaceTest.java create mode 100644 test_debug.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 1808fa1b995..e0867ea9ef9 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -63,7 +63,7 @@ jobs: prefix-key: ${{ env.CACHE_PREFIX }} - name: Install linting tools run: | - pip install ruff==0.11.2 maturin tensorflow tqdm ray[data] pyright datasets polars[pyarrow,pandas] + pip install ruff==0.11.2 maturin tensorflow tqdm ray[data] pyright datasets polars[pyarrow,pandas] lance-namespace pip install torch --index-url https://download.pytorch.org/whl/cpu - name: Lint Python run: | diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index 700cbdf308a..91fe5d3e715 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -7,7 +7,6 @@ use crate::namespace::{ BlockingDirectoryNamespace, BlockingRestNamespace, create_java_lance_namespace, }; use crate::session::{handle_from_session, session_from_handle}; -use crate::storage_options::JavaStorageOptionsProvider; use crate::traits::{FromJObjectWithEnv, FromJString, export_vec, import_vec, import_vec_to_rust}; use crate::utils::{ build_compaction_options, extract_storage_options, extract_write_params, @@ -51,7 +50,7 @@ use lance_index::optimize::OptimizeOptions; use lance_index::scalar::btree::BTreeParameters; use lance_index::{IndexParams, IndexType}; use lance_io::object_store::ObjectStoreRegistry; -use lance_io::object_store::StorageOptionsProvider; +use lance_io::object_store::{LanceNamespaceStorageOptionsProvider, StorageOptionsProvider}; use lance_namespace::LanceNamespace; use lance_table::io::commit::CommitHandler; use lance_table::io::commit::external_manifest::ExternalManifestCommitHandler; @@ -149,6 +148,7 @@ impl BlockingDataset { session: Option>, namespace: Option>, table_id: Option>, + namespace_client_managed_versioning: bool, ) -> Result { // Create storage options accessor from storage_options and provider let accessor = match (storage_options.is_empty(), storage_options_provider) { @@ -190,9 +190,11 @@ impl BlockingDataset { builder = builder.with_serialized_manifest(serialized_manifest)?; } - // Set up namespace commit handler if namespace and table_id are provided - if let (Some(ns), Some(tid)) = (namespace, table_id) { - let external_store = LanceNamespaceExternalManifestStore::new(ns, tid); + // Set up namespace commit handler only if namespace manages versioning + if namespace_client_managed_versioning + && let (Some(namespace_client), Some(tid)) = (namespace, table_id) + { + let external_store = LanceNamespaceExternalManifestStore::new(namespace_client, tid); let commit_handler: Arc = Arc::new(ExternalManifestCommitHandler { external_manifest_store: Arc::new(external_store), }); @@ -463,11 +465,11 @@ fn inner_create_with_ffi_schema<'local>( data_storage_version, enable_v2_manifest_paths, storage_options_obj, - JObject::null(), // No provider for schema-only creation initial_bases, target_bases, reader, - None, // No namespace for schema-only creation + None, // No namespace for schema-only creation + false, // No managed versioning for schema-only creation ) } @@ -503,66 +505,26 @@ fn inner_native_migrate_manifest_paths_v2(env: &mut JNIEnv, java_dataset: JObjec Ok(()) } -#[unsafe(no_mangle)] -pub extern "system" fn Java_org_lance_Dataset_createWithFfiStream<'local>( - mut env: JNIEnv<'local>, - _obj: JObject, - arrow_array_stream_addr: jlong, - path: JString, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - enable_v2_manifest_paths: JObject, // Optional - storage_options_obj: JObject, // Map - initial_bases: JObject, - target_bases: JObject, -) -> JObject<'local> { - ok_or_throw!( - env, - inner_create_with_ffi_stream( - &mut env, - arrow_array_stream_addr, - path, - max_rows_per_file, - max_rows_per_group, - max_bytes_per_file, - mode, - enable_stable_row_ids, - data_storage_version, - enable_v2_manifest_paths, - storage_options_obj, - JObject::null(), - initial_bases, - target_bases, - JObject::null(), // No namespace - JObject::null(), // No table_id - ) - ) -} - #[unsafe(no_mangle)] #[allow(clippy::too_many_arguments)] -pub extern "system" fn Java_org_lance_Dataset_createWithFfiStreamAndProvider<'local>( +pub extern "system" fn Java_org_lance_Dataset_createWithFfiStream<'local>( mut env: JNIEnv<'local>, _obj: JObject, arrow_array_stream_addr: jlong, path: JString, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - enable_v2_manifest_paths: JObject, // Optional - storage_options_obj: JObject, // Map - storage_options_provider_obj: JObject, // Optional - initial_bases: JObject, // Optional> - target_bases: JObject, // Optional> - namespace_obj: JObject, // LanceNamespace (can be null) - table_id_obj: JObject, // List (can be null) + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + enable_v2_manifest_paths: JObject, // Optional + storage_options_obj: JObject, // Map + initial_bases: JObject, // Optional> + target_bases: JObject, // Optional> + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) + namespace_client_managed_versioning: jboolean, // Whether namespace manages versioning ) -> JObject<'local> { ok_or_throw!( env, @@ -578,11 +540,11 @@ pub extern "system" fn Java_org_lance_Dataset_createWithFfiStreamAndProvider<'lo data_storage_version, enable_v2_manifest_paths, storage_options_obj, - storage_options_provider_obj, initial_bases, target_bases, namespace_obj, table_id_obj, + namespace_client_managed_versioning != 0, ) ) } @@ -592,19 +554,19 @@ fn inner_create_with_ffi_stream<'local>( env: &mut JNIEnv<'local>, arrow_array_stream_addr: jlong, path: JString, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - enable_v2_manifest_paths: JObject, // Optional - storage_options_obj: JObject, // Map - storage_options_provider_obj: JObject, // Optional - initial_bases: JObject, // Optional> - target_bases: JObject, // Optional> - namespace_obj: JObject, // LanceNamespace (can be null) - table_id_obj: JObject, // List (can be null) + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + enable_v2_manifest_paths: JObject, // Optional + storage_options_obj: JObject, // Map + initial_bases: JObject, // Optional> + target_bases: JObject, // Optional> + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) + namespace_client_managed_versioning: bool, // Whether namespace manages versioning ) -> Result> { let stream_ptr = arrow_array_stream_addr as *mut FFI_ArrowArrayStream; let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; @@ -623,14 +585,19 @@ fn inner_create_with_ffi_stream<'local>( data_storage_version, enable_v2_manifest_paths, storage_options_obj, - storage_options_provider_obj, initial_bases, target_bases, reader, namespace_info, + namespace_client_managed_versioning, ) } +/// Creates a dataset from a record batch reader. +/// +/// When `namespace_info` is provided, sets up the storage options provider for +/// credential refresh. When `namespace_client_managed_versioning` is true, also sets up the +/// commit handler for namespace-managed versioning. #[allow(clippy::too_many_arguments)] fn create_dataset<'local>( env: &mut JNIEnv<'local>, @@ -643,11 +610,11 @@ fn create_dataset<'local>( data_storage_version: JObject, enable_v2_manifest_paths: JObject, storage_options_obj: JObject, - storage_options_provider_obj: JObject, // Optional initial_bases: JObject, target_bases: JObject, reader: impl RecordBatchReader + Send + 'static, namespace_info: Option<(Arc, Vec)>, + namespace_client_managed_versioning: bool, ) -> Result> { let path_str = path.extract(env)?; @@ -661,18 +628,45 @@ fn create_dataset<'local>( &data_storage_version, Some(&enable_v2_manifest_paths), &storage_options_obj, - &storage_options_provider_obj, &initial_bases, &target_bases, )?; - // Set up namespace commit handler if provided + // Set up namespace commit handler and storage options provider if namespace is provided if let Some((namespace, table_id)) = namespace_info { - let external_store = LanceNamespaceExternalManifestStore::new(namespace, table_id); - let commit_handler: Arc = Arc::new(ExternalManifestCommitHandler { - external_manifest_store: Arc::new(external_store), + // Set up commit handler only if namespace manages versioning + if namespace_client_managed_versioning { + let external_store = + LanceNamespaceExternalManifestStore::new(namespace.clone(), table_id.clone()); + let commit_handler: Arc = Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }); + write_params.commit_handler = Some(commit_handler); + } + + // Set up storage options provider for credential refresh + let provider: Arc = Arc::new( + LanceNamespaceStorageOptionsProvider::new(namespace, table_id), + ); + + // Get existing storage options to combine with provider + let storage_options: HashMap = + extract_storage_options(env, &storage_options_obj)?; + + let accessor = if storage_options.is_empty() { + Arc::new(lance::io::StorageOptionsAccessor::with_provider(provider)) + } else { + Arc::new( + lance::io::StorageOptionsAccessor::with_initial_and_provider( + storage_options, + provider, + ), + ) + }; + write_params.store_params = Some(ObjectStoreParams { + storage_options_accessor: Some(accessor), + ..Default::default() }); - write_params.commit_handler = Some(commit_handler); } let dataset = BlockingDataset::write(reader, &path_str, Some(write_params))?; @@ -1205,12 +1199,12 @@ pub extern "system" fn Java_org_lance_Dataset_openNative<'local>( block_size_obj: JObject, // Optional index_cache_size_bytes: jlong, metadata_cache_size_bytes: jlong, - storage_options_obj: JObject, // Map - serialized_manifest: JObject, // Optional - storage_options_provider_obj: JObject, // Optional - session_handle: jlong, // Session handle, 0 means no session - namespace_obj: JObject, // LanceNamespace object, null if no namespace - table_id_obj: JObject, // List, null if no namespace + storage_options_obj: JObject, // Map + serialized_manifest: JObject, // Optional + session_handle: jlong, // Session handle, 0 means no session + namespace_obj: JObject, // LanceNamespace object, null if no namespace + table_id_obj: JObject, // List, null if no namespace + namespace_client_managed_versioning: jboolean, // Whether namespace manages versioning ) -> JObject<'local> { ok_or_throw!( env, @@ -1223,10 +1217,10 @@ pub extern "system" fn Java_org_lance_Dataset_openNative<'local>( metadata_cache_size_bytes, storage_options_obj, serialized_manifest, - storage_options_provider_obj, session_handle, namespace_obj, table_id_obj, + namespace_client_managed_versioning != 0, ) ) } @@ -1239,12 +1233,12 @@ fn inner_open_native<'local>( block_size_obj: JObject, // Optional index_cache_size_bytes: jlong, metadata_cache_size_bytes: jlong, - storage_options_obj: JObject, // Map - serialized_manifest: JObject, // Optional - storage_options_provider_obj: JObject, // Optional - session_handle: jlong, // Session handle, 0 means no session - namespace_obj: JObject, // LanceNamespace object, null if no namespace - table_id_obj: JObject, // List, null if no namespace + storage_options_obj: JObject, // Map + serialized_manifest: JObject, // Optional + session_handle: jlong, // Session handle, 0 means no session + namespace_obj: JObject, // LanceNamespace object, null if no namespace + table_id_obj: JObject, // List, null if no namespace + namespace_client_managed_versioning: bool, // Whether namespace manages versioning ) -> Result> { let path_str: String = path.extract(env)?; let version = env.get_u64_opt(&version_obj)?; @@ -1252,15 +1246,6 @@ fn inner_open_native<'local>( let jmap = JMap::from_env(env, &storage_options_obj)?; let storage_options = to_rust_map(env, &jmap)?; - // Extract storage options provider first (before get_bytes_opt which borrows env) - let storage_options_provider = env - .get_optional(&storage_options_provider_obj, |env, provider_obj| { - JavaStorageOptionsProvider::new(env, provider_obj) - })?; - - let storage_options_provider_arc = - storage_options_provider.map(|v| Arc::new(v) as Arc); - // Extract namespace and table_id if provided (before get_bytes_opt which holds borrow) let namespace_info = extract_namespace_info(env, &namespace_obj, &table_id_obj)?; let (namespace, table_id) = match namespace_info { @@ -1268,6 +1253,15 @@ fn inner_open_native<'local>( None => (None, None), }; + // When namespace is provided, automatically create a storage options provider + // for credential refresh + let storage_options_provider_arc: Option> = + if let (Some(ns), Some(tid)) = (namespace.clone(), table_id.clone()) { + Some(Arc::new(LanceNamespaceStorageOptionsProvider::new(ns, tid))) + } else { + None + }; + let serialized_manifest = env.get_bytes_opt(&serialized_manifest)?; // Convert session handle to Arc if provided @@ -1285,6 +1279,7 @@ fn inner_open_native<'local>( session, namespace, table_id, + namespace_client_managed_versioning, )?; dataset.into_java(env) } @@ -1331,20 +1326,21 @@ pub(crate) fn extract_namespace_info( return Ok(None); } - let namespace: Arc = if is_directory_namespace(env, namespace_obj)? { + let namespace_client: Arc = if is_directory_namespace(env, namespace_obj)? { let native_handle = get_native_namespace_handle(env, namespace_obj)?; - let ns = unsafe { &*(native_handle as *const BlockingDirectoryNamespace) }; - ns.inner.clone() + let dir_namespace_client = + unsafe { &*(native_handle as *const BlockingDirectoryNamespace) }; + dir_namespace_client.inner.clone() } else if is_rest_namespace(env, namespace_obj)? { let native_handle = get_native_namespace_handle(env, namespace_obj)?; - let ns = unsafe { &*(native_handle as *const BlockingRestNamespace) }; - ns.inner.clone() + let rest_namespace_client = unsafe { &*(native_handle as *const BlockingRestNamespace) }; + rest_namespace_client.inner.clone() } else { create_java_lance_namespace(env, namespace_obj)? }; let table_id = env.get_strings(table_id_obj)?; - Ok(Some((namespace, table_id))) + Ok(Some((namespace_client, table_id))) } #[unsafe(no_mangle)] diff --git a/java/lance-jni/src/fragment.rs b/java/lance-jni/src/fragment.rs index 05d71946a16..05b72c58a51 100644 --- a/java/lance-jni/src/fragment.rs +++ b/java/lance-jni/src/fragment.rs @@ -17,11 +17,17 @@ use lance_io::utils::CachedFileSize; use std::iter::once; use lance::dataset::fragment::FileFragment; +use lance::io::ObjectStoreParams; use lance_datafusion::utils::StreamingWriteSource; +use lance_io::object_store::{LanceNamespaceStorageOptionsProvider, StorageOptionsProvider}; +use std::collections::HashMap; +use std::sync::Arc; +use crate::blocking_dataset::extract_namespace_info; use crate::error::{Error, Result}; use crate::ffi::JNIEnvExt; use crate::traits::{FromJObjectWithEnv, IntoJava, JLance, export_vec, import_vec}; +use crate::utils::extract_storage_options; use crate::{ RT, blocking_dataset::{BlockingDataset, NATIVE_DATASET}, @@ -83,14 +89,15 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiArray<'local>( dataset_uri: JString, arrow_array_addr: jlong, arrow_schema_addr: jlong, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - storage_options_obj: JObject, // Map - storage_options_provider_obj: JObject, // Optional + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + storage_options_obj: JObject, // Map + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) ) -> JObject<'local> { ok_or_throw_with_return!( env, @@ -106,7 +113,8 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiArray<'local>( enable_stable_row_ids, data_storage_version, storage_options_obj, - storage_options_provider_obj, + namespace_obj, + table_id_obj, ), JObject::default() ) @@ -118,14 +126,15 @@ fn inner_create_with_ffi_array<'local>( dataset_uri: JString, arrow_array_addr: jlong, arrow_schema_addr: jlong, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - storage_options_obj: JObject, // Map - storage_options_provider_obj: JObject, // Optional + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + storage_options_obj: JObject, // Map + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) ) -> Result> { let c_array_ptr = arrow_array_addr as *mut FFI_ArrowArray; let c_schema_ptr = arrow_schema_addr as *mut FFI_ArrowSchema; @@ -150,7 +159,8 @@ fn inner_create_with_ffi_array<'local>( enable_stable_row_ids, data_storage_version, storage_options_obj, - storage_options_provider_obj, + namespace_obj, + table_id_obj, reader, ) } @@ -161,14 +171,15 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiStream<'a>( _obj: JObject, dataset_uri: JString, arrow_array_stream_addr: jlong, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - storage_options_obj: JObject, // Map - storage_options_provider_obj: JObject, // Optional + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + storage_options_obj: JObject, // Map + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) ) -> JObject<'a> { ok_or_throw_with_return!( env, @@ -183,7 +194,8 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiStream<'a>( enable_stable_row_ids, data_storage_version, storage_options_obj, - storage_options_provider_obj, + namespace_obj, + table_id_obj, ), JObject::null() ) @@ -194,14 +206,15 @@ fn inner_create_with_ffi_stream<'local>( env: &mut JNIEnv<'local>, dataset_uri: JString, arrow_array_stream_addr: jlong, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - storage_options_obj: JObject, // Map - storage_options_provider_obj: JObject, // Optional + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + storage_options_obj: JObject, // Map + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) ) -> Result> { let stream_ptr = arrow_array_stream_addr as *mut FFI_ArrowArrayStream; let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; @@ -216,7 +229,8 @@ fn inner_create_with_ffi_stream<'local>( enable_stable_row_ids, data_storage_version, storage_options_obj, - storage_options_provider_obj, + namespace_obj, + table_id_obj, reader, ) } @@ -225,19 +239,20 @@ fn inner_create_with_ffi_stream<'local>( fn create_fragment<'a>( env: &mut JNIEnv<'a>, dataset_uri: JString, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - storage_options_obj: JObject, // Map - storage_options_provider_obj: JObject, // Optional + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + storage_options_obj: JObject, // Map + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) source: impl StreamingWriteSource, ) -> Result> { let path_str = dataset_uri.extract(env)?; - let write_params = extract_write_params( + let mut write_params = extract_write_params( env, &max_rows_per_file, &max_rows_per_group, @@ -247,11 +262,36 @@ fn create_fragment<'a>( &data_storage_version, None, &storage_options_obj, - &storage_options_provider_obj, &JObject::null(), // not used when creating fragments &JObject::null(), // not used when creating fragments )?; + // Set up storage options provider if namespace is provided + let namespace_info = extract_namespace_info(env, &namespace_obj, &table_id_obj)?; + if let Some((namespace, table_id)) = namespace_info { + let provider: Arc = Arc::new( + LanceNamespaceStorageOptionsProvider::new(namespace, table_id), + ); + + let storage_options: HashMap = + extract_storage_options(env, &storage_options_obj)?; + + let accessor = if storage_options.is_empty() { + Arc::new(lance::io::StorageOptionsAccessor::with_provider(provider)) + } else { + Arc::new( + lance::io::StorageOptionsAccessor::with_initial_and_provider( + storage_options, + provider, + ), + ) + }; + write_params.store_params = Some(ObjectStoreParams { + storage_options_accessor: Some(accessor), + ..Default::default() + }); + } + let fragments = RT.block_on(FileFragment::create_fragments( &path_str, source, diff --git a/java/lance-jni/src/lib.rs b/java/lance-jni/src/lib.rs index 90be9b3ef80..7b3d40e6c00 100644 --- a/java/lance-jni/src/lib.rs +++ b/java/lance-jni/src/lib.rs @@ -67,7 +67,6 @@ mod vector_trainer; pub use error::Error; pub use error::Result; pub use ffi::JNIEnvExt; -pub use storage_options::JavaStorageOptionsProvider; use env_logger::{Builder, Env}; use std::env; diff --git a/java/lance-jni/src/namespace.rs b/java/lance-jni/src/namespace.rs index fa5a67437c7..7ee97587c06 100644 --- a/java/lance-jni/src/namespace.rs +++ b/java/lance-jni/src/namespace.rs @@ -8,12 +8,12 @@ use async_trait::async_trait; use bytes::Bytes; use jni::JNIEnv; use jni::objects::{GlobalRef, JByteArray, JMap, JObject, JString, JValue}; -use jni::sys::{jbyteArray, jlong, jstring}; +use jni::sys::{jbyteArray, jlong, jobject, jstring}; use lance_namespace::LanceNamespace as LanceNamespaceTrait; use lance_namespace::models::*; use lance_namespace_impls::{ - ConnectBuilder, DirectoryNamespaceBuilder, DynamicContextProvider, OperationInfo, RestAdapter, - RestAdapterConfig, RestNamespaceBuilder, + ConnectBuilder, DirectoryNamespace, DirectoryNamespaceBuilder, DynamicContextProvider, + OperationInfo, RestAdapter, RestAdapterConfig, RestNamespace, RestNamespaceBuilder, }; use serde::{Deserialize, Serialize}; @@ -117,14 +117,34 @@ fn convert_java_map_to_hashmap( Ok(result) } +/// Convert a Rust HashMap to a Java HashMap +fn rust_hashmap_to_java_map<'local>( + env: &mut JNIEnv<'local>, + map: &HashMap, +) -> Result> { + let java_map = env.new_object("java/util/HashMap", "()V", &[])?; + for (k, v) in map { + let jkey = env.new_string(k)?; + // Create a java.lang.Long object from the u64 value + let jval = env.new_object("java/lang/Long", "(J)V", &[JValue::Long(*v as i64)])?; + env.call_method( + &java_map, + "put", + "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", + &[JValue::Object(&jkey), JValue::Object(&jval)], + )?; + } + Ok(java_map) +} + /// Blocking wrapper for DirectoryNamespace pub struct BlockingDirectoryNamespace { - pub(crate) inner: Arc, + pub(crate) inner: Arc, } /// Blocking wrapper for RestNamespace pub struct BlockingRestNamespace { - pub(crate) inner: Arc, + pub(crate) inner: Arc, } // ============================================================================ @@ -1610,8 +1630,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listNamespace ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.list_namespaces(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_namespaces(req)) }), std::ptr::null_mut() ) @@ -1627,8 +1647,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeNames ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.describe_namespace(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.describe_namespace(req)) }), std::ptr::null_mut() ) @@ -1644,8 +1664,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createNamespa ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.create_namespace(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.create_namespace(req)) }), std::ptr::null_mut() ) @@ -1661,8 +1681,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_dropNamespace ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.drop_namespace(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.drop_namespace(req)) }), std::ptr::null_mut() ) @@ -1678,8 +1698,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_namespaceExis ) { ok_or_throw_without_return!( env, - call_namespace_void_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.namespace_exists(req)) + call_namespace_void_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.namespace_exists(req)) }) ) } @@ -1693,8 +1713,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listTablesNat ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.list_tables(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_tables(req)) }), std::ptr::null_mut() ) @@ -1710,8 +1730,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTable ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.describe_table(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.describe_table(req)) }), std::ptr::null_mut() ) @@ -1727,8 +1747,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_registerTable ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.register_table(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.register_table(req)) }), std::ptr::null_mut() ) @@ -1744,8 +1764,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_tableExistsNa ) { ok_or_throw_without_return!( env, - call_namespace_void_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.table_exists(req)) + call_namespace_void_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.table_exists(req)) }) ) } @@ -1759,8 +1779,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_dropTableNati ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.drop_table(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.drop_table(req)) }), std::ptr::null_mut() ) @@ -1776,8 +1796,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_deregisterTab ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.deregister_table(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.deregister_table(req)) }), std::ptr::null_mut() ) @@ -1813,7 +1833,9 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableNa handle, request_json, request_data, - |ns, req, data| { RT.block_on(ns.inner.create_table(req, data)) } + |namespace_client, req, data| { + RT.block_on(namespace_client.inner.create_table(req, data)) + } ), std::ptr::null_mut() ) @@ -1829,8 +1851,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_declareTableN ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.declare_table(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.declare_table(req)) }), std::ptr::null_mut() ) @@ -1852,7 +1874,9 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_insertIntoTab handle, request_json, request_data, - |ns, req, data| { RT.block_on(ns.inner.insert_into_table(req, data)) } + |namespace_client, req, data| { + RT.block_on(namespace_client.inner.insert_into_table(req, data)) + } ), std::ptr::null_mut() ) @@ -1874,7 +1898,9 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_mergeInsertIn handle, request_json, request_data, - |ns, req, data| { RT.block_on(ns.inner.merge_insert_into_table(req, data)) } + |namespace_client, req, data| { + RT.block_on(namespace_client.inner.merge_insert_into_table(req, data)) + } ), std::ptr::null_mut() ) @@ -1890,8 +1916,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_updateTableNa ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.update_table(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.update_table(req)) }), std::ptr::null_mut() ) @@ -1907,8 +1933,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_deleteFromTab ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.delete_from_table(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.delete_from_table(req)) }), std::ptr::null_mut() ) @@ -1939,8 +1965,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableIn ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.create_table_index(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.create_table_index(req)) }), std::ptr::null_mut() ) @@ -1956,8 +1982,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listTableIndi ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.list_table_indices(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_table_indices(req)) }), std::ptr::null_mut() ) @@ -1973,8 +1999,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTable ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.describe_table_index_stats(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.describe_table_index_stats(req)) }), std::ptr::null_mut() ) @@ -1990,8 +2016,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTrans ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.describe_transaction(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.describe_transaction(req)) }), std::ptr::null_mut() ) @@ -2007,8 +2033,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_alterTransact ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.alter_transaction(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.alter_transaction(req)) }), std::ptr::null_mut() ) @@ -2024,8 +2050,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listTableVers ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.list_table_versions(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_table_versions(req)) }), std::ptr::null_mut() ) @@ -2041,8 +2067,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableVe ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.create_table_version(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.create_table_version(req)) }), std::ptr::null_mut() ) @@ -2058,8 +2084,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTable ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.describe_table_version(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.describe_table_version(req)) }), std::ptr::null_mut() ) @@ -2075,14 +2101,45 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_batchDeleteTa ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.batch_delete_table_versions(req)) + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.batch_delete_table_versions(req)) }), std::ptr::null_mut() ) .into_raw() } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_retrieveOpsMetricsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, +) -> jobject { + ok_or_throw_with_return!( + env, + { + let namespace_client = unsafe { &*(handle as *const BlockingDirectoryNamespace) }; + let metrics = namespace_client.inner.retrieve_ops_metrics(); + rust_hashmap_to_java_map(&mut env, &metrics) + }, + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_resetOpsMetricsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, +) { + ok_or_throw_without_return!(env, { + let namespace_client = unsafe { &*(handle as *const BlockingDirectoryNamespace) }; + namespace_client.inner.reset_ops_metrics(); + Ok::<(), Error>(()) + }); +} + // ============================================================================ // RestNamespace JNI Functions // ============================================================================ @@ -2183,8 +2240,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_listNamespacesNati ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.list_namespaces(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_namespaces(req)) }), std::ptr::null_mut() ) @@ -2200,8 +2257,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeNamespaceN ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.describe_namespace(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.describe_namespace(req)) }), std::ptr::null_mut() ) @@ -2217,8 +2274,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_createNamespaceNat ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.create_namespace(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.create_namespace(req)) }), std::ptr::null_mut() ) @@ -2234,8 +2291,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_dropNamespaceNativ ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.drop_namespace(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.drop_namespace(req)) }), std::ptr::null_mut() ) @@ -2251,8 +2308,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_namespaceExistsNat ) { ok_or_throw_without_return!( env, - call_rest_namespace_void_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.namespace_exists(req)) + call_rest_namespace_void_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.namespace_exists(req)) }) ) } @@ -2266,8 +2323,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_listTablesNative( ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.list_tables(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_tables(req)) }), std::ptr::null_mut() ) @@ -2283,8 +2340,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTableNativ ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.describe_table(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.describe_table(req)) }), std::ptr::null_mut() ) @@ -2300,8 +2357,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_registerTableNativ ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.register_table(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.register_table(req)) }), std::ptr::null_mut() ) @@ -2317,8 +2374,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_tableExistsNative( ) { ok_or_throw_without_return!( env, - call_rest_namespace_void_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.table_exists(req)) + call_rest_namespace_void_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.table_exists(req)) }) ) } @@ -2332,8 +2389,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_dropTableNative( ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.drop_table(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.drop_table(req)) }), std::ptr::null_mut() ) @@ -2349,8 +2406,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_deregisterTableNat ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.deregister_table(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.deregister_table(req)) }), std::ptr::null_mut() ) @@ -2386,7 +2443,9 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableNative( handle, request_json, request_data, - |ns, req, data| { RT.block_on(ns.inner.create_table(req, data)) } + |namespace_client, req, data| { + RT.block_on(namespace_client.inner.create_table(req, data)) + } ), std::ptr::null_mut() ) @@ -2402,8 +2461,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_declareTableNative ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.declare_table(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.declare_table(req)) }), std::ptr::null_mut() ) @@ -2419,8 +2478,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_renameTableNative( ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.rename_table(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.rename_table(req)) }), std::ptr::null_mut() ) @@ -2442,7 +2501,9 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_insertIntoTableNat handle, request_json, request_data, - |ns, req, data| { RT.block_on(ns.inner.insert_into_table(req, data)) } + |namespace_client, req, data| { + RT.block_on(namespace_client.inner.insert_into_table(req, data)) + } ), std::ptr::null_mut() ) @@ -2464,7 +2525,9 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_mergeInsertIntoTab handle, request_json, request_data, - |ns, req, data| { RT.block_on(ns.inner.merge_insert_into_table(req, data)) } + |namespace_client, req, data| { + RT.block_on(namespace_client.inner.merge_insert_into_table(req, data)) + } ), std::ptr::null_mut() ) @@ -2480,8 +2543,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_updateTableNative( ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.update_table(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.update_table(req)) }), std::ptr::null_mut() ) @@ -2497,8 +2560,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_deleteFromTableNat ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.delete_from_table(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.delete_from_table(req)) }), std::ptr::null_mut() ) @@ -2529,8 +2592,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableIndexNa ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.create_table_index(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.create_table_index(req)) }), std::ptr::null_mut() ) @@ -2546,8 +2609,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_listTableIndicesNa ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.list_table_indices(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_table_indices(req)) }), std::ptr::null_mut() ) @@ -2563,8 +2626,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTableIndex ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.describe_table_index_stats(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.describe_table_index_stats(req)) }), std::ptr::null_mut() ) @@ -2580,8 +2643,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTransactio ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.describe_transaction(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.describe_transaction(req)) }), std::ptr::null_mut() ) @@ -2597,8 +2660,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_alterTransactionNa ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.alter_transaction(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.alter_transaction(req)) }), std::ptr::null_mut() ) @@ -2614,8 +2677,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_listTableVersionsN ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.list_table_versions(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_table_versions(req)) }), std::ptr::null_mut() ) @@ -2631,8 +2694,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableVersion ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.create_table_version(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.create_table_version(req)) }), std::ptr::null_mut() ) @@ -2648,8 +2711,8 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTableVersi ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.describe_table_version(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.describe_table_version(req)) }), std::ptr::null_mut() ) @@ -2665,14 +2728,45 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_batchDeleteTableVe ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.batch_delete_table_versions(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.batch_delete_table_versions(req)) }), std::ptr::null_mut() ) .into_raw() } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_retrieveOpsMetricsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, +) -> jobject { + ok_or_throw_with_return!( + env, + { + let namespace_client = unsafe { &*(handle as *const BlockingRestNamespace) }; + let metrics = namespace_client.inner.retrieve_ops_metrics(); + rust_hashmap_to_java_map(&mut env, &metrics) + }, + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_resetOpsMetricsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, +) { + ok_or_throw_without_return!(env, { + let namespace_client = unsafe { &*(handle as *const BlockingRestNamespace) }; + namespace_client.inner.reset_ops_metrics(); + Ok::<(), Error>(()) + }); +} + // ============================================================================ // Helper Functions // ============================================================================ @@ -2915,27 +3009,27 @@ pub struct BlockingRestAdapter { pub extern "system" fn Java_org_lance_namespace_RestAdapter_createNative( mut env: JNIEnv, _obj: JObject, - namespace_impl: JString, + namespace_client_impl: JString, properties_map: JObject, host: JString, port: JObject, ) -> jlong { ok_or_throw_with_return!( env, - create_rest_adapter_internal(&mut env, namespace_impl, properties_map, host, port), + create_rest_adapter_internal(&mut env, namespace_client_impl, properties_map, host, port), 0 ) } fn create_rest_adapter_internal( env: &mut JNIEnv, - namespace_impl: JString, + namespace_client_impl: JString, properties_map: JObject, host: JString, port: JObject, ) -> Result { - // Get namespace implementation type - let impl_str: String = env.get_string(&namespace_impl)?.into(); + // Get namespace client implementation type + let impl_str: String = env.get_string(&namespace_client_impl)?.into(); // Convert Java HashMap to Rust HashMap let jmap = JMap::from_env(env, &properties_map)?; diff --git a/java/lance-jni/src/storage_options.rs b/java/lance-jni/src/storage_options.rs index a5e05555a81..7ec607c26e7 100644 --- a/java/lance-jni/src/storage_options.rs +++ b/java/lance-jni/src/storage_options.rs @@ -1,180 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::collections::HashMap; -use std::sync::Arc; - -use async_trait::async_trait; -use jni::JNIEnv; -use jni::objects::{JMap, JObject, JString}; -use lance_io::object_store::StorageOptionsProvider; - -use crate::error::Result; - -/// Java-implemented storage options provider -/// -/// This wraps a Java object that implements the StorageOptionsProvider interface -/// and forwards get_storage_options() calls to the Java implementation. -pub struct JavaStorageOptionsProvider { - /// GlobalRef to the Java StorageOptionsProvider object - java_provider: jni::objects::GlobalRef, - /// JavaVM for making JNI calls - jvm: Arc, -} - -impl std::fmt::Debug for JavaStorageOptionsProvider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.provider_id()) - } -} - -impl std::fmt::Display for JavaStorageOptionsProvider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.provider_id()) - } -} - -impl JavaStorageOptionsProvider { - pub fn new(env: &mut JNIEnv, java_provider: JObject) -> Result { - // Create a global reference to the Java object so it persists - let java_provider = env.new_global_ref(java_provider)?; - - // Get the JavaVM for later JNI calls - let jvm = Arc::new(env.get_java_vm()?); - - Ok(Self { java_provider, jvm }) - } -} - -#[async_trait] -impl StorageOptionsProvider for JavaStorageOptionsProvider { - async fn fetch_storage_options(&self) -> lance_core::Result>> { - // Spawn blocking task to call Java method - let java_provider = self.java_provider.clone(); - let jvm = self.jvm.clone(); - - tokio::task::spawn_blocking(move || { - // Attach current thread to JVM - let mut env = jvm.attach_current_thread().map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to attach to JVM: {}", - e - )))) - })?; - - // Call fetchStorageOptions() method on Java object - // Returns Map with all storage options including optional EXPIRES_AT_MILLIS_KEY - // Or null if no storage options are available - let result = env - .call_method( - &java_provider, - "fetchStorageOptions", - "()Ljava/util/Map;", - &[], - ) - .map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to call fetchStorageOptions: {}", - e - )))) - })?; - - let result_obj = result.l().map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "fetchStorageOptions result is not an object: {}", - e - )))) - })?; - - // Check if result is null - if result_obj.is_null() { - return Ok(None); - } - - // Convert Java Map to Rust HashMap - let storage_options_map = JMap::from_env(&mut env, &result_obj).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "fetchStorageOptions result is not a Map: {}", - e - )))) - })?; - - let mut storage_options = HashMap::new(); - let mut iter = storage_options_map.iter(&mut env).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to iterate storage options: {}", - e - )))) - })?; - - while let Some((key, value)) = iter.next(&mut env).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to get next storage option entry: {}", - e - )))) - })? { - let key_str: String = env - .get_string(&JString::from(key)) - .map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "storage option key is not a string: {}", - e - )))) - })? - .into(); - - let value_str: String = env - .get_string(&JString::from(value)) - .map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "storage option value is not a string: {}", - e - )))) - })? - .into(); - - storage_options.insert(key_str, value_str); - } - - Ok(Some(storage_options)) - }) - .await - .map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to spawn blocking task: {}", - e - )))) - })? - } - - fn provider_id(&self) -> String { - // Call providerId() method on the Java object - // This should always succeed since StorageOptionsProvider.providerId() has a default implementation - let mut env = self - .jvm - .attach_current_thread() - .expect("Failed to attach to JVM"); - - let result = env - .call_method( - &self.java_provider, - "providerId", - "()Ljava/lang/String;", - &[], - ) - .expect("Failed to call providerId() on Java StorageOptionsProvider"); - - let result_obj = result.l().expect("providerId() did not return an object"); - - if result_obj.is_null() { - panic!("providerId() returned null"); - } - - let jstring = JString::from(result_obj); - let java_string = env - .get_string(&jstring) - .expect("Failed to convert Java string to Rust string"); - - java_string.into() - } -} +// This module is intentionally empty. +// Storage options providers are created directly in Rust from namespace clients +// using LanceNamespaceStorageOptionsProvider. diff --git a/java/lance-jni/src/transaction.rs b/java/lance-jni/src/transaction.rs index cdc5975bbfd..d46140020f0 100644 --- a/java/lance-jni/src/transaction.rs +++ b/java/lance-jni/src/transaction.rs @@ -6,7 +6,6 @@ use crate::JNIEnvExt; use crate::RT; use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET, extract_namespace_info}; use crate::error::Result; -use crate::storage_options::JavaStorageOptionsProvider; use crate::traits::{ FromJObjectWithEnv, FromJString, IntoJava, JLance, export_vec, import_vec_from_method, }; @@ -28,7 +27,7 @@ use lance::table::format::{Fragment, IndexMetadata}; use lance_core::datatypes::Field; use lance_core::datatypes::Schema as LanceSchema; use lance_file::version::LanceFileVersion; -use lance_io::object_store::StorageOptionsProvider; +use lance_io::object_store::{LanceNamespaceStorageOptionsProvider, StorageOptionsProvider}; use lance_table::io::commit::CommitHandler; use lance_table::io::commit::external_manifest::ExternalManifestCommitHandler; use prost::Message; @@ -625,6 +624,7 @@ pub extern "system" fn Java_org_lance_CommitBuilder_nativeCommitToDataset<'local skip_auto_cleanup: jboolean, namespace_obj: JObject, table_id_obj: JObject, + namespace_client_managed_versioning: jboolean, ) -> JObject<'local> { ok_or_throw!( env, @@ -641,6 +641,7 @@ pub extern "system" fn Java_org_lance_CommitBuilder_nativeCommitToDataset<'local skip_auto_cleanup != 0, namespace_obj, table_id_obj, + namespace_client_managed_versioning != 0, ) ) } @@ -659,6 +660,7 @@ fn inner_commit_to_dataset<'local>( skip_auto_cleanup: bool, namespace_obj: JObject, table_id_obj: JObject, + namespace_client_managed_versioning: bool, ) -> Result> { let write_param = if write_params_obj.is_null() { HashMap::new() @@ -751,14 +753,18 @@ fn inner_commit_to_dataset<'local>( Some(&mut java_blocking_ds), )?; - // Set namespace commit handler if provided + // Set namespace commit handler only if namespace_client_managed_versioning is true let namespace_info = extract_namespace_info(env, &namespace_obj, &table_id_obj)?; - let commit_handler = namespace_info.map(|(ns, tid)| { - let external_store = LanceNamespaceExternalManifestStore::new(ns, tid); - Arc::new(ExternalManifestCommitHandler { - external_manifest_store: Arc::new(external_store), - }) as Arc - }); + let commit_handler = if namespace_client_managed_versioning { + namespace_info.map(|(ns, tid)| { + let external_store = LanceNamespaceExternalManifestStore::new(ns, tid); + Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }) as Arc + }) + } else { + None + }; let new_blocking_ds = { let mut dataset_guard = @@ -1366,7 +1372,6 @@ pub extern "system" fn Java_org_lance_CommitBuilder_nativeCommitToUri<'local>( java_transaction: JObject, detached_jbool: jboolean, enable_v2_manifest_paths: jboolean, - storage_options_provider_obj: JObject, namespace_obj: JObject, table_id_obj: JObject, allocator_obj: JObject, @@ -1375,6 +1380,7 @@ pub extern "system" fn Java_org_lance_CommitBuilder_nativeCommitToUri<'local>( storage_format_obj: JObject, max_retries: jint, skip_auto_cleanup: jboolean, + namespace_client_managed_versioning: jboolean, ) -> JObject<'local> { ok_or_throw!( env, @@ -1384,7 +1390,6 @@ pub extern "system" fn Java_org_lance_CommitBuilder_nativeCommitToUri<'local>( java_transaction, detached_jbool != 0, enable_v2_manifest_paths != 0, - storage_options_provider_obj, namespace_obj, table_id_obj, allocator_obj, @@ -1393,6 +1398,7 @@ pub extern "system" fn Java_org_lance_CommitBuilder_nativeCommitToUri<'local>( storage_format_obj, max_retries as u32, skip_auto_cleanup != 0, + namespace_client_managed_versioning != 0, ) ) } @@ -1404,7 +1410,6 @@ fn inner_commit_to_uri<'local>( java_transaction: JObject, detached: bool, enable_v2_manifest_paths: bool, - storage_options_provider_obj: JObject, namespace_obj: JObject, table_id_obj: JObject, allocator_obj: JObject, @@ -1413,6 +1418,7 @@ fn inner_commit_to_uri<'local>( storage_format_obj: JObject, max_retries: u32, skip_auto_cleanup: bool, + namespace_client_managed_versioning: bool, ) -> Result> { let uri_str: String = uri.extract(env)?; @@ -1442,13 +1448,17 @@ fn inner_commit_to_uri<'local>( Some(parse_storage_format(&format_str)?) }; - // Build storage options accessor - let storage_options_provider: Option = env - .get_optional(&storage_options_provider_obj, |env, provider_obj| { - JavaStorageOptionsProvider::new(env, provider_obj) - })?; - let storage_options_provider = - storage_options_provider.map(|p| Arc::new(p) as Arc); + // Extract namespace info and create storage options provider if namespace is provided + let namespace_info = extract_namespace_info(env, &namespace_obj, &table_id_obj)?; + let storage_options_provider: Option> = + if let Some((ref ns, ref tid)) = namespace_info { + Some(Arc::new(LanceNamespaceStorageOptionsProvider::new( + ns.clone(), + tid.clone(), + ))) + } else { + None + }; // Keep a copy of initial options for opening the read dataset. let initial_storage_options = write_param.clone(); @@ -1471,9 +1481,8 @@ fn inner_commit_to_uri<'local>( ..Default::default() }; - let namespace_info = extract_namespace_info(env, &namespace_obj, &table_id_obj)?; let (open_namespace, open_table_id) = match &namespace_info { - Some((ns, tid)) => (Some(ns.clone()), Some(tid.clone())), + Some((namespace_client, tid)) => (Some(namespace_client.clone()), Some(tid.clone())), None => (None, None), }; @@ -1491,6 +1500,7 @@ fn inner_commit_to_uri<'local>( None, open_namespace, open_table_id, + namespace_client_managed_versioning, ) .ok(); @@ -1522,9 +1532,9 @@ fn inner_commit_to_uri<'local>( builder = builder.with_skip_auto_cleanup(true); } - // Set namespace commit handler if provided - if let Some((ns, tid)) = namespace_info { - let external_store = LanceNamespaceExternalManifestStore::new(ns, tid); + // Set namespace commit handler only if namespace_client_managed_versioning is true + if namespace_client_managed_versioning && let Some((namespace_client, tid)) = namespace_info { + let external_store = LanceNamespaceExternalManifestStore::new(namespace_client, tid); let commit_handler: Arc = Arc::new(ExternalManifestCommitHandler { external_manifest_store: Arc::new(external_store), }); diff --git a/java/lance-jni/src/utils.rs b/java/lance-jni/src/utils.rs index 4fe11b289e9..113769dd17f 100644 --- a/java/lance-jni/src/utils.rs +++ b/java/lance-jni/src/utils.rs @@ -23,11 +23,9 @@ use lance_linalg::distance::DistanceType; use crate::error::{Error, Result}; use crate::ffi::JNIEnvExt; -use crate::storage_options::JavaStorageOptionsProvider; use crate::traits::FromJObjectWithEnv; use lance_index::vector::Query; -use lance_io::object_store::StorageOptionsProvider; use std::collections::HashMap; use std::str::FromStr; @@ -51,9 +49,8 @@ pub fn extract_write_params( data_storage_version: &JObject, enable_v2_manifest_paths: Option<&JObject>, storage_options_obj: &JObject, - storage_options_provider_obj: &JObject, // Optional - initial_bases: &JObject, // Optional - target_bases: &JObject, // Optional + initial_bases: &JObject, // Optional + target_bases: &JObject, // Optional ) -> Result { let mut write_params = WriteParams::default(); @@ -90,13 +87,6 @@ pub fn extract_write_params( let storage_options: HashMap = extract_storage_options(env, storage_options_obj)?; - // Extract storage options provider if present - let storage_options_provider: Option> = env - .get_optional(storage_options_provider_obj, |env, provider_obj| { - JavaStorageOptionsProvider::new(env, provider_obj) - })? - .map(|p| Arc::new(p) as Arc); - if let Some(initial_bases) = env.get_list_opt(initial_bases, |env, elem| elem.extract_object(env))? { @@ -107,18 +97,13 @@ pub fn extract_write_params( write_params.target_base_names_or_paths = Some(names); } - // Create storage options accessor from storage_options and provider - let accessor = match (storage_options.is_empty(), storage_options_provider) { - (false, Some(provider)) => Some(Arc::new( - lance::io::StorageOptionsAccessor::with_initial_and_provider(storage_options, provider), - )), - (false, None) => Some(Arc::new( + // Create storage options accessor from static storage_options + let accessor = if storage_options.is_empty() { + None + } else { + Some(Arc::new( lance::io::StorageOptionsAccessor::with_static_options(storage_options), - )), - (true, Some(provider)) => Some(Arc::new(lance::io::StorageOptionsAccessor::with_provider( - provider, - ))), - (true, None) => None, + )) }; write_params.store_params = Some(ObjectStoreParams { diff --git a/java/pom.xml b/java/pom.xml index e31d77745b0..693beeefc69 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -109,12 +109,12 @@ org.lance lance-namespace-core - 0.5.2 + 0.6.1 org.lance lance-namespace-apache-client - 0.5.2 + 0.6.1 com.fasterxml.jackson.core diff --git a/java/src/main/java/org/lance/CommitBuilder.java b/java/src/main/java/org/lance/CommitBuilder.java index c56f1ab5631..fa083c9e93c 100644 --- a/java/src/main/java/org/lance/CommitBuilder.java +++ b/java/src/main/java/org/lance/CommitBuilder.java @@ -13,7 +13,6 @@ */ package org.lance; -import org.lance.io.StorageOptionsProvider; import org.lance.namespace.LanceNamespace; import org.apache.arrow.memory.BufferAllocator; @@ -65,9 +64,9 @@ public class CommitBuilder { private final BufferAllocator allocator; private Map writeParams; - private StorageOptionsProvider storageOptionsProvider; - private LanceNamespace namespace; + private LanceNamespace namespaceClient; private List tableId; + private boolean namespaceClientManagedVersioning = false; private boolean enableV2ManifestPaths = true; private boolean detached = false; private Boolean useStableRowIds; @@ -113,37 +112,43 @@ public CommitBuilder writeParams(Map writeParams) { } /** - * Set the storage options provider for credential refresh during URI-based commits. + * Set the namespace client for managed versioning. When set, commits are routed through the + * namespace client's {@code createTableVersion} API instead of writing directly to the object + * store. This is supported for both dataset-based and URI-based commits. * - * @param provider the storage options provider + * @param namespaceClient the LanceNamespace client instance * @return this builder instance */ - public CommitBuilder storageOptionsProvider(StorageOptionsProvider provider) { - this.storageOptionsProvider = provider; + public CommitBuilder namespaceClient(LanceNamespace namespaceClient) { + this.namespaceClient = namespaceClient; return this; } /** - * Set the namespace for managed versioning. When set, commits are routed through the namespace's - * {@code createTableVersion} API instead of writing directly to the object store. This is - * supported for both dataset-based and URI-based commits. + * Set the table ID for namespace client-based commit handling. * - * @param namespace the LanceNamespace instance + *

Must be provided together with `namespaceClient`. + * + * @param tableId the table identifier (e.g., ["workspace", "table_name"]) * @return this builder instance */ - public CommitBuilder namespace(LanceNamespace namespace) { - this.namespace = namespace; + public CommitBuilder tableId(List tableId) { + this.tableId = tableId; return this; } /** - * Set the table ID for namespace-based commit handling. + * Set whether namespace manages versioning. * - * @param tableId the table identifier (e.g., ["workspace", "table_name"]) + *

When true and namespaceClient/tableId are set, commits are routed through the namespace + * client's create_table_version API. This is typically set based on the managed_versioning field + * from describe_table or declare_table responses. + * + * @param namespaceClientManagedVersioning whether namespace manages versioning * @return this builder instance */ - public CommitBuilder tableId(List tableId) { - this.tableId = tableId; + public CommitBuilder namespaceClientManagedVersioning(boolean namespaceClientManagedVersioning) { + this.namespaceClientManagedVersioning = namespaceClientManagedVersioning; return this; } @@ -253,8 +258,9 @@ public Dataset execute(Transaction transaction) { storageFormat, maxRetries, skipAutoCleanup, - namespace, - tableId); + namespaceClient, + tableId, + namespaceClientManagedVersioning); result.setAllocator(dataset.allocator()); return result; } @@ -265,15 +271,15 @@ public Dataset execute(Transaction transaction) { transaction, detached, enableV2ManifestPaths, - storageOptionsProvider, - namespace, + namespaceClient, tableId, allocator, writeParams, useStableRowIds, storageFormat, maxRetries, - skipAutoCleanup); + skipAutoCleanup, + namespaceClientManagedVersioning); result.setAllocator(allocator); return result; } @@ -291,14 +297,14 @@ private static native Dataset nativeCommitToDataset( int maxRetries, boolean skipAutoCleanup, Object namespace, - Object tableId); + Object tableId, + boolean namespaceClientManagedVersioning); private static native Dataset nativeCommitToUri( String uri, Transaction transaction, boolean detached, boolean enableV2ManifestPaths, - Object storageOptionsProvider, Object namespace, Object tableId, Object allocator, @@ -306,5 +312,6 @@ private static native Dataset nativeCommitToUri( Boolean useStableRowIds, String storageFormat, int maxRetries, - boolean skipAutoCleanup); + boolean skipAutoCleanup, + boolean namespaceClientManagedVersioning); } diff --git a/java/src/main/java/org/lance/Dataset.java b/java/src/main/java/org/lance/Dataset.java index 5cba7696a76..bc7500270d7 100644 --- a/java/src/main/java/org/lance/Dataset.java +++ b/java/src/main/java/org/lance/Dataset.java @@ -24,7 +24,6 @@ import org.lance.index.IndexParams; import org.lance.index.IndexType; import org.lance.index.OptimizeOptions; -import org.lance.io.StorageOptionsProvider; import org.lance.ipc.DataStatistics; import org.lance.ipc.LanceScanner; import org.lance.ipc.ScanOptions; @@ -100,12 +99,12 @@ private Dataset() {} * .execute(); * } * - *

Example usage with namespace and empty table: + *

Example usage with namespaceClient and empty table: * *

{@code
    * Dataset dataset = Dataset.write()
    *     .schema(mySchema)
-   *     .namespace(myNamespace)
+   *     .namespaceClient(myNamespaceClient)
    *     .tableId(Arrays.asList("my_table"))
    *     .mode(WriteMode.CREATE)
    *     .execute();
@@ -169,32 +168,20 @@ public static Dataset create(
    *     Dataset.write().allocator(allocator).stream(stream).uri(path)
    *     .mode(WriteMode.CREATE).execute()}
    */
-  @Deprecated
-  public static Dataset create(
-      BufferAllocator allocator, ArrowArrayStream stream, String path, WriteParams params) {
-    return create(allocator, stream, path, params, null);
-  }
-
   /**
-   * Create a dataset with given stream and storage options provider.
-   *
-   * 

This method supports credential vending through the StorageOptionsProvider interface, which - * allows for dynamic credential refresh during long-running write operations. + * Create a dataset with given stream. * * @param allocator buffer allocator * @param stream arrow stream * @param path dataset uri * @param params write parameters - * @param storageOptionsProvider optional provider for dynamic storage options/credentials * @return Dataset + * @deprecated Use {@link #write()} builder instead. */ - static Dataset create( - BufferAllocator allocator, - ArrowArrayStream stream, - String path, - WriteParams params, - StorageOptionsProvider storageOptionsProvider) { - return create(allocator, stream, path, params, storageOptionsProvider, null, null); + @Deprecated + public static Dataset create( + BufferAllocator allocator, ArrowArrayStream stream, String path, WriteParams params) { + return create(allocator, stream, path, params, null, null, false); } private static native Dataset createWithFfiSchema( @@ -211,6 +198,26 @@ private static native Dataset createWithFfiSchema( Optional> initialBases, Optional> targetBases); + /** + * Creates a dataset from an FFI arrow stream. + * + * @param arrowStreamMemoryAddress memory address of the arrow stream + * @param path dataset uri + * @param maxRowsPerFile max rows per file + * @param maxRowsPerGroup max rows per group + * @param maxBytesPerFile max bytes per file + * @param mode write mode + * @param enableStableRowIds whether to enable stable row ids + * @param dataStorageVersion data storage version + * @param enableV2ManifestPaths whether to enable v2 manifest paths + * @param storageOptions storage options + * @param initialBases initial bases + * @param targetBases target bases + * @param namespaceClient optional namespace client for managed versioning and credential refresh + * (can be null) + * @param tableId optional table identifier within the namespace client (can be null) + * @return Dataset + */ private static native Dataset createWithFfiStream( long arrowStreamMemoryAddress, String path, @@ -223,38 +230,28 @@ private static native Dataset createWithFfiStream( Optional enableV2ManifestPaths, Map storageOptions, Optional> initialBases, - Optional> targetBases); - - private static native Dataset createWithFfiStreamAndProvider( - long arrowStreamMemoryAddress, - String path, - Optional maxRowsPerFile, - Optional maxRowsPerGroup, - Optional maxBytesPerFile, - Optional mode, - Optional enableStableRowIds, - Optional dataStorageVersion, - Optional enableV2ManifestPaths, - Map storageOptions, - Optional storageOptionsProvider, - Optional> initialBases, Optional> targetBases, - LanceNamespace namespace, - List tableId); + LanceNamespace namespaceClient, + List tableId, + boolean namespaceClientManagedVersioning); /** - * Creates a dataset with optional namespace support for managed versioning. + * Creates a dataset with optional namespace client support for managed versioning. * - *

When a namespace is provided, the commit handler will use the namespace's - * create_table_version method for version tracking. + *

When namespaceClient and tableId are provided, the Rust side will automatically create a + * storage options provider for credential refresh. When namespaceClientManagedVersioning is true, + * the commit handler will use the namespace client's create_table_version method for version + * tracking. * * @param allocator buffer allocator * @param stream arrow stream * @param path dataset uri * @param params write parameters - * @param storageOptionsProvider optional provider for dynamic storage options/credentials - * @param namespace optional namespace implementation for managed versioning (can be null) - * @param tableId optional table identifier within the namespace (can be null) + * @param namespaceClient optional namespace client for managed versioning and credential refresh + * (can be null) + * @param tableId optional table identifier within the namespace client (can be null) + * @param namespaceClientManagedVersioning whether namespace manages versioning (commits go + * through namespace API) * @return Dataset */ static Dataset create( @@ -262,15 +259,15 @@ static Dataset create( ArrowArrayStream stream, String path, WriteParams params, - StorageOptionsProvider storageOptionsProvider, - LanceNamespace namespace, - List tableId) { + LanceNamespace namespaceClient, + List tableId, + boolean namespaceClientManagedVersioning) { Preconditions.checkNotNull(allocator); Preconditions.checkNotNull(stream); Preconditions.checkNotNull(path); Preconditions.checkNotNull(params); Dataset dataset = - createWithFfiStreamAndProvider( + createWithFfiStream( stream.memoryAddress(), path, params.getMaxRowsPerFile(), @@ -281,11 +278,11 @@ static Dataset create( params.getDataStorageVersion(), params.getEnableV2ManifestPaths(), params.getStorageOptions(), - Optional.ofNullable(storageOptionsProvider), params.getInitialBases(), params.getTargetBases(), - namespace, - tableId); + namespaceClient, + tableId, + namespaceClientManagedVersioning); dataset.allocator = allocator; return dataset; } @@ -359,16 +356,22 @@ static Dataset open( String path, ReadOptions options, Session session) { - return open(allocator, selfManagedAllocator, path, options, session, null, null); + return open(allocator, selfManagedAllocator, path, options, session, null, null, false); } /** - * Open a dataset from the specified path with additional options and namespace commit handler. + * Open a dataset from the specified path with additional options and namespace client. + * + *

When namespaceClient and tableId are provided, the Rust side will automatically create a + * storage options provider for credential refresh. * * @param path file path * @param options the open options - * @param namespace the LanceNamespace to use for managed versioning (null if not using namespace) - * @param tableId table identifier (null if not using namespace) + * @param namespaceClient the LanceNamespace to use for managed versioning and credential refresh + * (null if not using namespace client) + * @param tableId table identifier (null if not using namespace client) + * @param namespaceClientManagedVersioning whether namespace manages versioning (commits go + * through namespace API) * @return Dataset */ static Dataset open( @@ -377,8 +380,9 @@ static Dataset open( String path, ReadOptions options, Session session, - LanceNamespace namespace, - List tableId) { + LanceNamespace namespaceClient, + List tableId, + boolean namespaceClientManagedVersioning) { Preconditions.checkNotNull(path); Preconditions.checkNotNull(allocator); Preconditions.checkNotNull(options); @@ -398,10 +402,10 @@ static Dataset open( options.getMetadataCacheSizeBytes(), options.getStorageOptions(), options.getSerializedManifest(), - options.getStorageOptionsProvider(), sessionHandle, - namespace, - tableId); + namespaceClient, + tableId, + namespaceClientManagedVersioning); dataset.allocator = allocator; dataset.selfManagedAllocator = selfManagedAllocator; if (effectiveSession != null) { @@ -421,10 +425,10 @@ private static native Dataset openNative( long metadataCacheSizeBytes, Map storageOptions, Optional serializedManifest, - Optional storageOptionsProvider, long sessionHandle, - LanceNamespace namespace, - List tableId); + LanceNamespace namespaceClient, + List tableId, + boolean namespaceClientManagedVersioning); /** * Creates a builder for opening a dataset. @@ -440,11 +444,11 @@ private static native Dataset openNative( * .build(); * }

* - *

Example usage with namespace: + *

Example usage with namespaceClient: * *

{@code
    * Dataset dataset = Dataset.open()
-   *     .namespace(myNamespace)
+   *     .namespaceClient(myNamespaceClient)
    *     .tableId(Arrays.asList("my_table"))
    *     .build();
    * }
diff --git a/java/src/main/java/org/lance/Fragment.java b/java/src/main/java/org/lance/Fragment.java index 8eb1f70053d..367e03b58d4 100644 --- a/java/src/main/java/org/lance/Fragment.java +++ b/java/src/main/java/org/lance/Fragment.java @@ -15,9 +15,9 @@ import org.lance.fragment.FragmentMergeResult; import org.lance.fragment.FragmentUpdateResult; -import org.lance.io.StorageOptionsProvider; import org.lance.ipc.LanceScanner; import org.lance.ipc.ScanOptions; +import org.lance.namespace.LanceNamespace; import org.apache.arrow.c.ArrowArray; import org.apache.arrow.c.ArrowArrayStream; @@ -234,30 +234,32 @@ public static WriteFragmentBuilder write() { @Deprecated public static List create( String datasetUri, BufferAllocator allocator, VectorSchemaRoot root, WriteParams params) { - return create(datasetUri, allocator, root, params, null); + return create(datasetUri, allocator, root, params, null, null); } /** - * Create a fragment from the given data with optional storage options provider. + * Create a fragment from the given arrow stream. * * @param datasetUri the dataset uri - * @param allocator the buffer allocator - * @param root the vector schema root + * @param stream the arrow stream * @param params the write params - * @param storageOptionsProvider optional provider for dynamic storage options with automatic - * credential refresh * @return the fragment metadata - * @deprecated Use {@link #write()} builder instead. For example: {@code Fragment.write() - * .datasetUri(uri).allocator(allocator).data(root).writeParams(params) - * .storageOptionsProvider(provider).execute()} + * @deprecated Use {@link #write()} builder instead. */ @Deprecated public static List create( + String datasetUri, ArrowArrayStream stream, WriteParams params) { + return create(datasetUri, stream, params, null, null); + } + + /** Create a fragment from the given arrow array and schema. */ + static List create( String datasetUri, BufferAllocator allocator, VectorSchemaRoot root, WriteParams params, - StorageOptionsProvider storageOptionsProvider) { + LanceNamespace namespaceClient, + List tableId) { Preconditions.checkNotNull(datasetUri); Preconditions.checkNotNull(allocator); Preconditions.checkNotNull(root); @@ -276,45 +278,18 @@ public static List create( params.getEnableStableRowIds(), params.getDataStorageVersion(), params.getStorageOptions(), - Optional.ofNullable(storageOptionsProvider)); + namespaceClient, + tableId); } } - /** - * Create a fragment from the given arrow stream. - * - * @param datasetUri the dataset uri - * @param stream the arrow stream - * @param params the write params - * @return the fragment metadata - * @deprecated Use {@link #write()} builder instead. For example: {@code Fragment.write() - * .datasetUri(uri).data(stream).writeParams(params).execute()} - */ - @Deprecated - public static List create( - String datasetUri, ArrowArrayStream stream, WriteParams params) { - return create(datasetUri, stream, params, null); - } - - /** - * Create a fragment from the given arrow stream with optional storage options provider. - * - * @param datasetUri the dataset uri - * @param stream the arrow stream - * @param params the write params - * @param storageOptionsProvider optional provider for dynamic storage options with automatic - * credential refresh - * @return the fragment metadata - * @deprecated Use {@link #write()} builder instead. For example: {@code - * Fragment.write().datasetUri(uri).data(stream).writeParams(params) - * .storageOptionsProvider(provider).execute()} - */ - @Deprecated - public static List create( + /** Create a fragment from the given arrow stream. */ + static List create( String datasetUri, ArrowArrayStream stream, WriteParams params, - StorageOptionsProvider storageOptionsProvider) { + LanceNamespace namespaceClient, + List tableId) { Preconditions.checkNotNull(datasetUri); Preconditions.checkNotNull(stream); Preconditions.checkNotNull(params); @@ -328,14 +303,11 @@ public static List create( params.getEnableStableRowIds(), params.getDataStorageVersion(), params.getStorageOptions(), - Optional.ofNullable(storageOptionsProvider)); + namespaceClient, + tableId); } - /** - * Create a fragment from the given arrow array and schema. - * - * @return the fragment metadata - */ + /** Create a fragment from the given arrow array and schema. */ private static native List createWithFfiArray( String datasetUri, long arrowArrayMemoryAddress, @@ -347,13 +319,10 @@ private static native List createWithFfiArray( Optional enableStableRowIds, Optional dataStorageVersion, Map storageOptions, - Optional storageOptionsProvider); + LanceNamespace namespaceClient, + List tableId); - /** - * Create a fragment from the given arrow stream. - * - * @return the fragment metadata - */ + /** Create a fragment from the given arrow stream. */ private static native List createWithFfiStream( String datasetUri, long arrowStreamMemoryAddress, @@ -364,5 +333,6 @@ private static native List createWithFfiStream( Optional enableStableRowIds, Optional dataStorageVersion, Map storageOptions, - Optional storageOptionsProvider); + LanceNamespace namespaceClient, + List tableId); } diff --git a/java/src/main/java/org/lance/OpenDatasetBuilder.java b/java/src/main/java/org/lance/OpenDatasetBuilder.java index 85bc19eac6e..4c07864091f 100644 --- a/java/src/main/java/org/lance/OpenDatasetBuilder.java +++ b/java/src/main/java/org/lance/OpenDatasetBuilder.java @@ -14,7 +14,6 @@ package org.lance; import org.lance.namespace.LanceNamespace; -import org.lance.namespace.LanceNamespaceStorageOptionsProvider; import org.lance.namespace.model.DescribeTableRequest; import org.lance.namespace.model.DescribeTableResponse; @@ -42,11 +41,11 @@ * .build(); * } * - *

Example usage with namespace: + *

Example usage with namespace client: * *

{@code
  * Dataset dataset = Dataset.open()
- *     .namespace(myNamespace)
+ *     .namespaceClient(myNamespaceClient)
  *     .tableId(Arrays.asList("my_table"))
  *     .build();
  * }
@@ -55,7 +54,7 @@ public class OpenDatasetBuilder { private BufferAllocator allocator; private boolean selfManagedAllocator = false; private String uri; - private LanceNamespace namespace; + private LanceNamespace namespaceClient; private List tableId; private ReadOptions options = new ReadOptions.Builder().build(); private Session session; @@ -79,7 +78,7 @@ public OpenDatasetBuilder allocator(BufferAllocator allocator) { /** * Sets the dataset URI. * - *

Either uri() or namespace()+tableId() must be specified, but not both. + *

Either uri() or namespaceClient()+tableId() must be specified, but not both. * * @param uri The dataset URI (e.g., "s3://bucket/table.lance" or "file:///path/to/table.lance") * @return this builder instance @@ -90,24 +89,24 @@ public OpenDatasetBuilder uri(String uri) { } /** - * Sets the namespace. + * Sets the namespace client. * - *

Must be used together with tableId(). Either uri() or namespace()+tableId() must be + *

Must be used together with tableId(). Either uri() or namespaceClient()+tableId() must be * specified, but not both. * - * @param namespace The namespace implementation to fetch table info from + * @param namespaceClient The namespace implementation to fetch table info from * @return this builder instance */ - public OpenDatasetBuilder namespace(LanceNamespace namespace) { - this.namespace = namespace; + public OpenDatasetBuilder namespaceClient(LanceNamespace namespaceClient) { + this.namespaceClient = namespaceClient; return this; } /** * Sets the table identifier. * - *

Must be used together with namespace(). Either uri() or namespace()+tableId() must be - * specified, but not both. + *

Must be used together with namespaceClient(). Either uri() or namespaceClient()+tableId() + * must be specified, but not both. * * @param tableId The table identifier (e.g., Arrays.asList("my_table")) * @return this builder instance @@ -149,32 +148,33 @@ public OpenDatasetBuilder session(Session session) { /** * Opens the dataset with the configured parameters. * - *

If a namespace is configured, this automatically fetches the table location and storage - * options from the namespace via describeTable(). + *

If a namespace client is configured, this automatically fetches the table location and + * storage options from the namespace client via describeTable(). * * @return Dataset * @throws IllegalArgumentException if required parameters are missing or invalid */ public Dataset build() { - // Validate that exactly one of uri or namespace+tableId is provided + // Validate that exactly one of uri or namespaceClient+tableId is provided boolean hasUri = uri != null; - boolean hasNamespace = namespace != null && tableId != null; + boolean hasNamespaceClient = namespaceClient != null && tableId != null; - if (hasUri && hasNamespace) { + if (hasUri && hasNamespaceClient) { throw new IllegalArgumentException( - "Cannot specify both uri and namespace+tableId. Use one or the other."); + "Cannot specify both uri and namespaceClient+tableId. Use one or the other."); } - if (!hasUri && !hasNamespace) { - if (namespace != null) { + if (!hasUri && !hasNamespaceClient) { + if (namespaceClient != null) { throw new IllegalArgumentException( - "namespace is set but tableId is missing. Both namespace and tableId must be" - + " provided together."); + "namespaceClient is set but tableId is missing. Both namespaceClient and tableId must" + + " be provided together."); } else if (tableId != null) { throw new IllegalArgumentException( - "tableId is set but namespace is missing. Both namespace and tableId must be" - + " provided together."); + "tableId is set but namespaceClient is missing. Both namespaceClient and tableId must" + + " be provided together."); } else { - throw new IllegalArgumentException("Either uri or namespace+tableId must be provided."); + throw new IllegalArgumentException( + "Either uri or namespaceClient+tableId must be provided."); } } @@ -186,31 +186,31 @@ public Dataset build() { selfManagedAllocator = true; } - // Handle namespace-based opening - if (hasNamespace) { - return buildFromNamespace(); + // Handle namespace client-based opening + if (hasNamespaceClient) { + return buildFromNamespaceClient(); } // Handle URI-based opening return Dataset.open(allocator, selfManagedAllocator, uri, options, session); } - private Dataset buildFromNamespace() { + private Dataset buildFromNamespaceClient() { // Call describe_table to get location and storage options DescribeTableRequest request = new DescribeTableRequest(); request.setId(tableId); // Only set version if present options.getVersion().ifPresent(v -> request.setVersion(Long.valueOf(v))); - DescribeTableResponse response = namespace.describeTable(request); + DescribeTableResponse response = namespaceClient.describeTable(request); String location = response.getLocation(); if (location == null || location.isEmpty()) { - throw new IllegalArgumentException("Namespace did not return a table location"); + throw new IllegalArgumentException("Namespace client did not return a table location"); } - // Check if namespace manages versioning (commits go through namespace API) - Boolean managedVersioning = response.getManagedVersioning(); + // Check if namespace client manages versioning (commits go through namespace client API) + Boolean namespaceClientManagedVersioning = response.getManagedVersioning(); Map namespaceStorageOptions = response.getStorageOptions(); @@ -219,12 +219,6 @@ private Dataset buildFromNamespace() { .setIndexCacheSizeBytes(options.getIndexCacheSizeBytes()) .setMetadataCacheSizeBytes(options.getMetadataCacheSizeBytes()); - if (namespaceStorageOptions != null && !namespaceStorageOptions.isEmpty()) { - LanceNamespaceStorageOptionsProvider storageOptionsProvider = - new LanceNamespaceStorageOptionsProvider(namespace, tableId); - optionsBuilder.setStorageOptionsProvider(storageOptionsProvider); - } - options.getVersion().ifPresent(optionsBuilder::setVersion); options.getBlockSize().ifPresent(optionsBuilder::setBlockSize); options.getSerializedManifest().ifPresent(optionsBuilder::setSerializedManifest); @@ -235,19 +229,19 @@ private Dataset buildFromNamespace() { } optionsBuilder.setStorageOptions(storageOptions); - // If managed_versioning is true, pass namespace for commit handler setup - if (Boolean.TRUE.equals(managedVersioning)) { - return Dataset.open( - allocator, - selfManagedAllocator, - location, - optionsBuilder.build(), - session, - namespace, - tableId); - } - - // Open dataset with regular open method (no namespace commit handler) - return Dataset.open(allocator, selfManagedAllocator, location, optionsBuilder.build(), session); + // Pass namespaceClient, tableId, and namespaceClientManagedVersioning to Rust + // The Rust side will: + // - Create a storage options provider when namespaceClient/tableId are non-null for credential + // refresh + // - Create an external manifest commit handler when namespaceClientManagedVersioning is true + return Dataset.open( + allocator, + selfManagedAllocator, + location, + optionsBuilder.build(), + session, + namespaceClient, + tableId, + Boolean.TRUE.equals(namespaceClientManagedVersioning)); } } diff --git a/java/src/main/java/org/lance/ReadOptions.java b/java/src/main/java/org/lance/ReadOptions.java index b9a244c55a5..8fd65c1e3e6 100644 --- a/java/src/main/java/org/lance/ReadOptions.java +++ b/java/src/main/java/org/lance/ReadOptions.java @@ -13,8 +13,6 @@ */ package org.lance; -import org.lance.io.StorageOptionsProvider; - import com.google.common.base.MoreObjects; import java.nio.ByteBuffer; @@ -31,7 +29,6 @@ public class ReadOptions { private final long metadataCacheSizeBytes; private final Optional serializedManifest; private final Map storageOptions; - private final Optional storageOptionsProvider; private final Optional session; private ReadOptions(Builder builder) { @@ -41,7 +38,6 @@ private ReadOptions(Builder builder) { this.metadataCacheSizeBytes = builder.metadataCacheSizeBytes; this.storageOptions = builder.storageOptions; this.serializedManifest = builder.serializedManifest; - this.storageOptionsProvider = builder.storageOptionsProvider; this.session = builder.session; } @@ -69,10 +65,6 @@ public Optional getSerializedManifest() { return serializedManifest; } - public Optional getStorageOptionsProvider() { - return storageOptionsProvider; - } - /** * Get the session to use for opening the dataset. * @@ -104,7 +96,6 @@ public static class Builder { private long metadataCacheSizeBytes = 1024L * 1024 * 1024; // Default to 1 GiB like Rust private Map storageOptions = new HashMap<>(); private Optional serializedManifest = Optional.empty(); - private Optional storageOptionsProvider = Optional.empty(); private Optional session = Optional.empty(); /** @@ -211,21 +202,6 @@ public Builder setSerializedManifest(ByteBuffer serializedManifest) { return this; } - /** - * Set a custom storage options provider for automatic storage options refresh. - * - *

The storage options provider will be called automatically before storage options expire, - * enabling long-running operations on cloud storage without interruption. This is currently - * only used for refreshing AWS temporary access credentials. - * - * @param storageOptionsProvider the storage options provider implementation - * @return this builder - */ - public Builder setStorageOptionsProvider(StorageOptionsProvider storageOptionsProvider) { - this.storageOptionsProvider = Optional.of(storageOptionsProvider); - return this; - } - /** * Set a session to share caches between multiple datasets. * diff --git a/java/src/main/java/org/lance/WriteDatasetBuilder.java b/java/src/main/java/org/lance/WriteDatasetBuilder.java index a95561acb2b..c0e5f8839c2 100644 --- a/java/src/main/java/org/lance/WriteDatasetBuilder.java +++ b/java/src/main/java/org/lance/WriteDatasetBuilder.java @@ -13,9 +13,7 @@ */ package org.lance; -import org.lance.io.StorageOptionsProvider; import org.lance.namespace.LanceNamespace; -import org.lance.namespace.LanceNamespaceStorageOptionsProvider; import org.lance.namespace.model.DeclareTableRequest; import org.lance.namespace.model.DeclareTableResponse; import org.lance.namespace.model.DescribeTableRequest; @@ -51,12 +49,12 @@ * .execute(); * } * - *

Example usage with namespace: + *

Example usage with namespace client: * *

{@code
  * Dataset dataset = Dataset.write(allocator)
  *     .reader(myReader)
- *     .namespace(myNamespace)
+ *     .namespaceClient(myNamespaceClient)
  *     .tableId(Arrays.asList("my_table"))
  *     .mode(WriteMode.CREATE)
  *     .execute();
@@ -67,7 +65,7 @@ public class WriteDatasetBuilder {
   private ArrowReader reader;
   private ArrowArrayStream stream;
   private String uri;
-  private LanceNamespace namespace;
+  private LanceNamespace namespaceClient;
   private List tableId;
   private WriteParams.WriteMode mode = WriteParams.WriteMode.CREATE;
   private Schema schema;
@@ -132,7 +130,7 @@ public WriteDatasetBuilder stream(ArrowArrayStream stream) {
   /**
    * Sets the dataset URI.
    *
-   * 

Either uri() or namespace()+tableId() must be specified, but not both. + *

Either uri() or namespaceClient()+tableId() must be specified, but not both. * * @param uri The dataset URI (e.g., "s3://bucket/table.lance" or "file:///path/to/table.lance") * @return this builder instance @@ -143,24 +141,24 @@ public WriteDatasetBuilder uri(String uri) { } /** - * Sets the namespace. + * Sets the namespace client. * - *

Must be used together with tableId(). Either uri() or namespace()+tableId() must be + *

Must be used together with tableId(). Either uri() or namespaceClient()+tableId() must be * specified, but not both. * - * @param namespace The namespace implementation to use for table operations + * @param namespaceClient The namespace implementation to use for table operations * @return this builder instance */ - public WriteDatasetBuilder namespace(LanceNamespace namespace) { - this.namespace = namespace; + public WriteDatasetBuilder namespaceClient(LanceNamespace namespaceClient) { + this.namespaceClient = namespaceClient; return this; } /** * Sets the table identifier. * - *

Must be used together with namespace(). Either uri() or namespace()+tableId() must be - * specified, but not both. + *

Must be used together with namespaceClient(). Either uri() or namespaceClient()+tableId() + * must be specified, but not both. * * @param tableId The table identifier (e.g., Arrays.asList("my_table")) * @return this builder instance @@ -207,10 +205,11 @@ public WriteDatasetBuilder storageOptions(Map storageOptions) { } /** - * Sets whether to ignore storage options from the namespace's describeTable() or declareTable(). + * Sets whether to ignore storage options from the namespace client's describeTable() or + * declareTable(). * - * @param ignoreNamespaceStorageOptions If true, storage options returned from namespace will be - * ignored + * @param ignoreNamespaceStorageOptions If true, storage options returned from namespace client + * will be ignored * @return this builder instance */ public WriteDatasetBuilder ignoreNamespaceStorageOptions(boolean ignoreNamespaceStorageOptions) { @@ -301,8 +300,9 @@ public WriteDatasetBuilder session(Session session) { /** * Executes the write operation and returns the created dataset. * - *

If a namespace is configured via namespace()+tableId(), this automatically handles table - * creation or retrieval through the namespace API with credential vending support. + *

If a namespace client is configured via namespaceClient()+tableId(), this automatically + * handles table creation or retrieval through the namespace client API with credential vending + * support. * * @return Dataset * @throws IllegalArgumentException if required parameters are missing or invalid @@ -313,23 +313,24 @@ public Dataset execute() { allocator = new RootAllocator(Long.MAX_VALUE); } - // Validate that exactly one of uri or namespace is provided + // Validate that exactly one of uri or namespaceClient is provided boolean hasUri = uri != null; - boolean hasNamespace = namespace != null && tableId != null; + boolean hasNamespaceClient = namespaceClient != null && tableId != null; - if (hasUri && hasNamespace) { + if (hasUri && hasNamespaceClient) { throw new IllegalArgumentException( - "Cannot specify both uri() and namespace()+tableId(). Use one or the other."); + "Cannot specify both uri() and namespaceClient()+tableId(). Use one or the other."); } - if (!hasUri && !hasNamespace) { - if (namespace != null) { + if (!hasUri && !hasNamespaceClient) { + if (namespaceClient != null) { throw new IllegalArgumentException( - "namespace() is set but tableId() is missing. Both must be provided together."); + "namespaceClient() is set but tableId() is missing. Both must be provided together."); } else if (tableId != null) { throw new IllegalArgumentException( - "tableId() is set but namespace() is missing. Both must be provided together."); + "tableId() is set but namespaceClient() is missing. Both must be provided together."); } else { - throw new IllegalArgumentException("Either uri() or namespace()+tableId() must be called."); + throw new IllegalArgumentException( + "Either uri() or namespaceClient()+tableId() must be called."); } } @@ -349,51 +350,53 @@ public Dataset execute() { + "Use only one of: reader(), stream(), or schema()."); } - // Handle namespace-based writing - if (hasNamespace) { - return executeWithNamespace(); + // Handle namespace client-based writing + if (hasNamespaceClient) { + return executeWithNamespaceClient(); } // Handle URI-based writing return executeWithUri(); } - private Dataset executeWithNamespace() { + private Dataset executeWithNamespaceClient() { String tableUri; Map namespaceStorageOptions = null; - boolean managedVersioning = false; + boolean namespaceClientManagedVersioning = false; - // Mode-specific namespace operations + // Mode-specific namespace client operations if (mode == WriteParams.WriteMode.CREATE) { DeclareTableRequest declareRequest = new DeclareTableRequest(); declareRequest.setId(tableId); - DeclareTableResponse declareResponse = namespace.declareTable(declareRequest); + DeclareTableResponse declareResponse = namespaceClient.declareTable(declareRequest); tableUri = declareResponse.getLocation(); if (tableUri == null || tableUri.isEmpty()) { - throw new IllegalArgumentException("Namespace did not return a table location"); + throw new IllegalArgumentException("Namespace client did not return a table location"); } - managedVersioning = Boolean.TRUE.equals(declareResponse.getManagedVersioning()); + namespaceClientManagedVersioning = + Boolean.TRUE.equals(declareResponse.getManagedVersioning()); namespaceStorageOptions = ignoreNamespaceStorageOptions ? null : declareResponse.getStorageOptions(); } else { - // For APPEND/OVERWRITE modes, call namespace.describeTable() + // For APPEND/OVERWRITE modes, call namespaceClient.describeTable() DescribeTableRequest request = new DescribeTableRequest(); request.setId(tableId); - DescribeTableResponse response = namespace.describeTable(request); + DescribeTableResponse response = namespaceClient.describeTable(request); tableUri = response.getLocation(); if (tableUri == null || tableUri.isEmpty()) { - throw new IllegalArgumentException("Namespace did not return a table location"); + throw new IllegalArgumentException("Namespace client did not return a table location"); } namespaceStorageOptions = ignoreNamespaceStorageOptions ? null : response.getStorageOptions(); - managedVersioning = Boolean.TRUE.equals(response.getManagedVersioning()); + namespaceClientManagedVersioning = Boolean.TRUE.equals(response.getManagedVersioning()); } - // Merge storage options (namespace options + user options, with namespace taking precedence) + // Merge storage options (namespace client options + user options, with namespace client taking + // precedence) Map mergedStorageOptions = new HashMap<>(storageOptions); if (namespaceStorageOptions != null && !namespaceStorageOptions.isEmpty()) { mergedStorageOptions.putAll(namespaceStorageOptions); @@ -414,18 +417,21 @@ private Dataset executeWithNamespace() { WriteParams params = paramsBuilder.build(); - // Create storage options provider for credential refresh during long-running writes - StorageOptionsProvider storageOptionsProvider = - ignoreNamespaceStorageOptions - ? null - : new LanceNamespaceStorageOptionsProvider(namespace, tableId); - - // Only use namespace for commit handling if managedVersioning is enabled - if (managedVersioning) { - return createDatasetWithStreamAndNamespace( - tableUri, params, storageOptionsProvider, namespace, tableId); + // Pass namespaceClient, tableId, and namespaceClientManagedVersioning to JNI + // Rust will automatically create a storage options provider when namespaceClient/tableId + // are non-null for credential refresh, and will create an external manifest commit handler + // when namespaceClientManagedVersioning is true + if (namespaceClientManagedVersioning) { + return createDatasetWithStreamAndNamespaceClient( + tableUri, params, namespaceClient, tableId, true); } else { - return createDatasetWithStream(tableUri, params, storageOptionsProvider); + // Even without managed versioning, pass namespaceClient for credential refresh + // when namespace client vends credentials (storage options was non-null) + if (!ignoreNamespaceStorageOptions && namespaceStorageOptions != null) { + return createDatasetWithStreamAndNamespaceClient( + tableUri, params, namespaceClient, tableId, false); + } + return createDatasetWithStream(tableUri, params); } } @@ -443,21 +449,20 @@ private Dataset executeWithUri() { WriteParams params = paramsBuilder.build(); - return createDatasetWithStream(uri, params, null); + return createDatasetWithStream(uri, params); } - private Dataset createDatasetWithStream( - String path, WriteParams params, StorageOptionsProvider storageOptionsProvider) { + private Dataset createDatasetWithStream(String path, WriteParams params) { // If stream is directly provided, use it if (stream != null) { - return Dataset.create(allocator, stream, path, params, storageOptionsProvider); + return Dataset.create(allocator, stream, path, params); } // If reader is provided, convert to stream if (reader != null) { try (ArrowArrayStream tempStream = ArrowArrayStream.allocateNew(allocator)) { Data.exportArrayStream(allocator, reader, tempStream); - return Dataset.create(allocator, tempStream, path, params, storageOptionsProvider); + return Dataset.create(allocator, tempStream, path, params); } } @@ -469,16 +474,22 @@ private Dataset createDatasetWithStream( throw new IllegalStateException("No data source provided"); } - private Dataset createDatasetWithStreamAndNamespace( + private Dataset createDatasetWithStreamAndNamespaceClient( String path, WriteParams params, - StorageOptionsProvider storageOptionsProvider, - LanceNamespace namespace, - List tableId) { + LanceNamespace namespaceClient, + List tableId, + boolean namespaceClientManagedVersioning) { // If stream is directly provided, use it if (stream != null) { return Dataset.create( - allocator, stream, path, params, storageOptionsProvider, namespace, tableId); + allocator, + stream, + path, + params, + namespaceClient, + tableId, + namespaceClientManagedVersioning); } // If reader is provided, convert to stream @@ -486,12 +497,18 @@ private Dataset createDatasetWithStreamAndNamespace( try (ArrowArrayStream tempStream = ArrowArrayStream.allocateNew(allocator)) { Data.exportArrayStream(allocator, reader, tempStream); return Dataset.create( - allocator, tempStream, path, params, storageOptionsProvider, namespace, tableId); + allocator, + tempStream, + path, + params, + namespaceClient, + tableId, + namespaceClientManagedVersioning); } } // If only schema is provided (empty table), use Dataset.create with schema - // Note: Schema-only creation doesn't support namespace-based commit handling + // Note: Schema-only creation doesn't support namespace client-based commit handling if (schema != null) { return Dataset.create(allocator, path, schema, params); } diff --git a/java/src/main/java/org/lance/WriteFragmentBuilder.java b/java/src/main/java/org/lance/WriteFragmentBuilder.java index 42ccf2d8dd3..693b5b6bc87 100644 --- a/java/src/main/java/org/lance/WriteFragmentBuilder.java +++ b/java/src/main/java/org/lance/WriteFragmentBuilder.java @@ -13,7 +13,7 @@ */ package org.lance; -import org.lance.io.StorageOptionsProvider; +import org.lance.namespace.LanceNamespace; import org.apache.arrow.c.ArrowArrayStream; import org.apache.arrow.memory.BufferAllocator; @@ -47,7 +47,8 @@ public class WriteFragmentBuilder { private ArrowArrayStream arrowArrayStream; private WriteParams writeParams; private WriteParams.Builder writeParamsBuilder; - private StorageOptionsProvider storageOptionsProvider; + private LanceNamespace namespaceClient; + private List tableId; WriteFragmentBuilder() {} @@ -123,13 +124,30 @@ public WriteFragmentBuilder storageOptions(Map storageOptions) { } /** - * Set the storage options provider for dynamic credential refresh. + * Set the namespace client for automatic credential refresh. * - * @param provider the storage options provider + *

When provided with `tableId`, a storage options provider will be created automatically to + * refresh credentials via the namespace client. Must be provided together with `tableId`. The + * caller should provide initial/merged storage options via the `storageOptions` method. + * + * @param namespaceClient the LanceNamespace client instance + * @return this builder + */ + public WriteFragmentBuilder namespaceClient(LanceNamespace namespaceClient) { + this.namespaceClient = namespaceClient; + return this; + } + + /** + * Set the table ID for namespace client-based credential refresh. + * + *

Must be provided together with `namespaceClient`. + * + * @param tableId the table identifier (e.g., ["workspace", "table_name"]) * @return this builder */ - public WriteFragmentBuilder storageOptionsProvider(StorageOptionsProvider provider) { - this.storageOptionsProvider = provider; + public WriteFragmentBuilder tableId(List tableId) { + this.tableId = tableId; return this; } @@ -213,15 +231,17 @@ public WriteFragmentBuilder dataStorageVersion(String version) { public List execute() { validate(); - // Build the write params if builder was used + // Build the write params WriteParams finalWriteParams = buildWriteParams(); + // Pass namespaceClient and tableId to JNI - Rust will automatically create a + // storage options provider when these are non-null for credential refresh if (vectorSchemaRoot != null) { return Fragment.create( - datasetUri, allocator, vectorSchemaRoot, finalWriteParams, storageOptionsProvider); + datasetUri, allocator, vectorSchemaRoot, finalWriteParams, namespaceClient, tableId); } else { return Fragment.create( - datasetUri, arrowArrayStream, finalWriteParams, storageOptionsProvider); + datasetUri, arrowArrayStream, finalWriteParams, namespaceClient, tableId); } } @@ -255,5 +275,9 @@ private void validate() { Preconditions.checkState( writeParams == null || writeParamsBuilder == null, "Cannot use both writeParams() and individual parameter methods"); + Preconditions.checkState( + (namespaceClient == null && tableId == null) + || (namespaceClient != null && tableId != null), + "Both 'namespaceClient' and 'tableId' must be provided together"); } } diff --git a/java/src/main/java/org/lance/io/StorageOptionsProvider.java b/java/src/main/java/org/lance/io/StorageOptionsProvider.java deleted file mode 100644 index 3876ca1fb06..00000000000 --- a/java/src/main/java/org/lance/io/StorageOptionsProvider.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.lance.io; - -import java.util.Map; - -/** - * Interface for providing cloud storage options to Lance datasets. - * - *

Storage options providers enable automatic refresh for long-running operations on cloud - * storage (S3, Azure, GCS). This is currently only used for refreshing AWS temporary access - * credentials. Implement this interface to integrate with custom credential management systems such - * as AWS STS, GCP STS, or proprietary credential services. - * - *

The provider is called automatically before storage options expire, ensuring uninterrupted - * access during long-running queries, training jobs, or data processing. - * - *

Example Implementation

- * - *
{@code
- * public class MyStorageOptionsProvider implements StorageOptionsProvider {
- *   public Map fetchStorageOptions() {
- *     // Fetch from your credential service
- *     Map credentials = new HashMap<>();
- *     credentials.put("aws_access_key_id", "ASIA...");
- *     credentials.put("aws_secret_access_key", "secret");
- *     credentials.put("aws_session_token", "token");
- *
- *     long expiresAtMillis = System.currentTimeMillis() + 3600000L;
- *     credentials.put("expires_at_millis", String.valueOf(expiresAtMillis));
- *
- *     return credentials;
- *   }
- * }
- *
- * // Use with dataset
- * StorageOptionsProvider vendor = new MyStorageOptionsProvider();
- * Dataset dataset = Dataset.open(
- *     "s3://bucket/table.lance",
- *     new ReadOptions.Builder()
- *         .setStorageOptionsProvider(vendor)
- *         .build()
- * );
- * }
- * - *

Error Handling

- * - *

If fetchStorageOptions() throws an exception, operations requiring credentials will fail. - * Implementations should handle recoverable errors internally (e.g., retry token refresh) and only - * throw exceptions for unrecoverable errors. - */ -public interface StorageOptionsProvider { - - /** - * Fetch fresh storage credentials. - * - *

This method is called automatically before each request and before existing credentials - * expire. It must return credentials in the format described below. - * - * @return Map of string key-value pairs containing cloud storage credentials and expiration time. - * Required key: - *

    - *
  • "expires_at_millis" (String): Unix timestamp in milliseconds (as string) when - * credentials expire. Lance will automatically call fetchStorageOptions() again before - * this time. - *
- * Plus provider-specific credential keys: - *
    - *
  • AWS S3: "aws_access_key_id", "aws_secret_access_key", "aws_session_token" (optional) - *
  • Azure Blob Storage: "account_name", "account_key" or "sas_token" - *
  • Google Cloud Storage: "service_account_key" or "token" - *
- * - * @throws RuntimeException if unable to fetch credentials - */ - Map fetchStorageOptions(); - - /** - * Return a human-readable unique identifier for this provider instance. - * - *

This is used for equality comparison and hashing in the object store registry. Two providers - * with the same ID will be treated as equal and share the same cached ObjectStore instance. - * - *

The default implementation uses the class name and toString() representation. Override this - * method to provide semantic equality based on configuration. - * - * @return A human-readable unique identifier string. For example: "MyProvider { endpoint: - * 'https://api.example.com' }" - */ - default String providerId() { - return this.getClass().getSimpleName() + " { repr: \"" + this.toString() + "\" }"; - } -} diff --git a/java/src/main/java/org/lance/namespace/DirectoryNamespace.java b/java/src/main/java/org/lance/namespace/DirectoryNamespace.java index b811a57920b..744357e817e 100644 --- a/java/src/main/java/org/lance/namespace/DirectoryNamespace.java +++ b/java/src/main/java/org/lance/namespace/DirectoryNamespace.java @@ -90,6 +90,16 @@ *

  • credential_vendor.azure_duration_millis (optional): Duration in ms (default: 3600000, * up to 7 days) * + *
  • Testing properties: + *
      + *
    • ops_metrics_enabled (optional): "true" to enable operation metrics tracking. Use + * {@link #retrieveOpsMetrics()} to get call counts. + *
    • vend_input_storage_options (optional): "true" to return input storage options in + * describeTable() when no credential vendor is configured. Useful for testing. + *
    • vend_input_storage_options_refresh_interval_millis (optional): When set with + * vend_input_storage_options, adds expires_at_millis to storage options. Value is + * current_time_millis + this interval. + *
    * * *

    Example usage (local filesystem): @@ -452,6 +462,33 @@ public long getNativeHandle() { return nativeDirectoryNamespaceHandle; } + // Operation metrics methods + + /** + * Retrieve operation metrics as a map. + * + *

    Returns a map where keys are operation names (e.g., "list_tables", "describe_table") and + * values are the number of times each operation was called. + * + *

    Returns an empty map if {@code ops_metrics_enabled} was false when creating the namespace. + * + * @return operation name to call count mapping + */ + public Map retrieveOpsMetrics() { + ensureInitialized(); + return retrieveOpsMetricsNative(nativeDirectoryNamespaceHandle); + } + + /** + * Reset all operation metrics counters to zero. + * + *

    Does nothing if {@code ops_metrics_enabled} was false when creating the namespace. + */ + public void resetOpsMetrics() { + ensureInitialized(); + resetOpsMetricsNative(nativeDirectoryNamespaceHandle); + } + private void ensureInitialized() { if (nativeDirectoryNamespaceHandle == 0) { throw new IllegalStateException( @@ -542,6 +579,10 @@ private native String mergeInsertIntoTableNative( private native String batchDeleteTableVersionsNative(long handle, String requestJson); + private native Map retrieveOpsMetricsNative(long handle); + + private native void resetOpsMetricsNative(long handle); + // ========================================================================== // Provider loading helpers // ========================================================================== diff --git a/java/src/main/java/org/lance/namespace/LanceNamespaceStorageOptionsProvider.java b/java/src/main/java/org/lance/namespace/LanceNamespaceStorageOptionsProvider.java deleted file mode 100644 index fb65e235c36..00000000000 --- a/java/src/main/java/org/lance/namespace/LanceNamespaceStorageOptionsProvider.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.lance.namespace; - -import org.lance.io.StorageOptionsProvider; -import org.lance.namespace.model.DescribeTableRequest; -import org.lance.namespace.model.DescribeTableResponse; - -import java.util.List; -import java.util.Map; - -/** - * Storage options provider that fetches storage options from a LanceNamespace. - * - *

    This provider automatically fetches fresh storage options by calling the namespace's - * describeTable() method, which returns both the table location and time-limited storage options. - * This is currently only used for refreshing AWS temporary access credentials. - * - *

    This is the recommended approach for LanceDB Cloud and other namespace-based deployments, as - * it handles storage options refresh automatically. - * - *

    Example Usage

    - * - *
    {@code
    - * // Connect to a namespace (e.g., LanceDB Cloud)
    - * LanceNamespace namespace = LanceNamespaces.connect("rest", Map.of(
    - *     "url", "https://api.lancedb.com",
    - *     "api_key", "your-api-key"
    - * ));
    - *
    - * // Create storage options provider
    - * LanceNamespaceStorageOptionsProvider provider = new LanceNamespaceStorageOptionsProvider(
    - *     namespace,
    - *     Arrays.asList("workspace", "table_name")
    - * );
    - *
    - * // Use with dataset - storage options auto-refresh!
    - * Dataset dataset = Dataset.open(
    - *     "s3://bucket/table.lance",
    - *     new ReadOptions.Builder()
    - *         .setStorageOptionsProvider(provider)
    - *         .build()
    - * );
    - * }
    - */ -public class LanceNamespaceStorageOptionsProvider implements StorageOptionsProvider { - - private final LanceNamespace namespace; - private final List tableId; - - /** - * Create a storage options provider that fetches storage options from a LanceNamespace. - * - * @param namespace The namespace instance to fetch storage options from - * @param tableId The table identifier (e.g., ["workspace", "table_name"]) - */ - public LanceNamespaceStorageOptionsProvider(LanceNamespace namespace, List tableId) { - this.namespace = namespace; - this.tableId = tableId; - } - - /** - * Fetch credentials from the namespace. - * - *

    This calls namespace.describeTable() to get the latest credentials and optionally their - * expiration time. - * - * @return Flat map of string key-value pairs containing credentials. May optionally include - * expires_at_millis. If expires_at_millis is not provided, credentials are treated as - * non-expiring and will not be automatically refreshed. - * @throws RuntimeException if the namespace doesn't return storage credentials - */ - @Override - public Map fetchStorageOptions() { - // Create describe table request with table ID - DescribeTableRequest request = new DescribeTableRequest(); - request.setId(tableId); - - // Call namespace to describe the table and get credentials - DescribeTableResponse response = namespace.describeTable(request); - - // Extract storage options - should already be a flat Map - Map storageOptions = response.getStorageOptions(); - if (storageOptions == null || storageOptions.isEmpty()) { - throw new RuntimeException( - "Namespace did not return storage_options. " - + "Ensure the namespace supports credential vending."); - } - - // Return storage_options directly - it's already a flat Map - // Note: expires_at_millis is optional. If not provided, credentials are treated - // as non-expiring and will not be automatically refreshed. - return storageOptions; - } - - /** - * Return a human-readable unique identifier for this provider instance. - * - *

    This creates a semantic ID based on the namespace's ID and the table ID, enabling proper - * equality comparison and caching. - * - * @return A human-readable unique identifier string combining namespace and table info - */ - @Override - public String providerId() { - // Call namespaceId() on the namespace (requires lance-namespace >= 0.0.20) - String namespaceId = namespace.namespaceId(); - return String.format( - "LanceNamespaceStorageOptionsProvider { namespace: %s, table_id: %s }", - namespaceId, tableId); - } -} diff --git a/java/src/main/java/org/lance/namespace/RestAdapter.java b/java/src/main/java/org/lance/namespace/RestAdapter.java index 534a7eabb9e..5d22c2962a3 100644 --- a/java/src/main/java/org/lance/namespace/RestAdapter.java +++ b/java/src/main/java/org/lance/namespace/RestAdapter.java @@ -58,16 +58,17 @@ public class RestAdapter implements Closeable, AutoCloseable { /** * Creates a new REST adapter with the given backend namespace. * - * @param namespaceImpl The namespace implementation type (e.g., "dir" for DirectoryNamespace) - * @param backendConfig Configuration properties for the backend namespace + * @param namespaceClientImpl The namespace client implementation type (e.g., "dir" for + * DirectoryNamespace) + * @param backendConfig Configuration properties for the backend namespace client * @param host Host to bind the server to, or null for default (127.0.0.1) * @param port Port to bind the server to. Use 0 to let the OS assign an available port, or null * for default (2333). */ public RestAdapter( - String namespaceImpl, Map backendConfig, String host, Integer port) { - if (namespaceImpl == null || namespaceImpl.isEmpty()) { - throw new IllegalArgumentException("namespace implementation cannot be null or empty"); + String namespaceClientImpl, Map backendConfig, String host, Integer port) { + if (namespaceClientImpl == null || namespaceClientImpl.isEmpty()) { + throw new IllegalArgumentException("namespace client implementation cannot be null or empty"); } if (backendConfig == null) { throw new IllegalArgumentException("backend config cannot be null"); @@ -76,19 +77,29 @@ public RestAdapter( throw new IllegalArgumentException("port must be between 0 and 65535"); } - this.nativeRestAdapterHandle = createNative(namespaceImpl, backendConfig, host, port); + this.nativeRestAdapterHandle = createNative(namespaceClientImpl, backendConfig, host, port); } /** * Creates a new REST adapter with default host and port. * - * @param namespaceImpl The namespace implementation type - * @param backendConfig Configuration properties for the backend namespace + * @param namespaceClientImpl The namespace client implementation type + * @param backendConfig Configuration properties for the backend namespace client */ - public RestAdapter(String namespaceImpl, Map backendConfig) { - this(namespaceImpl, backendConfig, null, null); + public RestAdapter(String namespaceClientImpl, Map backendConfig) { + this(namespaceClientImpl, backendConfig, null, null); } + /** + * Creates a new REST adapter with the given backend namespace. + * + * @param namespaceImpl The namespace implementation type (e.g., "dir" for DirectoryNamespace) + * @param backendConfig Configuration properties for the backend namespace + * @param host Host to bind the server to, or null for default (127.0.0.1) + * @param port Port to bind the server to. Use 0 to let the OS assign an available port, or null + * for default (2333). + */ + /** * Start the REST server in the background. * @@ -144,7 +155,7 @@ public void close() { // Native methods private native long createNative( - String namespaceImpl, Map backendConfig, String host, Integer port); + String namespaceClientImpl, Map backendConfig, String host, Integer port); private native void start(long handle); diff --git a/java/src/main/java/org/lance/namespace/RestNamespace.java b/java/src/main/java/org/lance/namespace/RestNamespace.java index 8763512b321..421bd01e3d4 100644 --- a/java/src/main/java/org/lance/namespace/RestNamespace.java +++ b/java/src/main/java/org/lance/namespace/RestNamespace.java @@ -377,6 +377,33 @@ public long getNativeHandle() { return nativeRestNamespaceHandle; } + // Operation metrics methods + + /** + * Retrieve operation metrics as a map. + * + *

    Returns a map where keys are operation names (e.g., "list_tables", "describe_table") and + * values are the number of times each operation was called. + * + *

    Returns an empty map if {@code ops_metrics_enabled} was false when creating the namespace. + * + * @return operation name to call count mapping + */ + public Map retrieveOpsMetrics() { + ensureInitialized(); + return retrieveOpsMetricsNative(nativeRestNamespaceHandle); + } + + /** + * Reset all operation metrics counters to zero. + * + *

    Does nothing if {@code ops_metrics_enabled} was false when creating the namespace. + */ + public void resetOpsMetrics() { + ensureInitialized(); + resetOpsMetricsNative(nativeRestNamespaceHandle); + } + private void ensureInitialized() { if (nativeRestNamespaceHandle == 0) { throw new IllegalStateException("RestNamespace not initialized. Call initialize() first."); @@ -468,6 +495,10 @@ private native String mergeInsertIntoTableNative( private native String batchDeleteTableVersionsNative(long handle, String requestJson); + private native Map retrieveOpsMetricsNative(long handle); + + private native void resetOpsMetricsNative(long handle); + // ========================================================================== // Provider loading helpers // ========================================================================== diff --git a/java/src/test/java/org/lance/CustomNamespaceIntegrationTest.java b/java/src/test/java/org/lance/CustomNamespaceIntegrationTest.java new file mode 100644 index 00000000000..a836d48bf3f --- /dev/null +++ b/java/src/test/java/org/lance/CustomNamespaceIntegrationTest.java @@ -0,0 +1,40 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.lance; + +import org.lance.namespace.CustomNamespace; +import org.lance.namespace.DirectoryNamespace; +import org.lance.namespace.LanceNamespace; + +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +/** + * Integration tests for CustomNamespace wrapper around DirectoryNamespace. + * + *

    This test class extends DirectoryNamespaceIntegrationTest to verify that all tests pass when + * using a CustomNamespace wrapper around DirectoryNamespace. This validates that the Java-Rust + * binding works correctly for custom namespace implementations in integration scenarios. + * + *

    These tests require LocalStack to be running. Run with: docker compose up -d + * + *

    Set LANCE_INTEGRATION_TEST=1 environment variable to enable these tests. + */ +@EnabledIfEnvironmentVariable(named = "LANCE_INTEGRATION_TEST", matches = "1") +public class CustomNamespaceIntegrationTest extends DirectoryNamespaceIntegrationTest { + + @Override + protected LanceNamespace wrapNamespace(DirectoryNamespace inner) { + return new CustomNamespace(inner); + } +} diff --git a/java/src/test/java/org/lance/NamespaceIntegrationTest.java b/java/src/test/java/org/lance/DirectoryNamespaceIntegrationTest.java similarity index 80% rename from java/src/test/java/org/lance/NamespaceIntegrationTest.java rename to java/src/test/java/org/lance/DirectoryNamespaceIntegrationTest.java index dd2ad7539f3..e6728f3e244 100644 --- a/java/src/test/java/org/lance/NamespaceIntegrationTest.java +++ b/java/src/test/java/org/lance/DirectoryNamespaceIntegrationTest.java @@ -15,15 +15,12 @@ import org.lance.namespace.DirectoryNamespace; import org.lance.namespace.LanceNamespace; -import org.lance.namespace.LanceNamespaceStorageOptionsProvider; import org.lance.namespace.errors.LanceNamespaceException; import org.lance.namespace.model.CreateNamespaceRequest; import org.lance.namespace.model.CreateTableRequest; import org.lance.namespace.model.CreateTableResponse; import org.lance.namespace.model.DeclareTableRequest; import org.lance.namespace.model.DeclareTableResponse; -import org.lance.namespace.model.DescribeTableRequest; -import org.lance.namespace.model.DescribeTableResponse; import org.lance.namespace.model.DropTableRequest; import org.lance.namespace.model.DropTableResponse; import org.lance.namespace.model.TableExistsRequest; @@ -79,15 +76,15 @@ /** * Integration tests for Lance with S3 and credential refresh using StorageOptionsProvider. * - *

    This test simulates a tracking credential provider that returns incrementing credentials and - * verifies that the credential refresh mechanism works correctly. + *

    This test uses DirectoryNamespace with native ops_metrics and vend_input_storage_options + * features to track API calls and test credential refresh mechanisms. * *

    These tests require LocalStack to be running. Run with: docker compose up -d * *

    Set LANCE_INTEGRATION_TEST=1 environment variable to enable these tests. */ @EnabledIfEnvironmentVariable(named = "LANCE_INTEGRATION_TEST", matches = "1") -public class NamespaceIntegrationTest { +public class DirectoryNamespaceIntegrationTest { private static final String ENDPOINT_URL = "http://localhost:4566"; private static final String REGION = "us-east-1"; @@ -161,105 +158,108 @@ private static void deleteBucket() { } /** - * Tracking LanceNamespace implementation for testing. - * - *

    This implementation wraps DirectoryNamespace and tracks API calls. It returns incrementing - * credentials with expiration timestamps to test the credential refresh mechanism. + * Result holder for namespace creation that includes both the namespace to use for operations and + * the inner DirectoryNamespace for metrics retrieval. */ - static class TrackingNamespace implements LanceNamespace { - private final String bucketName; - private final Map baseStorageOptions; - private final int credentialExpiresInSeconds; - private final AtomicInteger describeCallCount = new AtomicInteger(0); - private final AtomicInteger createCallCount = new AtomicInteger(0); - private final DirectoryNamespace inner; - - public TrackingNamespace( - String bucketName, Map storageOptions, int credentialExpiresInSeconds) { - this.bucketName = bucketName; - this.baseStorageOptions = new HashMap<>(storageOptions); - this.credentialExpiresInSeconds = credentialExpiresInSeconds; - - // Create underlying DirectoryNamespace with storage options - Map dirProps = new HashMap<>(); - for (Map.Entry entry : storageOptions.entrySet()) { - dirProps.put("storage." + entry.getKey(), entry.getValue()); - } + protected static class TrackingNamespaceResult { + /** The namespace client to use for operations. May be wrapped. */ + public final LanceNamespace namespaceClient; - // Set root based on bucket type - if (bucketName.startsWith("/") || bucketName.startsWith("file://")) { - dirProps.put("root", bucketName + "/namespace_root"); - } else { - dirProps.put("root", "s3://" + bucketName + "/namespace_root"); - } + /** The inner DirectoryNamespace for metrics retrieval. */ + public final DirectoryNamespace innerNamespaceClient; - this.inner = new DirectoryNamespace(); - try (BufferAllocator allocator = new RootAllocator()) { - this.inner.initialize(dirProps, allocator); - } - } - - public int getDescribeCallCount() { - return describeCallCount.get(); - } - - public int getCreateCallCount() { - return createCallCount.get(); - } - - @Override - public void initialize(Map configProperties, BufferAllocator allocator) { - // Already initialized in constructor + public TrackingNamespaceResult( + LanceNamespace namespaceClient, DirectoryNamespace innerNamespaceClient) { + this.namespaceClient = namespaceClient; + this.innerNamespaceClient = innerNamespaceClient; } + } - @Override - public String namespaceId() { - return "TrackingNamespace { inner: " + inner.namespaceId() + " }"; + /** + * Creates a DirectoryNamespace configured for testing with ops metrics and credential vending. + * + *

    Uses native DirectoryNamespace features: + * + *

      + *
    • ops_metrics_enabled=true: Tracks API call counts via retrieveOpsMetrics() + *
    • vend_input_storage_options=true: Returns input storage options in responses + *
    • vend_input_storage_options_refresh_interval_millis: Adds expires_at_millis + *
    + * + * @param bucketName S3 bucket name or local path + * @param storageOptions Storage options to pass through (credentials, endpoint, etc.) + * @param credentialExpiresInSeconds Interval in seconds for credential expiration + * @return TrackingNamespaceResult containing the namespace and inner DirectoryNamespace + */ + protected TrackingNamespaceResult createTrackingNamespace( + String bucketName, Map storageOptions, int credentialExpiresInSeconds) { + Map dirProps = new HashMap<>(); + + // Add refresh_offset_millis to storage options so that credentials are not + // considered expired immediately. Set to 1 second (1000ms) so that refresh + // checks work correctly with short-lived credentials in tests. + Map storageOptionsWithRefresh = new HashMap<>(storageOptions); + storageOptionsWithRefresh.put("refresh_offset_millis", "1000"); + + for (Map.Entry entry : storageOptionsWithRefresh.entrySet()) { + dirProps.put("storage." + entry.getKey(), entry.getValue()); } - /** - * Simulates a credential vendor returning only vended credentials. - * - *

    Returns only credential keys with expiration metadata. Clients are expected to provide - * their own connection config (endpoint, region, allow_http) via storageOptions. - * - * @param count Call count to use for credential generation - * @return Storage options with vended credentials - */ - private Map vendStorageOptions(int count) { - Map options = new HashMap<>(); - - options.put("aws_access_key_id", "AKID_" + count); - options.put("aws_secret_access_key", "SECRET_" + count); - options.put("aws_session_token", "TOKEN_" + count); - - long expiresAtMillis = System.currentTimeMillis() + (credentialExpiresInSeconds * 1000L); - options.put("expires_at_millis", String.valueOf(expiresAtMillis)); - // Set refresh offset to 1 second (1000ms) for short-lived credential tests - options.put("refresh_offset_millis", "1000"); - - return options; + // Set root based on bucket type + if (bucketName.startsWith("/") || bucketName.startsWith("file://")) { + dirProps.put("root", bucketName + "/namespace_root"); + } else { + dirProps.put("root", "s3://" + bucketName + "/namespace_root"); } - @Override - public DeclareTableResponse declareTable(DeclareTableRequest request) { - int count = createCallCount.incrementAndGet(); - - DeclareTableResponse response = inner.declareTable(request); - response.setStorageOptions(vendStorageOptions(count)); + // Enable ops metrics tracking + dirProps.put("ops_metrics_enabled", "true"); + // Enable storage options vending + dirProps.put("vend_input_storage_options", "true"); + // Set refresh interval in milliseconds + dirProps.put( + "vend_input_storage_options_refresh_interval_millis", + String.valueOf(credentialExpiresInSeconds * 1000L)); - return response; + DirectoryNamespace innerNamespaceClient = new DirectoryNamespace(); + try (BufferAllocator allocator = new RootAllocator()) { + innerNamespaceClient.initialize(dirProps, allocator); } + LanceNamespace namespaceClient = wrapNamespace(innerNamespaceClient); + return new TrackingNamespaceResult(namespaceClient, innerNamespaceClient); + } - @Override - public DescribeTableResponse describeTable(DescribeTableRequest request) { - int count = describeCallCount.incrementAndGet(); + /** + * Factory method to wrap the DirectoryNamespace. Subclasses can override this to provide a custom + * namespace implementation. + * + * @param inner The DirectoryNamespace to wrap + * @return The namespace to use in tests (may be the same as inner or a wrapper) + */ + protected LanceNamespace wrapNamespace(DirectoryNamespace inner) { + return inner; + } - DescribeTableResponse response = inner.describeTable(request); - response.setStorageOptions(vendStorageOptions(count)); + /** + * Gets the number of describe_table calls made to the namespace. + * + * @param namespaceClient The DirectoryNamespace to check + * @return Number of describe_table calls + */ + protected static int getDescribeCallCount(DirectoryNamespace namespaceClient) { + Map metrics = namespaceClient.retrieveOpsMetrics(); + return metrics.getOrDefault("describe_table", 0L).intValue(); + } - return response; - } + /** + * Gets the number of declare_table calls made to the namespace. + * + * @param namespaceClient The DirectoryNamespace to check + * @return Number of declare_table calls + */ + protected static int getDeclareCallCount(DirectoryNamespace namespaceClient) { + Map metrics = namespaceClient.retrieveOpsMetrics(); + return metrics.getOrDefault("declare_table", 0L).intValue(); } @Test @@ -274,7 +274,9 @@ void testOpenDatasetWithoutRefresh() throws Exception { storageOptions.put("aws_region", REGION); // Create tracking namespace with 60-second expiration (long enough to not expire during test) - TrackingNamespace namespace = new TrackingNamespace(BUCKET_NAME, storageOptions, 60); + TrackingNamespaceResult nsResult = createTrackingNamespace(BUCKET_NAME, storageOptions, 60); + LanceNamespace namespaceClient = nsResult.namespaceClient; + DirectoryNamespace innerNamespaceClient = nsResult.innerNamespaceClient; String tableName = UUID.randomUUID().toString(); // Create schema and data @@ -333,12 +335,12 @@ public VectorSchemaRoot getVectorSchemaRoot() { } }; - // Create dataset through namespace + // Create dataset through namespace client try (Dataset dataset = Dataset.write() .allocator(allocator) .reader(testReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) .storageOptions(storageOptions) @@ -348,22 +350,23 @@ public VectorSchemaRoot getVectorSchemaRoot() { } // Verify declareTable was called - assertEquals(1, namespace.getCreateCallCount(), "declareTable should be called once"); + assertEquals( + 1, getDeclareCallCount(innerNamespaceClient), "declareTable should be called once"); - // Open dataset through namespace WITH refresh enabled + // Open dataset through namespace client WITH refresh enabled ReadOptions readOptions = new ReadOptions.Builder().setStorageOptions(storageOptions).build(); - int callCountBeforeOpen = namespace.getDescribeCallCount(); + int callCountBeforeOpen = getDescribeCallCount(innerNamespaceClient); try (Dataset dsFromNamespace = Dataset.open() .allocator(allocator) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .readOptions(readOptions) .build()) { // With the fix, describeTable should only be called once during open // to get the table location and initial storage options - int callCountAfterOpen = namespace.getDescribeCallCount(); + int callCountAfterOpen = getDescribeCallCount(innerNamespaceClient); assertEquals( 1, callCountAfterOpen - callCountBeforeOpen, @@ -382,7 +385,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { assertEquals(1, versions.size()); // With the fix, credentials are cached so no additional calls are made - int finalCallCount = namespace.getDescribeCallCount(); + int finalCallCount = getDescribeCallCount(innerNamespaceClient); int totalCalls = finalCallCount - callCountBeforeOpen; assertEquals( 1, @@ -405,7 +408,9 @@ void testStorageOptionsProviderWithRefresh() throws Exception { storageOptions.put("aws_region", REGION); // Create tracking namespace with 5-second expiration for faster testing - TrackingNamespace namespace = new TrackingNamespace(BUCKET_NAME, storageOptions, 5); + TrackingNamespaceResult nsResult = createTrackingNamespace(BUCKET_NAME, storageOptions, 5); + LanceNamespace namespaceClient = nsResult.namespaceClient; + DirectoryNamespace innerNamespaceClient = nsResult.innerNamespaceClient; String tableName = UUID.randomUUID().toString(); // Create schema and data @@ -464,12 +469,12 @@ public VectorSchemaRoot getVectorSchemaRoot() { } }; - // Create dataset through namespace with refresh enabled + // Create dataset through namespace client with refresh enabled try (Dataset dataset = Dataset.write() .allocator(allocator) .reader(testReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) .storageOptions(storageOptions) @@ -479,21 +484,22 @@ public VectorSchemaRoot getVectorSchemaRoot() { } // Verify declareTable was called - assertEquals(1, namespace.getCreateCallCount(), "declareTable should be called once"); + assertEquals( + 1, getDeclareCallCount(innerNamespaceClient), "declareTable should be called once"); - // Open dataset through namespace with refresh enabled + // Open dataset through namespace client with refresh enabled ReadOptions readOptions = new ReadOptions.Builder().setStorageOptions(storageOptions).build(); - int callCountBeforeOpen = namespace.getDescribeCallCount(); + int callCountBeforeOpen = getDescribeCallCount(innerNamespaceClient); try (Dataset dsFromNamespace = Dataset.open() .allocator(allocator) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .readOptions(readOptions) .build()) { // With the fix, describeTable should only be called once during open - int callCountAfterOpen = namespace.getDescribeCallCount(); + int callCountAfterOpen = getDescribeCallCount(innerNamespaceClient); assertEquals( 1, callCountAfterOpen - callCountBeforeOpen, @@ -504,7 +510,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { assertEquals(2, dsFromNamespace.countRows()); // Record call count after initial reads - int callCountAfterInitialReads = namespace.getDescribeCallCount(); + int callCountAfterInitialReads = getDescribeCallCount(innerNamespaceClient); int callsAfterFirstRead = callCountAfterInitialReads - callCountBeforeOpen; assertEquals( 1, @@ -523,7 +529,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { List versions = dsFromNamespace.listVersions(); assertEquals(1, versions.size()); - int finalCallCount = namespace.getDescribeCallCount(); + int finalCallCount = getDescribeCallCount(innerNamespaceClient); int totalCallsAfterExpiration = finalCallCount - callCountBeforeOpen; assertEquals( 2, @@ -547,7 +553,9 @@ void testWriteDatasetBuilderWithNamespaceCreate() throws Exception { storageOptions.put("aws_region", REGION); // Create tracking namespace - TrackingNamespace namespace = new TrackingNamespace(BUCKET_NAME, storageOptions, 60); + TrackingNamespaceResult nsResult = createTrackingNamespace(BUCKET_NAME, storageOptions, 60); + LanceNamespace namespaceClient = nsResult.namespaceClient; + DirectoryNamespace innerNamespaceClient = nsResult.innerNamespaceClient; String tableName = UUID.randomUUID().toString(); // Create schema and data @@ -606,21 +614,21 @@ public VectorSchemaRoot getVectorSchemaRoot() { } }; - int callCountBefore = namespace.getCreateCallCount(); + int callCountBefore = getDeclareCallCount(innerNamespaceClient); - // Use the write builder to create a dataset through namespace + // Use the write builder to create a dataset through namespace client try (Dataset dataset = Dataset.write() .allocator(allocator) .reader(testReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) .storageOptions(storageOptions) .execute()) { // Verify declareTable was called - int callCountAfter = namespace.getCreateCallCount(); + int callCountAfter = getDeclareCallCount(innerNamespaceClient); assertEquals(1, callCountAfter - callCountBefore, "declareTable should be called once"); // Verify dataset was created successfully @@ -645,12 +653,16 @@ void testWriteDatasetBuilderWithNamespaceCreateCallCounts() throws Exception { // Create tracking namespace with 60-second expiration (long enough that no refresh happens) // Credentials expire at T+60s. With a 1s refresh offset, refresh would happen at T+59s. // Since writes complete well under 59 seconds, NO credential refresh should occur. - TrackingNamespace namespace = new TrackingNamespace(BUCKET_NAME, storageOptions, 60); + TrackingNamespaceResult nsResult = createTrackingNamespace(BUCKET_NAME, storageOptions, 60); + LanceNamespace namespaceClient = nsResult.namespaceClient; + DirectoryNamespace innerNamespaceClient = nsResult.innerNamespaceClient; String tableName = UUID.randomUUID().toString(); // Verify initial call counts - assertEquals(0, namespace.getCreateCallCount(), "declareTable should not be called yet"); - assertEquals(0, namespace.getDescribeCallCount(), "describeTable should not be called yet"); + assertEquals( + 0, getDeclareCallCount(innerNamespaceClient), "declareTable should not be called yet"); + assertEquals( + 0, getDescribeCallCount(innerNamespaceClient), "describeTable should not be called yet"); // Create schema and data Schema schema = @@ -708,13 +720,13 @@ public VectorSchemaRoot getVectorSchemaRoot() { } }; - // Use the write builder to create a dataset through namespace + // Use the write builder to create a dataset through namespace client // Write completes instantly, so NO describeTable call should happen for refresh. try (Dataset dataset = Dataset.write() .allocator(allocator) .reader(testReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) .storageOptions(storageOptions) @@ -722,14 +734,16 @@ public VectorSchemaRoot getVectorSchemaRoot() { // Verify declareTable was called exactly ONCE assertEquals( - 1, namespace.getCreateCallCount(), "declareTable should be called exactly once"); + 1, + getDeclareCallCount(innerNamespaceClient), + "declareTable should be called exactly once"); // Verify describeTable was NOT called during CREATE // Initial credentials come from declareTable response, and since credentials // don't expire during the fast write, NO refresh (describeTable) is needed assertEquals( 0, - namespace.getDescribeCallCount(), + getDescribeCallCount(innerNamespaceClient), "describeTable should NOT be called during CREATE - " + "initial credentials come from declareTable response and don't expire"); @@ -740,19 +754,22 @@ public VectorSchemaRoot getVectorSchemaRoot() { } // Verify counts after dataset is closed - assertEquals(1, namespace.getCreateCallCount(), "declareTable should still be 1 after close"); + assertEquals( + 1, + getDeclareCallCount(innerNamespaceClient), + "declareTable should still be 1 after close"); assertEquals( 0, - namespace.getDescribeCallCount(), + getDescribeCallCount(innerNamespaceClient), "describeTable should still be 0 after close (no refresh needed)"); - // Now open the dataset through namespace with long-lived credentials (60s expiration) + // Now open the dataset through namespace client with long-lived credentials (60s expiration) ReadOptions readOptions = new ReadOptions.Builder().setStorageOptions(storageOptions).build(); try (Dataset dsFromNamespace = Dataset.open() .allocator(allocator) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .readOptions(readOptions) .build()) { @@ -760,13 +777,13 @@ public VectorSchemaRoot getVectorSchemaRoot() { // declareTable should NOT be called during open (only during CREATE) assertEquals( 1, - namespace.getCreateCallCount(), + getDeclareCallCount(innerNamespaceClient), "declareTable should still be 1 (not called during open)"); // describeTable is called exactly ONCE during open to get table location assertEquals( 1, - namespace.getDescribeCallCount(), + getDescribeCallCount(innerNamespaceClient), "describeTable should be called exactly once during open"); // Verify we can read the data multiple times @@ -778,13 +795,13 @@ public VectorSchemaRoot getVectorSchemaRoot() { // (credentials are cached and don't expire during this fast test) assertEquals( 1, - namespace.getDescribeCallCount(), + getDescribeCallCount(innerNamespaceClient), "describeTable should still be 1 after reads (credentials cached, no refresh needed)"); } // Final verification - assertEquals(1, namespace.getCreateCallCount(), "Final: declareTable = 1"); - assertEquals(1, namespace.getDescribeCallCount(), "Final: describeTable = 1"); + assertEquals(1, getDeclareCallCount(innerNamespaceClient), "Final: declareTable = 1"); + assertEquals(1, getDescribeCallCount(innerNamespaceClient), "Final: describeTable = 1"); } } @@ -800,7 +817,9 @@ void testWriteDatasetBuilderWithNamespaceAppend() throws Exception { storageOptions.put("aws_region", REGION); // Create tracking namespace - TrackingNamespace namespace = new TrackingNamespace(BUCKET_NAME, storageOptions, 60); + TrackingNamespaceResult nsResult = createTrackingNamespace(BUCKET_NAME, storageOptions, 60); + LanceNamespace namespaceClient = nsResult.namespaceClient; + DirectoryNamespace innerNamespaceClient = nsResult.innerNamespaceClient; String tableName = UUID.randomUUID().toString(); Schema schema = @@ -858,12 +877,12 @@ public VectorSchemaRoot getVectorSchemaRoot() { } }; - // Create initial dataset through namespace + // Create initial dataset through namespace client try (Dataset dataset = Dataset.write() .allocator(allocator) .reader(testReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) .storageOptions(storageOptions) @@ -871,10 +890,11 @@ public VectorSchemaRoot getVectorSchemaRoot() { assertEquals(2, dataset.countRows()); } - assertEquals(1, namespace.getCreateCallCount(), "declareTable should be called once"); - int initialDescribeCount = namespace.getDescribeCallCount(); + assertEquals( + 1, getDeclareCallCount(innerNamespaceClient), "declareTable should be called once"); + int initialDescribeCount = getDescribeCallCount(innerNamespaceClient); - // Now append data using the write builder with namespace + // Now append data using the write builder with namespace client ArrowReader appendReader = new ArrowReader(allocator) { boolean firstRead = true; @@ -907,19 +927,19 @@ public VectorSchemaRoot getVectorSchemaRoot() { } }; - // Use the write builder to append to dataset through namespace + // Use the write builder to append to dataset through namespace client try (Dataset dataset = Dataset.write() .allocator(allocator) .reader(appendReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.APPEND) .storageOptions(storageOptions) .execute()) { // Verify describeTable was called - int callCountAfter = namespace.getDescribeCallCount(); + int callCountAfter = getDescribeCallCount(innerNamespaceClient); assertEquals( 1, callCountAfter - initialDescribeCount, @@ -944,7 +964,9 @@ void testWriteDatasetBuilderWithNamespaceOverwrite() throws Exception { storageOptions.put("aws_region", REGION); // Create tracking namespace - TrackingNamespace namespace = new TrackingNamespace(BUCKET_NAME, storageOptions, 60); + TrackingNamespaceResult nsResult = createTrackingNamespace(BUCKET_NAME, storageOptions, 60); + LanceNamespace namespaceClient = nsResult.namespaceClient; + DirectoryNamespace innerNamespaceClient = nsResult.innerNamespaceClient; String tableName = UUID.randomUUID().toString(); Schema schema = @@ -1004,7 +1026,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { Dataset.write() .allocator(allocator) .reader(createReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) .storageOptions(storageOptions) @@ -1012,8 +1034,12 @@ public VectorSchemaRoot getVectorSchemaRoot() { assertEquals(1, dataset.countRows()); } - assertEquals(1, namespace.getCreateCallCount(), "declareTable should be called once"); - assertEquals(0, namespace.getDescribeCallCount(), "describeTable should not be called yet"); + assertEquals( + 1, getDeclareCallCount(innerNamespaceClient), "declareTable should be called once"); + assertEquals( + 0, + getDescribeCallCount(innerNamespaceClient), + "describeTable should not be called yet"); // Now overwrite with 2 rows aVector.allocateNew(2); @@ -1064,15 +1090,16 @@ public VectorSchemaRoot getVectorSchemaRoot() { Dataset.write() .allocator(allocator) .reader(overwriteReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.OVERWRITE) .storageOptions(storageOptions) .execute()) { // Verify describeTable was called for overwrite - assertEquals(1, namespace.getCreateCallCount(), "declareTable should still be 1"); - int describeCountAfterOverwrite = namespace.getDescribeCallCount(); + assertEquals( + 1, getDeclareCallCount(innerNamespaceClient), "declareTable should still be 1"); + int describeCountAfterOverwrite = getDescribeCallCount(innerNamespaceClient); assertEquals( 1, describeCountAfterOverwrite, "describeTable should be called once for overwrite"); @@ -1082,11 +1109,11 @@ public VectorSchemaRoot getVectorSchemaRoot() { 2, dataset.listVersions().size()); // Version 1 (create) + Version 2 (overwrite) } - // Verify we can open and read the dataset through namespace + // Verify we can open and read the dataset through namespace client try (Dataset ds = Dataset.open() .allocator(allocator) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .readOptions(new ReadOptions.Builder().setStorageOptions(storageOptions).build()) .build()) { @@ -1109,7 +1136,9 @@ void testDistributedWriteWithNamespace() throws Exception { storageOptions.put("aws_region", REGION); // Create tracking namespace - TrackingNamespace namespace = new TrackingNamespace(BUCKET_NAME, storageOptions, 60); + TrackingNamespaceResult nsResult = createTrackingNamespace(BUCKET_NAME, storageOptions, 60); + LanceNamespace namespaceClient = nsResult.namespaceClient; + DirectoryNamespace innerNamespaceClient = nsResult.innerNamespaceClient; String tableName = UUID.randomUUID().toString(); Schema schema = @@ -1121,10 +1150,12 @@ void testDistributedWriteWithNamespace() throws Exception { // Step 1: Declare table via namespace DeclareTableRequest request = new DeclareTableRequest(); request.setId(Arrays.asList(tableName)); - DeclareTableResponse response = namespace.declareTable(request); + DeclareTableResponse response = namespaceClient.declareTable(request); - assertEquals(1, namespace.getCreateCallCount(), "declareTable should be called once"); - assertEquals(0, namespace.getDescribeCallCount(), "describeTable should not be called yet"); + assertEquals( + 1, getDeclareCallCount(innerNamespaceClient), "declareTable should be called once"); + assertEquals( + 0, getDescribeCallCount(innerNamespaceClient), "describeTable should not be called yet"); String tableUri = response.getLocation(); Map namespaceStorageOptions = response.getStorageOptions(); @@ -1135,11 +1166,8 @@ void testDistributedWriteWithNamespace() throws Exception { mergedOptions.putAll(namespaceStorageOptions); } - // Create storage options provider - LanceNamespaceStorageOptionsProvider storageOptionsProvider = - new LanceNamespaceStorageOptionsProvider(namespace, Arrays.asList(tableName)); - WriteParams writeParams = new WriteParams.Builder().withStorageOptions(mergedOptions).build(); + List tableId = Arrays.asList(tableName); // Step 2: Write multiple fragments in parallel (simulated) List allFragments = new ArrayList<>(); @@ -1160,7 +1188,7 @@ void testDistributedWriteWithNamespace() throws Exception { root.setRowCount(2); List fragment1 = - Fragment.create(tableUri, allocator, root, writeParams, storageOptionsProvider); + Fragment.create(tableUri, allocator, root, writeParams, namespaceClient, tableId); allFragments.addAll(fragment1); } @@ -1180,7 +1208,7 @@ void testDistributedWriteWithNamespace() throws Exception { root.setRowCount(2); List fragment2 = - Fragment.create(tableUri, allocator, root, writeParams, storageOptionsProvider); + Fragment.create(tableUri, allocator, root, writeParams, namespaceClient, tableId); allFragments.addAll(fragment2); } @@ -1198,7 +1226,7 @@ void testDistributedWriteWithNamespace() throws Exception { root.setRowCount(1); List fragment3 = - Fragment.create(tableUri, allocator, root, writeParams, storageOptionsProvider); + Fragment.create(tableUri, allocator, root, writeParams, namespaceClient, tableId); allFragments.addAll(fragment3); } @@ -1212,15 +1240,15 @@ void testDistributedWriteWithNamespace() throws Exception { assertEquals(1, dataset.listVersions().size(), "Should have 1 version after commit"); } - // Step 4: Open dataset through namespace and verify + // Step 4: Open dataset through namespace client and verify try (Dataset dsFromNamespace = Dataset.open() .allocator(allocator) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .readOptions(new ReadOptions.Builder().setStorageOptions(storageOptions).build()) .build()) { - assertEquals(5, dsFromNamespace.countRows(), "Should read 5 rows through namespace"); + assertEquals(5, dsFromNamespace.countRows(), "Should read 5 rows through namespace client"); } } } @@ -1237,7 +1265,9 @@ void testFragmentCreateAndCommitWithNamespace() throws Exception { storageOptions.put("aws_region", REGION); // Create tracking namespace with 60-second expiration - TrackingNamespace namespace = new TrackingNamespace(BUCKET_NAME, storageOptions, 60); + TrackingNamespaceResult nsResult = createTrackingNamespace(BUCKET_NAME, storageOptions, 60); + LanceNamespace namespaceClient = nsResult.namespaceClient; + DirectoryNamespace innerNamespaceClient = nsResult.innerNamespaceClient; String tableName = UUID.randomUUID().toString(); Schema schema = @@ -1249,9 +1279,10 @@ void testFragmentCreateAndCommitWithNamespace() throws Exception { // Declare table via namespace DeclareTableRequest request = new DeclareTableRequest(); request.setId(Arrays.asList(tableName)); - DeclareTableResponse response = namespace.declareTable(request); + DeclareTableResponse response = namespaceClient.declareTable(request); - assertEquals(1, namespace.getCreateCallCount(), "declareTable should be called once"); + assertEquals( + 1, getDeclareCallCount(innerNamespaceClient), "declareTable should be called once"); String tableUri = response.getLocation(); Map namespaceStorageOptions = response.getStorageOptions(); @@ -1262,11 +1293,8 @@ void testFragmentCreateAndCommitWithNamespace() throws Exception { mergedOptions.putAll(namespaceStorageOptions); } - // Create storage options provider - LanceNamespaceStorageOptionsProvider provider = - new LanceNamespaceStorageOptionsProvider(namespace, Arrays.asList(tableName)); - WriteParams writeParams = new WriteParams.Builder().withStorageOptions(mergedOptions).build(); + List tableId = Arrays.asList(tableName); try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { IntVector idVector = (IntVector) root.getVector("id"); @@ -1287,9 +1315,9 @@ void testFragmentCreateAndCommitWithNamespace() throws Exception { valueVector.setValueCount(3); root.setRowCount(3); - // Create fragment with StorageOptionsProvider + // Create fragment with namespace client List fragments1 = - Fragment.create(tableUri, allocator, root, writeParams, provider); + Fragment.create(tableUri, allocator, root, writeParams, namespaceClient, tableId); assertEquals(1, fragments1.size()); @@ -1302,9 +1330,9 @@ void testFragmentCreateAndCommitWithNamespace() throws Exception { valueVector.set(2, 600); root.setRowCount(3); - // Create another fragment with the same provider + // Create another fragment with the same namespace client List fragments2 = - Fragment.create(tableUri, allocator, root, writeParams, provider); + Fragment.create(tableUri, allocator, root, writeParams, namespaceClient, tableId); assertEquals(1, fragments2.size()); @@ -1326,11 +1354,11 @@ void testFragmentCreateAndCommitWithNamespace() throws Exception { } } - // Verify we can open and read the dataset through namespace + // Verify we can open and read the dataset through namespace client try (Dataset ds = Dataset.open() .allocator(allocator) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .readOptions(new ReadOptions.Builder().setStorageOptions(storageOptions).build()) .build()) { @@ -1352,7 +1380,8 @@ void testTransactionCommitWithNamespace() throws Exception { storageOptions.put("aws_region", REGION); // Create tracking namespace - TrackingNamespace namespace = new TrackingNamespace(BUCKET_NAME, storageOptions, 60); + TrackingNamespaceResult nsResult = createTrackingNamespace(BUCKET_NAME, storageOptions, 60); + LanceNamespace namespaceClient = nsResult.namespaceClient; String tableName = UUID.randomUUID().toString(); Schema schema = @@ -1364,7 +1393,7 @@ void testTransactionCommitWithNamespace() throws Exception { // Declare table via namespace DeclareTableRequest request = new DeclareTableRequest(); request.setId(Arrays.asList(tableName)); - DeclareTableResponse response = namespace.declareTable(request); + DeclareTableResponse response = namespaceClient.declareTable(request); String tableUri = response.getLocation(); Map namespaceStorageOptions = response.getStorageOptions(); @@ -1375,12 +1404,9 @@ void testTransactionCommitWithNamespace() throws Exception { mergedOptions.putAll(namespaceStorageOptions); } - // Create storage options provider - LanceNamespaceStorageOptionsProvider provider = - new LanceNamespaceStorageOptionsProvider(namespace, Arrays.asList(tableName)); - // First, write some initial data using Fragment.create and commit WriteParams writeParams = new WriteParams.Builder().withStorageOptions(mergedOptions).build(); + List tableId = Arrays.asList(tableName); List initialFragments; try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { @@ -1400,7 +1426,8 @@ void testTransactionCommitWithNamespace() throws Exception { nameVector.setValueCount(2); root.setRowCount(2); - initialFragments = Fragment.create(tableUri, allocator, root, writeParams, provider); + initialFragments = + Fragment.create(tableUri, allocator, root, writeParams, namespaceClient, tableId); } // Commit initial fragments @@ -1412,15 +1439,17 @@ void testTransactionCommitWithNamespace() throws Exception { assertEquals(2, dataset.countRows()); } - // Now test Transaction.commit with provider - // Open dataset with provider using mergedOptions (which has expires_at_millis) - ReadOptions readOptions = - new ReadOptions.Builder() - .setStorageOptions(mergedOptions) - .setStorageOptionsProvider(provider) - .build(); + // Now test Transaction.commit with namespace client + // Open dataset with namespace client using mergedOptions (which has expires_at_millis) + ReadOptions readOptions = new ReadOptions.Builder().setStorageOptions(mergedOptions).build(); - try (Dataset datasetWithProvider = Dataset.open(allocator, tableUri, readOptions)) { + try (Dataset datasetWithNamespaceClient = + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .readOptions(readOptions) + .build()) { // Create more fragments to append List newFragments; try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { @@ -1440,29 +1469,30 @@ void testTransactionCommitWithNamespace() throws Exception { nameVector.setValueCount(2); root.setRowCount(2); - newFragments = Fragment.create(tableUri, allocator, root, writeParams, provider); + newFragments = + Fragment.create(tableUri, allocator, root, writeParams, namespaceClient, tableId); } // Create and commit transaction Append appendOp = Append.builder().fragments(newFragments).build(); try (Transaction transaction = new Transaction.Builder() - .readVersion(datasetWithProvider.version()) + .readVersion(datasetWithNamespaceClient.version()) .operation(appendOp) .build()) { try (Dataset committedDataset = - new CommitBuilder(datasetWithProvider).execute(transaction)) { + new CommitBuilder(datasetWithNamespaceClient).execute(transaction)) { assertEquals(2, committedDataset.version()); assertEquals(4, committedDataset.countRows()); } } } - // Verify we can open and read the dataset through namespace + // Verify we can open and read the dataset through namespace client try (Dataset ds = Dataset.open() .allocator(allocator) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .readOptions(new ReadOptions.Builder().setStorageOptions(storageOptions).build()) .build()) { @@ -1531,8 +1561,8 @@ private byte[] createTestTableData() throws Exception { @Test void testBasicCreateAndDropOnS3() throws Exception { - DirectoryNamespace namespace = new DirectoryNamespace(); - namespace.initialize(createDirectoryNamespaceS3Config(), testAllocator); + DirectoryNamespace namespaceClient = new DirectoryNamespace(); + namespaceClient.initialize(createDirectoryNamespaceS3Config(), testAllocator); try { String tableName = "basic_test_table"; @@ -1540,32 +1570,32 @@ void testBasicCreateAndDropOnS3() throws Exception { byte[] tableData = createTestTableData(); CreateTableRequest createReq = new CreateTableRequest().id(tableId); - CreateTableResponse createResp = namespace.createTable(createReq, tableData); + CreateTableResponse createResp = namespaceClient.createTable(createReq, tableData); assertNotNull(createResp); assertNotNull(createResp.getLocation()); DropTableRequest dropReq = new DropTableRequest().id(tableId); - DropTableResponse dropResp = namespace.dropTable(dropReq); + DropTableResponse dropResp = namespaceClient.dropTable(dropReq); assertNotNull(dropResp); TableExistsRequest existsReq = new TableExistsRequest().id(tableId); - assertThrows(LanceNamespaceException.class, () -> namespace.tableExists(existsReq)); + assertThrows(LanceNamespaceException.class, () -> namespaceClient.tableExists(existsReq)); } finally { - namespace.close(); + namespaceClient.close(); } } @Test void testConcurrentCreateAndDropWithSingleInstanceOnS3() throws Exception { - DirectoryNamespace namespace = new DirectoryNamespace(); - namespace.initialize(createDirectoryNamespaceS3Config(), testAllocator); + DirectoryNamespace namespaceClient = new DirectoryNamespace(); + namespaceClient.initialize(createDirectoryNamespaceS3Config(), testAllocator); try { // Initialize namespace first - create parent namespace to ensure __manifest table // is created before concurrent operations CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("test_ns")); - namespace.createNamespace(createNsReq); + namespaceClient.createNamespace(createNsReq); int numTables = 10; ExecutorService executor = Executors.newFixedThreadPool(numTables); @@ -1586,10 +1616,10 @@ void testConcurrentCreateAndDropWithSingleInstanceOnS3() throws Exception { byte[] tableData = createTestTableData(); CreateTableRequest createReq = new CreateTableRequest().id(tableId); - namespace.createTable(createReq, tableData); + namespaceClient.createTable(createReq, tableData); DropTableRequest dropReq = new DropTableRequest().id(tableId); - namespace.dropTable(dropReq); + namespaceClient.dropTable(dropReq); successCount.incrementAndGet(); } catch (Exception e) { @@ -1609,7 +1639,7 @@ void testConcurrentCreateAndDropWithSingleInstanceOnS3() throws Exception { assertEquals(numTables, successCount.get(), "All tasks should succeed"); assertEquals(0, failCount.get(), "No tasks should fail"); } finally { - namespace.close(); + namespaceClient.close(); } } diff --git a/java/src/test/java/org/lance/namespace/CustomNamespace.java b/java/src/test/java/org/lance/namespace/CustomNamespace.java new file mode 100644 index 00000000000..eb383e118d8 --- /dev/null +++ b/java/src/test/java/org/lance/namespace/CustomNamespace.java @@ -0,0 +1,340 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.lance.namespace; + +import org.lance.namespace.model.*; + +import org.apache.arrow.memory.BufferAllocator; + +import java.io.Closeable; +import java.util.Map; + +/** + * A custom namespace wrapper that delegates all operations to an inner DirectoryNamespace. + * + *

    This class is used for testing to verify that the Java-Rust binding works correctly for custom + * namespace implementations that wrap DirectoryNamespace. + */ +public class CustomNamespace implements LanceNamespace, Closeable { + private final DirectoryNamespace inner; + + /** + * Creates a CustomNamespace wrapping the given DirectoryNamespace. + * + * @param inner The DirectoryNamespace to wrap + */ + public CustomNamespace(DirectoryNamespace inner) { + this.inner = inner; + } + + /** + * Gets the wrapped DirectoryNamespace for metrics retrieval. + * + * @return The inner DirectoryNamespace + */ + public DirectoryNamespace getInner() { + return inner; + } + + /** + * Gets the native handle from the inner namespace. This is required for the JNI layer to work + * correctly with wrapped namespaces. + * + * @return The native handle + */ + public long getNativeHandle() { + return inner.getNativeHandle(); + } + + @Override + public void initialize(Map configProperties, BufferAllocator allocator) { + // Already initialized in constructor via inner namespace + } + + @Override + public String namespaceId() { + return "CustomNamespace[" + inner.namespaceId() + "]"; + } + + // Namespace operations + + @Override + public ListNamespacesResponse listNamespaces(ListNamespacesRequest request) { + return inner.listNamespaces(request); + } + + @Override + public DescribeNamespaceResponse describeNamespace(DescribeNamespaceRequest request) { + return inner.describeNamespace(request); + } + + @Override + public CreateNamespaceResponse createNamespace(CreateNamespaceRequest request) { + return inner.createNamespace(request); + } + + @Override + public DropNamespaceResponse dropNamespace(DropNamespaceRequest request) { + return inner.dropNamespace(request); + } + + @Override + public void namespaceExists(NamespaceExistsRequest request) { + inner.namespaceExists(request); + } + + // Table operations + + @Override + public ListTablesResponse listTables(ListTablesRequest request) { + return inner.listTables(request); + } + + @Override + public DescribeTableResponse describeTable(DescribeTableRequest request) { + return inner.describeTable(request); + } + + @Override + public RegisterTableResponse registerTable(RegisterTableRequest request) { + return inner.registerTable(request); + } + + @Override + public void tableExists(TableExistsRequest request) { + inner.tableExists(request); + } + + @Override + public DropTableResponse dropTable(DropTableRequest request) { + return inner.dropTable(request); + } + + @Override + public DeregisterTableResponse deregisterTable(DeregisterTableRequest request) { + return inner.deregisterTable(request); + } + + @Override + public Long countTableRows(CountTableRowsRequest request) { + return inner.countTableRows(request); + } + + // Data operations + + @Override + public CreateTableResponse createTable(CreateTableRequest request, byte[] requestData) { + return inner.createTable(request, requestData); + } + + @Override + public DeclareTableResponse declareTable(DeclareTableRequest request) { + return inner.declareTable(request); + } + + @Override + @SuppressWarnings("deprecation") + public CreateEmptyTableResponse createEmptyTable(CreateEmptyTableRequest request) { + return inner.createEmptyTable(request); + } + + @Override + public InsertIntoTableResponse insertIntoTable( + InsertIntoTableRequest request, byte[] requestData) { + return inner.insertIntoTable(request, requestData); + } + + @Override + public MergeInsertIntoTableResponse mergeInsertIntoTable( + MergeInsertIntoTableRequest request, byte[] requestData) { + return inner.mergeInsertIntoTable(request, requestData); + } + + @Override + public UpdateTableResponse updateTable(UpdateTableRequest request) { + return inner.updateTable(request); + } + + @Override + public DeleteFromTableResponse deleteFromTable(DeleteFromTableRequest request) { + return inner.deleteFromTable(request); + } + + @Override + public byte[] queryTable(QueryTableRequest request) { + return inner.queryTable(request); + } + + // Index operations + + @Override + public CreateTableIndexResponse createTableIndex(CreateTableIndexRequest request) { + return inner.createTableIndex(request); + } + + @Override + public CreateTableScalarIndexResponse createTableScalarIndex(CreateTableIndexRequest request) { + return inner.createTableScalarIndex(request); + } + + @Override + public ListTableIndicesResponse listTableIndices(ListTableIndicesRequest request) { + return inner.listTableIndices(request); + } + + @Override + public DescribeTableIndexStatsResponse describeTableIndexStats( + DescribeTableIndexStatsRequest request, String indexName) { + return inner.describeTableIndexStats(request, indexName); + } + + @Override + public DropTableIndexResponse dropTableIndex(DropTableIndexRequest request, String indexName) { + return inner.dropTableIndex(request, indexName); + } + + // Table version and schema operations + + @Override + public ListTablesResponse listAllTables(ListTablesRequest request) { + return inner.listAllTables(request); + } + + @Override + public RestoreTableResponse restoreTable(RestoreTableRequest request) { + return inner.restoreTable(request); + } + + @Override + public RenameTableResponse renameTable(RenameTableRequest request) { + return inner.renameTable(request); + } + + @Override + public ListTableVersionsResponse listTableVersions(ListTableVersionsRequest request) { + return inner.listTableVersions(request); + } + + @Override + public CreateTableVersionResponse createTableVersion(CreateTableVersionRequest request) { + return inner.createTableVersion(request); + } + + @Override + public DescribeTableVersionResponse describeTableVersion(DescribeTableVersionRequest request) { + return inner.describeTableVersion(request); + } + + @Override + public BatchDeleteTableVersionsResponse batchDeleteTableVersions( + BatchDeleteTableVersionsRequest request) { + return inner.batchDeleteTableVersions(request); + } + + @Override + public BatchCreateTableVersionsResponse batchCreateTableVersions( + BatchCreateTableVersionsRequest request) { + return inner.batchCreateTableVersions(request); + } + + @Override + public BatchCommitTablesResponse batchCommitTables(BatchCommitTablesRequest request) { + return inner.batchCommitTables(request); + } + + @Override + public UpdateTableSchemaMetadataResponse updateTableSchemaMetadata( + UpdateTableSchemaMetadataRequest request) { + return inner.updateTableSchemaMetadata(request); + } + + @Override + public GetTableStatsResponse getTableStats(GetTableStatsRequest request) { + return inner.getTableStats(request); + } + + // Query plan operations + + @Override + public String explainTableQueryPlan(ExplainTableQueryPlanRequest request) { + return inner.explainTableQueryPlan(request); + } + + @Override + public String analyzeTableQueryPlan(AnalyzeTableQueryPlanRequest request) { + return inner.analyzeTableQueryPlan(request); + } + + // Column operations + + @Override + public AlterTableAddColumnsResponse alterTableAddColumns(AlterTableAddColumnsRequest request) { + return inner.alterTableAddColumns(request); + } + + @Override + public AlterTableAlterColumnsResponse alterTableAlterColumns( + AlterTableAlterColumnsRequest request) { + return inner.alterTableAlterColumns(request); + } + + @Override + public AlterTableDropColumnsResponse alterTableDropColumns(AlterTableDropColumnsRequest request) { + return inner.alterTableDropColumns(request); + } + + // Tag operations + + @Override + public ListTableTagsResponse listTableTags(ListTableTagsRequest request) { + return inner.listTableTags(request); + } + + @Override + public GetTableTagVersionResponse getTableTagVersion(GetTableTagVersionRequest request) { + return inner.getTableTagVersion(request); + } + + @Override + public CreateTableTagResponse createTableTag(CreateTableTagRequest request) { + return inner.createTableTag(request); + } + + @Override + public DeleteTableTagResponse deleteTableTag(DeleteTableTagRequest request) { + return inner.deleteTableTag(request); + } + + @Override + public UpdateTableTagResponse updateTableTag(UpdateTableTagRequest request) { + return inner.updateTableTag(request); + } + + // Transaction operations + + @Override + public DescribeTransactionResponse describeTransaction(DescribeTransactionRequest request) { + return inner.describeTransaction(request); + } + + @Override + public AlterTransactionResponse alterTransaction(AlterTransactionRequest request) { + return inner.alterTransaction(request); + } + + @Override + public void close() { + inner.close(); + } +} diff --git a/java/src/test/java/org/lance/namespace/CustomNamespaceTest.java b/java/src/test/java/org/lance/namespace/CustomNamespaceTest.java new file mode 100644 index 00000000000..fd13747662f --- /dev/null +++ b/java/src/test/java/org/lance/namespace/CustomNamespaceTest.java @@ -0,0 +1,55 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.lance.namespace; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for CustomNamespace implementation that wraps DirectoryNamespace. + * + *

    This test class extends DirectoryNamespaceTest to verify that all tests pass when using a + * CustomNamespace wrapper around DirectoryNamespace. This validates that the Java-Rust binding + * works correctly for custom namespace implementations. + */ +public class CustomNamespaceTest extends DirectoryNamespaceTest { + + @Override + protected LanceNamespace wrapNamespace(DirectoryNamespace inner) { + return new CustomNamespace(inner); + } + + @Test + void testCustomNamespaceId() { + String namespaceId = namespaceClient.namespaceId(); + assertNotNull(namespaceId); + assertTrue( + namespaceId.startsWith("CustomNamespace["), + "namespaceId should start with 'CustomNamespace[', got: " + namespaceId); + assertTrue( + namespaceId.contains("DirectoryNamespace"), + "namespaceId should contain 'DirectoryNamespace', got: " + namespaceId); + } + + @Test + void testCustomNamespaceInnerAccess() { + assertTrue( + namespaceClient instanceof CustomNamespace, "namespaceClient should be a CustomNamespace"); + CustomNamespace customNs = (CustomNamespace) namespaceClient; + assertNotNull(customNs.getInner(), "inner namespace should not be null"); + assertSame( + innerNamespaceClient, customNs.getInner(), "inner namespace should be the same instance"); + } +} diff --git a/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java b/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java index edaaa9c28bc..7d5fc9c6096 100644 --- a/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java +++ b/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java @@ -62,23 +62,40 @@ public class DirectoryNamespaceTest { @TempDir Path tempDir; - private BufferAllocator allocator; - private DirectoryNamespace namespace; + protected BufferAllocator allocator; + protected LanceNamespace namespaceClient; + protected DirectoryNamespace innerNamespaceClient; @BeforeEach void setUp() { allocator = new RootAllocator(Long.MAX_VALUE); - namespace = new DirectoryNamespace(); + innerNamespaceClient = new DirectoryNamespace(); Map config = new HashMap<>(); config.put("root", tempDir.toString()); - namespace.initialize(config, allocator); + innerNamespaceClient.initialize(config, allocator); + namespaceClient = wrapNamespace(innerNamespaceClient); + } + + /** + * Factory method to wrap the DirectoryNamespace. Subclasses can override this to provide a custom + * namespace implementation. + * + * @param inner The DirectoryNamespace to wrap + * @return The namespace client to use in tests (may be the same as inner or a wrapper) + */ + protected LanceNamespace wrapNamespace(DirectoryNamespace inner) { + return inner; } @AfterEach void tearDown() { - if (namespace != null) { - namespace.close(); + if (namespaceClient != null && namespaceClient instanceof java.io.Closeable) { + try { + ((java.io.Closeable) namespaceClient).close(); + } catch (Exception e) { + // Ignore + } } if (allocator != null) { allocator.close(); @@ -131,21 +148,23 @@ private byte[] createTestTableData() throws Exception { @Test void testNamespaceId() { - String namespaceId = namespace.namespaceId(); + String namespaceId = namespaceClient.namespaceId(); assertNotNull(namespaceId); - assertTrue(namespaceId.contains("DirectoryNamespace")); + assertTrue( + namespaceId.contains("DirectoryNamespace"), + "namespaceId should contain 'DirectoryNamespace', got: " + namespaceId); } @Test void testCreateAndListNamespaces() { // Create a namespace CreateNamespaceRequest createReq = new CreateNamespaceRequest().id(Arrays.asList("workspace")); - CreateNamespaceResponse createResp = namespace.createNamespace(createReq); + CreateNamespaceResponse createResp = namespaceClient.createNamespace(createReq); assertNotNull(createResp); // List namespaces ListNamespacesRequest listReq = new ListNamespacesRequest(); - ListNamespacesResponse listResp = namespace.listNamespaces(listReq); + ListNamespacesResponse listResp = namespaceClient.listNamespaces(listReq); assertNotNull(listResp); assertNotNull(listResp.getNamespaces()); assertTrue(listResp.getNamespaces().contains("workspace")); @@ -155,12 +174,12 @@ void testCreateAndListNamespaces() { void testDescribeNamespace() { // Create a namespace CreateNamespaceRequest createReq = new CreateNamespaceRequest().id(Arrays.asList("workspace")); - namespace.createNamespace(createReq); + namespaceClient.createNamespace(createReq); // Describe namespace DescribeNamespaceRequest descReq = new DescribeNamespaceRequest().id(Arrays.asList("workspace")); - DescribeNamespaceResponse descResp = namespace.describeNamespace(descReq); + DescribeNamespaceResponse descResp = namespaceClient.describeNamespace(descReq); assertNotNull(descResp); assertNotNull(descResp.getProperties()); } @@ -169,17 +188,18 @@ void testDescribeNamespace() { void testNamespaceExists() { // Create a namespace CreateNamespaceRequest createReq = new CreateNamespaceRequest().id(Arrays.asList("workspace")); - namespace.createNamespace(createReq); + namespaceClient.createNamespace(createReq); // Check existence NamespaceExistsRequest existsReq = new NamespaceExistsRequest().id(Arrays.asList("workspace")); - assertDoesNotThrow(() -> namespace.namespaceExists(existsReq)); + assertDoesNotThrow(() -> namespaceClient.namespaceExists(existsReq)); // Check non-existent namespace NamespaceExistsRequest notExistsReq = new NamespaceExistsRequest().id(Arrays.asList("nonexistent")); LanceNamespaceException ex = - assertThrows(LanceNamespaceException.class, () -> namespace.namespaceExists(notExistsReq)); + assertThrows( + LanceNamespaceException.class, () -> namespaceClient.namespaceExists(notExistsReq)); assertEquals(ErrorCode.NAMESPACE_NOT_FOUND, ex.getErrorCode()); } @@ -187,16 +207,16 @@ void testNamespaceExists() { void testDropNamespace() { // Create a namespace CreateNamespaceRequest createReq = new CreateNamespaceRequest().id(Arrays.asList("workspace")); - namespace.createNamespace(createReq); + namespaceClient.createNamespace(createReq); // Drop namespace DropNamespaceRequest dropReq = new DropNamespaceRequest().id(Arrays.asList("workspace")); - DropNamespaceResponse dropResp = namespace.dropNamespace(dropReq); + DropNamespaceResponse dropResp = namespaceClient.dropNamespace(dropReq); assertNotNull(dropResp); // Verify it's gone NamespaceExistsRequest existsReq = new NamespaceExistsRequest().id(Arrays.asList("workspace")); - assertThrows(LanceNamespaceException.class, () -> namespace.namespaceExists(existsReq)); + assertThrows(LanceNamespaceException.class, () -> namespaceClient.namespaceExists(existsReq)); } @Test @@ -204,13 +224,13 @@ void testCreateTable() throws Exception { // Create parent namespace CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("workspace")); - namespace.createNamespace(createNsReq); + namespaceClient.createNamespace(createNsReq); // Create table with data byte[] tableData = createTestTableData(); CreateTableRequest createReq = new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); - CreateTableResponse createResp = namespace.createTable(createReq, tableData); + CreateTableResponse createResp = namespaceClient.createTable(createReq, tableData); assertNotNull(createResp); assertNotNull(createResp.getLocation()); @@ -223,17 +243,17 @@ void testListTables() throws Exception { // Create parent namespace CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("workspace")); - namespace.createNamespace(createNsReq); + namespaceClient.createNamespace(createNsReq); // Create a table byte[] tableData = createTestTableData(); CreateTableRequest createReq = new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); - namespace.createTable(createReq, tableData); + namespaceClient.createTable(createReq, tableData); // List tables ListTablesRequest listReq = new ListTablesRequest().id(Arrays.asList("workspace")); - ListTablesResponse listResp = namespace.listTables(listReq); + ListTablesResponse listResp = namespaceClient.listTables(listReq); assertNotNull(listResp); assertNotNull(listResp.getTables()); @@ -245,18 +265,18 @@ void testDescribeTable() throws Exception { // Create parent namespace CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("workspace")); - namespace.createNamespace(createNsReq); + namespaceClient.createNamespace(createNsReq); // Create a table byte[] tableData = createTestTableData(); CreateTableRequest createReq = new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); - namespace.createTable(createReq, tableData); + namespaceClient.createTable(createReq, tableData); // Describe table DescribeTableRequest descReq = new DescribeTableRequest().id(Arrays.asList("workspace", "test_table")); - DescribeTableResponse descResp = namespace.describeTable(descReq); + DescribeTableResponse descResp = namespaceClient.describeTable(descReq); assertNotNull(descResp); assertNotNull(descResp.getLocation()); @@ -268,23 +288,23 @@ void testTableExists() throws Exception { // Create parent namespace CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("workspace")); - namespace.createNamespace(createNsReq); + namespaceClient.createNamespace(createNsReq); // Create a table byte[] tableData = createTestTableData(); CreateTableRequest createReq = new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); - namespace.createTable(createReq, tableData); + namespaceClient.createTable(createReq, tableData); // Check existence TableExistsRequest existsReq = new TableExistsRequest().id(Arrays.asList("workspace", "test_table")); - assertDoesNotThrow(() -> namespace.tableExists(existsReq)); + assertDoesNotThrow(() -> namespaceClient.tableExists(existsReq)); // Check non-existent table TableExistsRequest notExistsReq = new TableExistsRequest().id(Arrays.asList("workspace", "nonexistent")); - assertThrows(LanceNamespaceException.class, () -> namespace.tableExists(notExistsReq)); + assertThrows(LanceNamespaceException.class, () -> namespaceClient.tableExists(notExistsReq)); } @Test @@ -292,23 +312,23 @@ void testDropTable() throws Exception { // Create parent namespace CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("workspace")); - namespace.createNamespace(createNsReq); + namespaceClient.createNamespace(createNsReq); // Create a table byte[] tableData = createTestTableData(); CreateTableRequest createReq = new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); - namespace.createTable(createReq, tableData); + namespaceClient.createTable(createReq, tableData); // Drop table DropTableRequest dropReq = new DropTableRequest().id(Arrays.asList("workspace", "test_table")); - DropTableResponse dropResp = namespace.dropTable(dropReq); + DropTableResponse dropResp = namespaceClient.dropTable(dropReq); assertNotNull(dropResp); // Verify it's gone TableExistsRequest existsReq = new TableExistsRequest().id(Arrays.asList("workspace", "test_table")); - assertThrows(LanceNamespaceException.class, () -> namespace.tableExists(existsReq)); + assertThrows(LanceNamespaceException.class, () -> namespaceClient.tableExists(existsReq)); } @Test @@ -386,99 +406,47 @@ void testDescribeTableVersion() throws Exception { } /** - * Inner class that wraps DirectoryNamespace and tracks API calls for testing managed versioning. + * Creates a DirectoryNamespace configured for testing managed versioning with ops metrics. + * + * @param root The root path for the namespace + * @return A DirectoryNamespace with table_version_tracking_enabled and ops_metrics_enabled */ - static class TableVersionTrackingNamespace implements LanceNamespace, java.io.Closeable { - private final DirectoryNamespace inner; - private final AtomicInteger createTableVersionCount = new AtomicInteger(0); - private final AtomicInteger describeTableVersionCount = new AtomicInteger(0); - private final AtomicInteger listTableVersionsCount = new AtomicInteger(0); - - public TableVersionTrackingNamespace(Path root) { - Map dirProps = new HashMap<>(); - dirProps.put("root", root.toString()); - dirProps.put("table_version_tracking_enabled", "true"); - dirProps.put("manifest_enabled", "true"); - - this.inner = new DirectoryNamespace(); - try (BufferAllocator allocator = new RootAllocator()) { - this.inner.initialize(dirProps, allocator); - } - } - - public int getCreateTableVersionCount() { - return createTableVersionCount.get(); - } - - public int getDescribeTableVersionCount() { - return describeTableVersionCount.get(); - } - - public int getListTableVersionsCount() { - return listTableVersionsCount.get(); - } - - public long getNativeHandle() { - return inner.getNativeHandle(); - } - - @Override - public void initialize(Map configProperties, BufferAllocator allocator) { - // Already initialized in constructor - } - - @Override - public String namespaceId() { - return "TableVersionTrackingNamespace { inner: " + inner.namespaceId() + " }"; - } - - @Override - public DeclareTableResponse declareTable(DeclareTableRequest request) { - return inner.declareTable(request); - } - - @Override - public DescribeTableResponse describeTable(DescribeTableRequest request) { - return inner.describeTable(request); - } - - @Override - public CreateTableVersionResponse createTableVersion(CreateTableVersionRequest request) { - createTableVersionCount.incrementAndGet(); - return inner.createTableVersion(request); - } - - @Override - public DescribeTableVersionResponse describeTableVersion(DescribeTableVersionRequest request) { - describeTableVersionCount.incrementAndGet(); - return inner.describeTableVersion(request); + private DirectoryNamespace createManagedVersioningNamespace(Path root) { + Map dirProps = new HashMap<>(); + dirProps.put("root", root.toString()); + dirProps.put("table_version_tracking_enabled", "true"); + dirProps.put("manifest_enabled", "true"); + dirProps.put("ops_metrics_enabled", "true"); + + DirectoryNamespace ns = new DirectoryNamespace(); + try (BufferAllocator allocator = new RootAllocator()) { + ns.initialize(dirProps, allocator); } + return ns; + } - @Override - public ListTableVersionsResponse listTableVersions(ListTableVersionsRequest request) { - listTableVersionsCount.incrementAndGet(); - return inner.listTableVersions(request); - } + private static int getCreateTableVersionCount(DirectoryNamespace ns) { + Map metrics = ns.retrieveOpsMetrics(); + return metrics.getOrDefault("create_table_version", 0L).intValue(); + } - @Override - public BatchDeleteTableVersionsResponse batchDeleteTableVersions( - BatchDeleteTableVersionsRequest request) { - return inner.batchDeleteTableVersions(request); - } + private static int getDescribeTableVersionCount(DirectoryNamespace ns) { + Map metrics = ns.retrieveOpsMetrics(); + return metrics.getOrDefault("describe_table_version", 0L).intValue(); + } - @Override - public void close() { - inner.close(); - } + private static int getListTableVersionsCount(DirectoryNamespace ns) { + Map metrics = ns.retrieveOpsMetrics(); + return metrics.getOrDefault("list_table_versions", 0L).intValue(); } @Test void testExternalManifestStoreInvokesNamespaceApis(@TempDir Path managedVersioningTempDir) throws Exception { try (BufferAllocator allocator = new RootAllocator()) { - // Create namespace with table_version_tracking_enabled - TableVersionTrackingNamespace namespace = - new TableVersionTrackingNamespace(managedVersioningTempDir); + // Create namespace with table_version_tracking_enabled and ops_metrics_enabled + DirectoryNamespace namespaceClient = + createManagedVersioningNamespace(managedVersioningTempDir); String tableName = "test_table"; java.util.List tableId = Arrays.asList(tableName); @@ -542,7 +510,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { Dataset.write() .allocator(allocator) .reader(testReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(tableId) .mode(WriteParams.WriteMode.CREATE) .execute()) { @@ -554,7 +522,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { // Verify describe_table returns managed_versioning=true DescribeTableRequest descReq = new DescribeTableRequest(); descReq.setId(tableId); - DescribeTableResponse descResp = namespace.describeTable(descReq); + DescribeTableResponse descResp = namespaceClient.describeTable(descReq); assertEquals( Boolean.TRUE, @@ -562,22 +530,26 @@ public VectorSchemaRoot getVectorSchemaRoot() { "Expected managedVersioning=true when table_version_tracking_enabled"); // Open dataset through namespace - this should call list_table_versions for latest - int initialListCount = namespace.getListTableVersionsCount(); + int initialListCount = getListTableVersionsCount(namespaceClient); try (Dataset dsFromNamespace = - Dataset.open().allocator(allocator).namespace(namespace).tableId(tableId).build()) { + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .build()) { assertEquals(2, dsFromNamespace.countRows()); assertEquals(1, dsFromNamespace.version()); } assertEquals( initialListCount + 1, - namespace.getListTableVersionsCount(), + getListTableVersionsCount(namespaceClient), "list_table_versions should have been called once when opening latest version"); // Verify create_table_version was called once during CREATE assertEquals( 1, - namespace.getCreateTableVersionCount(), + getCreateTableVersionCount(namespaceClient), "create_table_version should have been called once during CREATE"); try (VectorSchemaRoot appendRoot = VectorSchemaRoot.create(schema, allocator)) { @@ -633,7 +605,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { Dataset.write() .allocator(allocator) .reader(appendReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(tableId) .mode(WriteParams.WriteMode.APPEND) .execute()) { @@ -644,28 +616,32 @@ public VectorSchemaRoot getVectorSchemaRoot() { assertEquals( 2, - namespace.getCreateTableVersionCount(), + getCreateTableVersionCount(namespaceClient), "create_table_version should have been called twice (once for CREATE, once for APPEND)"); // Open latest version - should call list_table_versions - int listCountBeforeLatest = namespace.getListTableVersionsCount(); + int listCountBeforeLatest = getListTableVersionsCount(namespaceClient); try (Dataset latestDs = - Dataset.open().allocator(allocator).namespace(namespace).tableId(tableId).build()) { + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .build()) { assertEquals(4, latestDs.countRows()); assertEquals(2, latestDs.version()); } assertEquals( listCountBeforeLatest + 1, - namespace.getListTableVersionsCount(), + getListTableVersionsCount(namespaceClient), "list_table_versions should have been called once when opening latest version"); // Open specific version (version 1) - should call describe_table_version - int describeCountBeforeV1 = namespace.getDescribeTableVersionCount(); + int describeCountBeforeV1 = getDescribeTableVersionCount(namespaceClient); try (Dataset v1Ds = Dataset.open() .allocator(allocator) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(tableId) .readOptions(new ReadOptions.Builder().setVersion(1L).build()) .build()) { @@ -675,10 +651,10 @@ public VectorSchemaRoot getVectorSchemaRoot() { } assertEquals( describeCountBeforeV1 + 1, - namespace.getDescribeTableVersionCount(), + getDescribeTableVersionCount(namespaceClient), "describe_table_version should have been called once when opening version 1"); - namespace.close(); + namespaceClient.close(); } } @@ -686,8 +662,8 @@ public VectorSchemaRoot getVectorSchemaRoot() { void testDatasetBasedCommitBuilderWithNamespace(@TempDir Path managedVersioningTempDir) throws Exception { try (BufferAllocator allocator = new RootAllocator()) { - TableVersionTrackingNamespace namespace = - new TableVersionTrackingNamespace(managedVersioningTempDir); + DirectoryNamespace namespaceClient = + createManagedVersioningNamespace(managedVersioningTempDir); String tableName = "test_table"; List tableId = Arrays.asList(tableName); @@ -748,7 +724,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { Dataset.write() .allocator(allocator) .reader(reader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(tableId) .mode(WriteParams.WriteMode.CREATE) .execute()) { @@ -760,12 +736,16 @@ public VectorSchemaRoot getVectorSchemaRoot() { // Verify initial create used createTableVersion once assertEquals( 1, - namespace.getCreateTableVersionCount(), + getCreateTableVersionCount(namespaceClient), "create_table_version should be called once during CREATE"); // Open dataset through namespace (returns dataset with managed versioning) Dataset existingDataset = - Dataset.open().allocator(allocator).namespace(namespace).tableId(tableId).build(); + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .build(); // Get the dataset URI for Fragment.create() String datasetUri = existingDataset.uri(); @@ -791,7 +771,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { } // Commit using dataset-based CommitBuilder WITH namespace (the new path) - int createCountBefore = namespace.getCreateTableVersionCount(); + int createCountBefore = getCreateTableVersionCount(namespaceClient); try (Transaction txn = new Transaction.Builder() .readVersion(existingDataset.version()) @@ -799,7 +779,7 @@ public VectorSchemaRoot getVectorSchemaRoot() { .build(); Dataset committed = new CommitBuilder(existingDataset) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(tableId) .execute(txn)) { assertEquals(2, committed.version()); @@ -809,18 +789,22 @@ public VectorSchemaRoot getVectorSchemaRoot() { // Verify createTableVersion was called for the dataset-based commit assertEquals( createCountBefore + 1, - namespace.getCreateTableVersionCount(), + getCreateTableVersionCount(namespaceClient), "create_table_version should be called for dataset-based CommitBuilder with namespace"); // Verify the data is accessible through namespace try (Dataset latestDs = - Dataset.open().allocator(allocator).namespace(namespace).tableId(tableId).build()) { + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .build()) { assertEquals(4, latestDs.countRows()); assertEquals(2, latestDs.version()); } existingDataset.close(); - namespace.close(); + namespaceClient.close(); } } @@ -829,7 +813,7 @@ void testConcurrentCreateAndDropWithSingleInstance() throws Exception { // Initialize namespace first - create parent namespace to ensure __manifest table // is created before concurrent operations CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("test_ns")); - namespace.createNamespace(createNsReq); + namespaceClient.createNamespace(createNsReq); int numTables = 10; ExecutorService executor = Executors.newFixedThreadPool(numTables); @@ -850,11 +834,11 @@ void testConcurrentCreateAndDropWithSingleInstance() throws Exception { CreateTableRequest createReq = new CreateTableRequest().id(Arrays.asList("test_ns", tableName)); - namespace.createTable(createReq, tableData); + namespaceClient.createTable(createReq, tableData); DropTableRequest dropReq = new DropTableRequest().id(Arrays.asList("test_ns", tableName)); - namespace.dropTable(dropReq); + namespaceClient.dropTable(dropReq); successCount.incrementAndGet(); } catch (Exception e) { @@ -875,7 +859,7 @@ void testConcurrentCreateAndDropWithSingleInstance() throws Exception { assertEquals(0, failCount.get(), "No tasks should fail"); ListTablesRequest listReq = new ListTablesRequest().id(Arrays.asList("test_ns")); - ListTablesResponse listResp = namespace.listTables(listReq); + ListTablesResponse listResp = namespaceClient.listTables(listReq); assertEquals(0, listResp.getTables().size(), "All tables should be dropped"); } diff --git a/python/pyproject.toml b/python/pyproject.toml index f105144e933..613ff5b8d5b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pylance" dynamic = ["version"] -dependencies = ["pyarrow>=14", "numpy>=1.22", "lance-namespace>=0.5.2"] +dependencies = ["pyarrow>=14", "numpy>=1.22", "lance-namespace>=0.6.1"] description = "python wrapper for Lance columnar format" authors = [{ name = "Lance Devs", email = "dev@lance.org" }] license = { file = "LICENSE" } diff --git a/python/python/lance/__init__.py b/python/python/lance/__init__.py index 0b905457a50..806f7125c07 100644 --- a/python/python/lance/__init__.py +++ b/python/python/lance/__init__.py @@ -6,7 +6,7 @@ import logging import os import warnings -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Union from . import io, log from .blob import Blob, BlobArray, BlobColumn, BlobFile, blob_array, blob_field @@ -36,7 +36,6 @@ from .namespace import ( DescribeTableRequest, LanceNamespace, - LanceNamespaceStorageOptionsProvider, ) from .schema import json_to_schema, schema_to_json from .util import sanitize_ts @@ -68,7 +67,6 @@ "LanceFragment", "LanceOperation", "LanceScanner", - "LanceNamespaceStorageOptionsProvider", "MergeInsertBuilder", "ScanStatistics", "Transaction", @@ -99,9 +97,8 @@ def dataset( index_cache_size_bytes: Optional[int] = None, read_params: Optional[Dict[str, any]] = None, session: Optional[Session] = None, - namespace: Optional[LanceNamespace] = None, + namespace_client: Optional[LanceNamespace] = None, table_id: Optional[List[str]] = None, - storage_options_provider: Optional[Any] = None, ) -> LanceDataset: """ Opens the Lance dataset from the address specified. @@ -111,7 +108,7 @@ def dataset( uri : str, optional Address to the Lance dataset. It can be a local file path `/tmp/data.lance`, or a cloud object store URI, i.e., `s3://bucket/data.lance`. - Either `uri` or (`namespace` + `table_id`) must be provided, but not both. + Either `uri` or (`namespace_client` + `table_id`) must be provided. version : optional, int | str If specified, load a specific version of the Lance dataset. Else, loads the latest version. A version number (`int`) or a tag (`str`) can be provided. @@ -161,69 +158,64 @@ def dataset( session : optional, lance.Session A session to use for this dataset. This contains the caches used by the across multiple datasets. - namespace : optional, LanceNamespace - A namespace instance from which to fetch table location and storage options. + namespace_client : optional, LanceNamespace + A namespace client from which to fetch table location and storage options. Use lance.namespace.connect() to create a namespace instance. Must be provided together with `table_id`. Cannot be used with `uri`. When provided, the table location will be fetched automatically from the namespace via describe_table(). table_id : optional, List[str] The table identifier when using a namespace (e.g., ["my_table"]). - Must be provided together with `namespace`. Cannot be used with `uri`. - storage_options_provider : optional - A storage options provider for automatic credential refresh. Must implement - `fetch_storage_options()` method that returns a dict of storage options. - If provided along with `namespace`, this takes precedence over the - namespace-created provider. + Must be provided together with `namespace_client`. Cannot be used with `uri`. Notes ----- - When using `namespace` and `table_id`: + When using `namespace_client` and `table_id`: - The `uri` parameter is optional and will be fetched from the namespace - Storage options from describe_table() will be used automatically - A dynamic storage options provider will be created to refresh credentials - Initial storage options from describe_table() will be merged with any provided `storage_options` """ - # Validate that user provides either uri OR (namespace + table_id), not both + # Validate that user provides either uri OR (namespace_client + table_id), not both has_uri = uri is not None - has_namespace = namespace is not None or table_id is not None + has_namespace = namespace_client is not None or table_id is not None if has_uri and has_namespace: raise ValueError( - "Cannot specify both 'uri' and 'namespace/table_id'. " - "Please provide either 'uri' or both 'namespace' and 'table_id'." + "Cannot specify both 'uri' and 'namespace_client/table_id'. " + "Please provide either 'uri' or both 'namespace_client' and 'table_id'." ) elif not has_uri and not has_namespace: raise ValueError( - "Must specify either 'uri' or both 'namespace' and 'table_id'." + "Must specify either 'uri' or both 'namespace_client' and 'table_id'." ) # Handle namespace resolution in Python - managed_versioning = False - if namespace is not None: + namespace_client_managed_versioning = False + if namespace_client is not None: if table_id is None: raise ValueError( - "Both 'namespace' and 'table_id' must be provided together." + "Both 'namespace_client' and 'table_id' must be provided together." ) request = DescribeTableRequest(id=table_id, version=version) - response = namespace.describe_table(request) + response = namespace_client.describe_table(request) uri = response.location if uri is None: raise ValueError("Namespace did not return a 'location' for the table") # Check if namespace manages versioning (commits go through namespace API) - managed_versioning = getattr(response, "managed_versioning", None) is True + namespace_client_managed_versioning = ( + getattr(response, "managed_versioning", None) is True + ) namespace_storage_options = response.storage_options - if namespace_storage_options: - if storage_options_provider is None: - storage_options_provider = LanceNamespaceStorageOptionsProvider( - namespace=namespace, table_id=table_id - ) + # Merge namespace storage options with user-provided options + # Namespace options take precedence + if namespace_storage_options is not None: if storage_options is None: storage_options = namespace_storage_options else: @@ -231,7 +223,9 @@ def dataset( merged_options.update(namespace_storage_options) storage_options = merged_options elif table_id is not None: - raise ValueError("Both 'namespace' and 'table_id' must be provided together.") + raise ValueError( + "Both 'namespace_client' and 'table_id' must be provided together." + ) ds = LanceDataset( uri, @@ -245,9 +239,9 @@ def dataset( index_cache_size_bytes=index_cache_size_bytes, read_params=read_params, session=session, - storage_options_provider=storage_options_provider, - namespace=namespace if managed_versioning else None, - table_id=table_id if managed_versioning else None, + namespace_client=namespace_client, + table_id=table_id, + namespace_client_managed_versioning=namespace_client_managed_versioning, ) if version is None and asof is not None: ts_cutoff = sanitize_ts(asof) @@ -271,7 +265,9 @@ def dataset( index_cache_size_bytes=index_cache_size_bytes, read_params=read_params, session=session, - storage_options_provider=storage_options_provider, + namespace_client=namespace_client, + table_id=table_id, + namespace_client_managed_versioning=namespace_client_managed_versioning, ) else: return ds diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index 8c1016c1504..d02a5a9c1a0 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -78,7 +78,6 @@ from lance.namespace import LanceNamespace from .commit import CommitLock - from .io import StorageOptionsProvider from .lance.indices import IndexDescription from .progress import FragmentWriteProgress from .types import ReaderLike @@ -430,14 +429,13 @@ def __init__( index_cache_size_bytes: Optional[int] = None, read_params: Optional[Dict[str, Any]] = None, session: Optional[Session] = None, - storage_options_provider: Optional[Any] = None, - namespace: Optional[Any] = None, + namespace_client: Optional[Any] = None, table_id: Optional[List[str]] = None, + namespace_client_managed_versioning: bool = False, ): uri = os.fspath(uri) if isinstance(uri, Path) else uri self._uri = uri self._storage_options = storage_options - self._storage_options_provider = storage_options_provider # Handle deprecation warning for index_cache_size if index_cache_size is not None: @@ -449,6 +447,13 @@ def __init__( stacklevel=2, ) + # Store namespace_client and table_id for credential refresh in file operations + self._namespace_client = namespace_client + self._table_id = table_id + self._namespace_client_managed_versioning = namespace_client_managed_versioning + + # Storage options provider is automatically created in Rust when + # namespace_client and table_id are provided self._ds = _Dataset( uri, version, @@ -462,9 +467,9 @@ def __init__( index_cache_size_bytes=index_cache_size_bytes, read_params=read_params, session=session, - storage_options_provider=storage_options_provider, - namespace=namespace, + namespace_client=namespace_client, table_id=table_id, + namespace_client_managed_versioning=namespace_client_managed_versioning, ) self._default_scan_options = default_scan_options self._read_params = read_params @@ -529,13 +534,19 @@ def __setstate__(self, state): ) self._default_scan_options = default_scan_options self._read_params = read_params - self._storage_options_provider = None + self._namespace_client = None + self._table_id = None + self._namespace_client_managed_versioning = False def __copy__(self): ds = LanceDataset.__new__(LanceDataset) ds._uri = self._uri ds._storage_options = self._storage_options - ds._storage_options_provider = self._storage_options_provider + ds._namespace_client = self._namespace_client + ds._table_id = self._table_id + ds._namespace_client_managed_versioning = ( + self._namespace_client_managed_versioning + ) ds._ds = copy.copy(self._ds) ds._default_scan_options = self._default_scan_options ds._read_params = self._read_params.copy() if self._read_params else None @@ -630,7 +641,8 @@ def create_branch( ds._ds = new_ds ds._uri = new_ds.uri ds._storage_options = self._storage_options - ds._storage_options_provider = self._storage_options_provider + ds._namespace_client = self._namespace_client + ds._table_id = self._table_id ds._default_scan_options = self._default_scan_options ds._read_params = self._read_params return ds @@ -2349,7 +2361,8 @@ def new_file_session(self): return LanceFileSession( base_path=self._uri, storage_options=self.latest_storage_options(), - storage_options_provider=self._storage_options_provider, + namespace_client=self._namespace_client, + table_id=self._table_id, ) def checkout_version( @@ -3595,15 +3608,15 @@ def commit( read_version: Optional[int] = None, commit_lock: Optional[CommitLock] = None, storage_options: Optional[Dict[str, str]] = None, - storage_options_provider: Optional["StorageOptionsProvider"] = None, enable_v2_manifest_paths: Optional[bool] = None, detached: Optional[bool] = False, max_retries: int = 20, *, commit_message: Optional[str] = None, enable_stable_row_ids: Optional[bool] = None, - namespace: Optional["LanceNamespace"] = None, + namespace_client: Optional["LanceNamespace"] = None, table_id: Optional[List[str]] = None, + namespace_client_managed_versioning: bool = False, ) -> LanceDataset: """Create a new version of dataset @@ -3643,8 +3656,6 @@ def commit( storage_options : optional, dict Extra options that make sense for a particular storage connection. This is used to store connection parameters like credentials, endpoint, etc. - storage_options_provider : StorageOptionsProvider, optional - A provider for dynamic storage options with automatic credential refresh. enable_v2_manifest_paths : bool, optional If True, and this is a new dataset, uses the new V2 manifest paths. These paths provide more efficient opening of datasets with many @@ -3670,12 +3681,12 @@ def commit( row IDs assign each row a monotonically increasing id that persists across compaction and other maintenance operations. This option is ignored for existing datasets. - namespace : LanceNamespace, optional - A namespace instance. Must be provided together with table_id. + namespace_client : LanceNamespace, optional + A namespace client. Must be provided together with table_id. Use lance.namespace.connect() to create a namespace. table_id : List[str], optional The table identifier within the namespace (e.g., ["workspace", "table"]). - Must be provided together with namespace. + Must be provided together with namespace_client. Returns ------- @@ -3729,6 +3740,9 @@ def commit( "read_version is required for all operations except " "Overwrite and Restore" ) + + # Storage options provider is automatically created in Rust when + # namespace_client and table_id are provided if isinstance(operation, Transaction): if commit_message is not None: raise ValueError( @@ -3741,13 +3755,13 @@ def commit( operation, commit_lock, storage_options=storage_options, - storage_options_provider=storage_options_provider, enable_v2_manifest_paths=enable_v2_manifest_paths, detached=detached, max_retries=max_retries, enable_stable_row_ids=enable_stable_row_ids, - namespace=namespace, + namespace_client=namespace_client, table_id=table_id, + namespace_client_managed_versioning=namespace_client_managed_versioning, ) elif isinstance(operation, LanceOperation.BaseOperation): new_ds = _Dataset.commit( @@ -3756,14 +3770,14 @@ def commit( read_version, commit_lock, storage_options=storage_options, - storage_options_provider=storage_options_provider, enable_v2_manifest_paths=enable_v2_manifest_paths, detached=detached, max_retries=max_retries, commit_message=commit_message, enable_stable_row_ids=enable_stable_row_ids, - namespace=namespace, + namespace_client=namespace_client, table_id=table_id, + namespace_client_managed_versioning=namespace_client_managed_versioning, ) else: raise TypeError( @@ -3773,7 +3787,9 @@ def commit( ds = LanceDataset.__new__(LanceDataset) ds._storage_options = storage_options - ds._storage_options_provider = storage_options_provider + ds._namespace_client = namespace_client + ds._table_id = table_id + ds._namespace_client_managed_versioning = namespace_client_managed_versioning ds._ds = new_ds ds._uri = new_ds.uri ds._default_scan_options = None @@ -3786,7 +3802,6 @@ def commit_batch( transactions: Sequence[Transaction], commit_lock: Optional[CommitLock] = None, storage_options: Optional[Dict[str, str]] = None, - storage_options_provider: Optional["StorageOptionsProvider"] = None, enable_v2_manifest_paths: Optional[bool] = None, detached: Optional[bool] = False, max_retries: int = 20, @@ -3815,8 +3830,6 @@ def commit_batch( storage_options : optional, dict Extra options that make sense for a particular storage connection. This is used to store connection parameters like credentials, endpoint, etc. - storage_options_provider : StorageOptionsProvider, optional - A provider for dynamic storage options with automatic credential refresh. enable_v2_manifest_paths : bool, optional If True, and this is a new dataset, uses the new V2 manifest paths. These paths provide more efficient opening of datasets with many @@ -3863,7 +3876,6 @@ def commit_batch( transactions, commit_lock, storage_options=storage_options, - storage_options_provider=storage_options_provider, enable_v2_manifest_paths=enable_v2_manifest_paths, detached=detached, max_retries=max_retries, @@ -3872,7 +3884,8 @@ def commit_batch( ds._ds = new_ds ds._uri = new_ds.uri ds._storage_options = storage_options - ds._storage_options_provider = storage_options_provider + ds._namespace_client = None + ds._table_id = None ds._default_scan_options = None ds._read_params = None return BulkCommitResult( @@ -5932,7 +5945,7 @@ def write_dataset( initial_bases: Optional[List[DatasetBasePath]] = None, target_bases: Optional[List[str]] = None, allow_external_blob_outside_bases: bool = False, - namespace: Optional[LanceNamespace] = None, + namespace_client: Optional[LanceNamespace] = None, table_id: Optional[List[str]] = None, ) -> LanceDataset: """Write a given data_obj to the given uri @@ -5946,7 +5959,7 @@ def write_dataset( uri: str, Path, LanceDataset, or None Where to write the dataset to (directory). If a LanceDataset is passed, the session will be reused. - Either `uri` or (`namespace` + `table_id`) must be provided, but not both. + Either `uri` or (`namespace_client` + `table_id`) must be provided. schema: Schema, optional If specified and the input is a pandas DataFrame, use this schema instead of the default pandas to arrow table conversion. @@ -6029,45 +6042,44 @@ def write_dataset( allow_external_blob_outside_bases: bool, default False If False, external blob URIs must map to the dataset root or a registered base path. If True, external blob URIs outside registered bases are allowed. - namespace : optional, LanceNamespace - A namespace instance from which to fetch table location and storage options. + namespace_client : optional, LanceNamespace + A namespace client from which to fetch table location and storage options. Must be provided together with `table_id`. Cannot be used with `uri`. When provided, the table location will be fetched automatically from the namespace via describe_table(). Storage options will be automatically refreshed before they expire. table_id : optional, List[str] The table identifier when using a namespace (e.g., ["my_table"]). - Must be provided together with `namespace`. Cannot be used with `uri`. + Must be provided together with `namespace_client`. Cannot be used with `uri`. Notes ----- - When using `namespace` and `table_id`: + When using `namespace_client` and `table_id`: - The `uri` parameter is optional and will be fetched from the namespace - Storage options from describe_table() will be used automatically - - A `LanceNamespaceStorageOptionsProvider` will be created automatically for - storage options refresh + - Storage options provider will be created automatically for credential refresh - Initial storage options from describe_table() will be merged with any provided `storage_options` """ - # Validate that user provides either uri OR (namespace + table_id), not both + # Validate that user provides either uri OR (namespace_client + table_id), not both has_uri = uri is not None - has_namespace = namespace is not None or table_id is not None + has_namespace = namespace_client is not None or table_id is not None if has_uri and has_namespace: raise ValueError( - "Cannot specify both 'uri' and 'namespace/table_id'. " - "Please provide either 'uri' or both 'namespace' and 'table_id'." + "Cannot specify both 'uri' and 'namespace_client/table_id'. " + "Please provide either 'uri' or both 'namespace_client' and 'table_id'." ) elif not has_uri and not has_namespace: raise ValueError( - "Must specify either 'uri' or both 'namespace' and 'table_id'." + "Must specify either 'uri' or both 'namespace_client' and 'table_id'." ) # Handle namespace-based dataset writing - if namespace is not None: + if namespace_client is not None: if table_id is None: raise ValueError( - "Both 'namespace' and 'table_id' must be provided together." + "Both 'namespace_client' and 'table_id' must be provided together." ) # Implement write_into_namespace logic in Python @@ -6079,16 +6091,15 @@ def write_dataset( from .namespace import ( DeclareTableRequest, DescribeTableRequest, - LanceNamespaceStorageOptionsProvider, ) # Determine which namespace method to call based on mode if mode == "create": declare_request = DeclareTableRequest(id=table_id, location=None) - response = namespace.declare_table(declare_request) + response = namespace_client.declare_table(declare_request) elif mode in ("append", "overwrite"): request = DescribeTableRequest(id=table_id, version=None) - response = namespace.describe_table(request) + response = namespace_client.describe_table(request) else: raise ValueError(f"Invalid mode: {mode}") @@ -6100,33 +6111,29 @@ def write_dataset( ) # Check if namespace manages versioning (commits go through namespace API) - managed_versioning = getattr(response, "managed_versioning", None) is True + namespace_client_managed_versioning = ( + getattr(response, "managed_versioning", None) is True + ) # Use namespace storage options namespace_storage_options = response.storage_options - # Set up storage options and provider - if namespace_storage_options: - # Create the storage options provider for automatic refresh - storage_options_provider = LanceNamespaceStorageOptionsProvider( - namespace=namespace, table_id=table_id - ) - - # Merge namespace storage options with any existing options - # Namespace options take precedence (same as Rust implementation) + # Merge namespace storage options with any existing options + # Namespace options take precedence (same as Rust implementation) + # Storage options provider will be created automatically in Rust + if namespace_storage_options is not None: if storage_options is None: storage_options = dict(namespace_storage_options) else: merged_options = dict(storage_options) merged_options.update(namespace_storage_options) storage_options = merged_options - else: - storage_options_provider = None elif table_id is not None: - raise ValueError("Both 'namespace' and 'table_id' must be provided together.") + raise ValueError( + "Both 'namespace_client' and 'table_id' must be provided together." + ) else: - storage_options_provider = None - managed_versioning = False + namespace_client_managed_versioning = False if use_legacy_format is not None: warnings.warn( @@ -6164,14 +6171,14 @@ def write_dataset( "allow_external_blob_outside_bases": allow_external_blob_outside_bases, } - # Add storage_options_provider if created from namespace - if storage_options_provider is not None: - params["storage_options_provider"] = storage_options_provider - - # Add namespace and table_id for managed versioning (external manifest store) - if managed_versioning and namespace is not None and table_id is not None: - params["namespace"] = namespace + # Add namespace_client and table_id for storage options provider and managed + # versioning. The storage options provider will be created automatically in Rust. + if namespace_client is not None and table_id is not None: + params["namespace_client"] = namespace_client params["table_id"] = table_id + params["namespace_client_managed_versioning"] = ( + namespace_client_managed_versioning + ) if commit_lock: if not callable(commit_lock): @@ -6189,7 +6196,9 @@ def write_dataset( ds = LanceDataset.__new__(LanceDataset) ds._storage_options = storage_options - ds._storage_options_provider = None + ds._namespace_client = namespace_client + ds._table_id = table_id + ds._namespace_client_managed_versioning = namespace_client_managed_versioning ds._ds = inner_ds ds._uri = inner_ds.uri ds._default_scan_options = None diff --git a/python/python/lance/file.py b/python/python/lance/file.py index 8a20e4aff2f..011fbe4a01d 100644 --- a/python/python/lance/file.py +++ b/python/python/lance/file.py @@ -2,11 +2,10 @@ # SPDX-FileCopyrightText: Copyright The Lance Authors from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Union import pyarrow as pa -from .io import StorageOptionsProvider from .lance import ( LanceBufferDescriptor, LanceColumnMetadata, @@ -25,6 +24,9 @@ LanceFileWriter as _LanceFileWriter, ) +if TYPE_CHECKING: + from .namespace import LanceNamespace + class ReaderResults: """ @@ -67,7 +69,8 @@ def __init__( storage_options: Optional[Dict[str, str]] = None, columns: Optional[List[str]] = None, *, - storage_options_provider: Optional[StorageOptionsProvider] = None, + namespace_client: Optional["LanceNamespace"] = None, + table_id: Optional[List[str]] = None, _inner_reader: Optional[_LanceFileReader] = None, ): """ @@ -82,9 +85,12 @@ def __init__( storage_options : optional, dict Extra options to be used for a particular storage connection. This is used to store connection parameters like credentials, endpoint, etc. - storage_options_provider : optional - A provider that can provide storage options dynamically. This is useful - for credentials that need to be refreshed or vended on-demand. + namespace_client : optional, LanceNamespace + A namespace client for automatic credential refresh. + Must be provided together with table_id. + table_id : optional, List[str] + The table identifier within the namespace. + Must be provided together with namespace_client. columns: list of str, default None List of column names to be fetched. All columns are fetched if None or unspecified. @@ -97,7 +103,8 @@ def __init__( self._reader = _LanceFileReader( path, storage_options=storage_options, - storage_options_provider=storage_options_provider, + namespace_client=namespace_client, + table_id=table_id, columns=columns, ) @@ -213,7 +220,8 @@ def __init__( self, base_path: str, storage_options: Optional[Dict[str, str]] = None, - storage_options_provider: Optional[StorageOptionsProvider] = None, + namespace_client: Optional["LanceNamespace"] = None, + table_id: Optional[List[str]] = None, ): """ Creates a new file session @@ -227,16 +235,20 @@ def __init__( storage_options : optional, dict Extra options to be used for a particular storage connection. This is used to store connection parameters like credentials, endpoint, etc. - storage_options_provider : optional - A provider that can provide storage options dynamically. This is useful - for credentials that need to be refreshed or vended on-demand. + namespace_client : optional, LanceNamespace + A namespace client for automatic credential refresh. + Must be provided together with table_id. + table_id : optional, List[str] + The table identifier within the namespace. + Must be provided together with namespace_client. """ if isinstance(base_path, Path): base_path = str(base_path) self._session = _LanceFileSession( base_path, storage_options=storage_options, - storage_options_provider=storage_options_provider, + namespace_client=namespace_client, + table_id=table_id, ) def open_reader( @@ -380,7 +392,8 @@ def __init__( data_cache_bytes: Optional[int] = None, version: Optional[str] = None, storage_options: Optional[Dict[str, str]] = None, - storage_options_provider: Optional[StorageOptionsProvider] = None, + namespace_client: Optional["LanceNamespace"] = None, + table_id: Optional[List[str]] = None, max_page_bytes: Optional[int] = None, _inner_writer: Optional[_LanceFileWriter] = None, **kwargs, @@ -407,10 +420,12 @@ def __init__( storage_options : optional, dict Extra options to be used for a particular storage connection. This is used to store connection parameters like credentials, endpoint, etc. - storage_options_provider : optional, StorageOptionsProvider - A storage options provider that can fetch and refresh storage options - dynamically. This is useful for credentials that expire and need to be - refreshed automatically. + namespace_client : optional, LanceNamespace + A namespace client for automatic credential refresh. + Must be provided together with table_id. + table_id : optional, List[str] + The table identifier within the namespace. + Must be provided together with namespace_client. max_page_bytes : optional, int The maximum size of a page in bytes, if a single array would create a page larger than this then it will be split into multiple pages. The @@ -427,7 +442,8 @@ def __init__( data_cache_bytes=data_cache_bytes, version=version, storage_options=storage_options, - storage_options_provider=storage_options_provider, + namespace_client=namespace_client, + table_id=table_id, max_page_bytes=max_page_bytes, **kwargs, ) diff --git a/python/python/lance/fragment.py b/python/python/lance/fragment.py index f8cff450f06..ffdfacf443c 100644 --- a/python/python/lance/fragment.py +++ b/python/python/lance/fragment.py @@ -48,6 +48,7 @@ Transaction, ) from .lance import LanceSchema + from .namespace import LanceNamespace DEFAULT_MAX_BYTES_PER_FILE = 90 * 1024 * 1024 * 1024 @@ -314,6 +315,8 @@ def create( data_storage_version: Optional[str] = None, use_legacy_format: Optional[bool] = None, storage_options: Optional[Dict[str, str]] = None, + namespace_client: Optional["LanceNamespace"] = None, + table_id: Optional[List[str]] = None, ) -> FragmentMetadata: """Create a :class:`FragmentMetadata` from the given data. @@ -353,6 +356,15 @@ def create( storage_options : optional, dict Extra options that make sense for a particular storage connection. This is used to store connection parameters like credentials, endpoint, etc. + namespace_client : optional, LanceNamespace + A namespace client for automatic credential refresh. When provided with + `table_id`, a storage options provider will be created automatically to + refresh credentials via the namespace. Must be provided together with + `table_id`. The caller should provide initial/merged storage options via + the `storage_options` parameter. + table_id : optional, List[str] + The table identifier when using a namespace (e.g., ["my_table"]). + Must be provided together with `namespace_client`. See Also -------- @@ -368,6 +380,16 @@ def create( ------- FragmentMetadata """ + # Validate namespace_client and table_id are provided together + if namespace_client is not None and table_id is None: + raise ValueError( + "Both 'namespace_client' and 'table_id' must be provided together." + ) + elif table_id is not None and namespace_client is None: + raise ValueError( + "Both 'namespace_client' and 'table_id' must be provided together." + ) + if use_legacy_format is not None: warnings.warn( "use_legacy_format is deprecated, use data_storage_version instead", @@ -394,6 +416,8 @@ def create( mode=mode, data_storage_version=data_storage_version, storage_options=storage_options, + namespace_client=namespace_client, + table_id=table_id, ) @property @@ -877,10 +901,11 @@ def write_fragments( data_storage_version: Optional[str] = None, use_legacy_format: Optional[bool] = None, storage_options: Optional[Dict[str, str]] = None, - storage_options_provider=None, enable_stable_row_ids: bool = False, target_bases: Optional[List[str]] = None, initial_bases: Optional[List["DatasetBasePath"]] = None, + namespace_client: Optional[LanceNamespace] = None, + table_id: Optional[List[str]] = None, ) -> Transaction: ... @overload @@ -898,10 +923,11 @@ def write_fragments( data_storage_version: Optional[str] = None, use_legacy_format: Optional[bool] = None, storage_options: Optional[Dict[str, str]] = None, - storage_options_provider=None, enable_stable_row_ids: bool = False, target_bases: Optional[List[str]] = None, initial_bases: Optional[List["DatasetBasePath"]] = None, + namespace_client: Optional[LanceNamespace] = None, + table_id: Optional[List[str]] = None, ) -> List[FragmentMetadata]: ... @@ -919,10 +945,11 @@ def write_fragments( data_storage_version: Optional[str] = None, use_legacy_format: Optional[bool] = None, storage_options: Optional[Dict[str, str]] = None, - storage_options_provider=None, enable_stable_row_ids: bool = False, target_bases: Optional[List[str]] = None, initial_bases: Optional[List["DatasetBasePath"]] = None, + namespace_client: Optional[LanceNamespace] = None, + table_id: Optional[List[str]] = None, ) -> List[FragmentMetadata] | Transaction: """ Write data into one or more fragments. @@ -971,10 +998,6 @@ def write_fragments( storage_options : Optional[Dict[str, str]] Extra options that make sense for a particular storage connection. This is used to store connection parameters like credentials, endpoint, etc. - storage_options_provider : Optional[StorageOptionsProvider] - A storage options provider that can fetch and refresh storage options - dynamically. This is useful for credentials that expire and need to be - refreshed automatically. enable_stable_row_ids: bool Experimental: if set to true, the writer will use stable row ids. These row ids are stable after compaction operations, but not after updates. @@ -1002,6 +1025,15 @@ def write_fragments( **Only valid in CREATE mode**. Will raise an error if used with APPEND/OVERWRITE modes. + namespace_client : optional, LanceNamespace + A namespace client for automatic credential refresh. When provided with + `table_id`, a storage options provider will be created automatically to + refresh credentials via the namespace. Must be provided together with + `table_id`. The caller should provide initial/merged storage options via + the `storage_options` parameter. + table_id : optional, List[str] + The table identifier when using a namespace (e.g., ["my_table"]). + Must be provided together with `namespace_client`. Returns ------- @@ -1019,6 +1051,16 @@ def write_fragments( """ from .dataset import LanceDataset + # Validate namespace_client and table_id are provided together + if namespace_client is not None and table_id is None: + raise ValueError( + "Both 'namespace_client' and 'table_id' must be provided together." + ) + elif table_id is not None and namespace_client is None: + raise ValueError( + "Both 'namespace_client' and 'table_id' must be provided together." + ) + reader = _coerce_reader(data, schema) if isinstance(dataset_uri, Path): @@ -1050,7 +1092,8 @@ def write_fragments( progress=progress, data_storage_version=data_storage_version, storage_options=storage_options, - storage_options_provider=storage_options_provider, + namespace_client=namespace_client, + table_id=table_id, enable_stable_row_ids=enable_stable_row_ids, target_bases=target_bases, initial_bases=initial_bases, diff --git a/python/python/lance/io.py b/python/python/lance/io.py index b12d6dc106f..bfda99d22e7 100644 --- a/python/python/lance/io.py +++ b/python/python/lance/io.py @@ -4,137 +4,5 @@ """I/O utilities for Lance datasets. This module provides utilities for customizing how Lance datasets interact with -cloud storage, including credential management for long-running operations. +cloud storage. """ - -from abc import ABC, abstractmethod -from typing import Dict - - -class StorageOptionsProvider(ABC): - """Abstract base class for providing storage options to Lance datasets. - - Storage options providers enable automatic refresh for long-running operations - on cloud storage (S3, Azure, GCS). This is currently only used for refreshing - AWS temporary access credentials. Implement this interface to integrate with - custom credential management systems such as AWS STS, GCP STS, or - proprietary credential services. - - The provider is called automatically before storage options expire, ensuring - uninterrupted access during long-running queries, training jobs, or data processing. - - Example - ------- - >>> import lance - >>> class MyStorageOptionsProvider(StorageOptionsProvider): - ... def fetch_storage_options(self): - ... # Fetch from your credential service - ... return { - ... "aws_access_key_id": "ASIA...", - ... "aws_secret_access_key": "secret", - ... "aws_session_token": "token", - ... "expires_at_millis": "1234567890000", - ... } - ... - >>> provider = MyStorageOptionsProvider() - >>> dataset = lance.dataset( # doctest: +SKIP - ... "s3://bucket/table.lance", storage_options_provider=provider - ... ) - - Error Handling - -------------- - If fetch_storage_options() raises an exception, operations requiring - credentials will fail. Implementations should handle recoverable errors - internally (e.g., retry token refresh) and only raise exceptions for - unrecoverable errors. - """ - - @abstractmethod - def fetch_storage_options(self) -> Dict[str, str]: - """Get fresh storage credentials. - - This method is called automatically before each request and before existing - credentials expire. It must return credentials in the format below. - - Returns - ------- - Dict[str, str] - Dictionary of string key-value pairs containing cloud storage credentials - and expiration time. Required keys: - - - "expires_at_millis" (str): Unix timestamp in milliseconds (as string) - when credentials expire. Lance will automatically call - fetch_storage_options() again before this time. - - Plus provider-specific credential keys: - - AWS S3: - - "aws_access_key_id" (str): AWS access key - - "aws_secret_access_key" (str): AWS secret key - - "aws_session_token" (str, optional): Session token for temporary - credentials - - Azure Blob Storage: - - "account_name" (str): Storage account name - - "account_key" (str): Storage account key - - Or "sas_token" (str): SAS token - - Google Cloud Storage: - - "service_account_key" (str): Service account JSON key - - Or "token" (str): OAuth token - - Raises - ------ - Exception - If unable to fetch credentials, the exception will be propagated - and operations requiring credentials will fail. - - Example - ------- - >>> def fetch_storage_options(self): - ... # Example: AWS temporary credentials - ... response = sts_client.assume_role( - ... RoleArn='arn:aws:iam::123456789012:role/DataReader', - ... RoleSessionName='lance-session' - ... ) - ... creds = response['Credentials'] - ... expires_at_millis = int(creds['Expiration'].timestamp() * 1000) - ... return { - ... "aws_access_key_id": creds['AccessKeyId'], - ... "aws_secret_access_key": creds['SecretAccessKey'], - ... "aws_session_token": creds['SessionToken'], - ... "expires_at_millis": str(expires_at_millis), - ... } - """ - pass - - def provider_id(self) -> str: - """Return a human-readable unique identifier for this provider instance. - - This is used for equality comparison and hashing in the object store - registry. Two providers with the same ID will be treated as equal and - share the same cached ObjectStore instance. - - The default implementation uses the class name and object's string - representation. Override this method to provide semantic equality based - on configuration. - - Returns - ------- - str - A human-readable unique identifier string. - For example: "MyProvider { endpoint: 'https://api.example.com' }" - - Example - ------- - >>> class MyProvider(StorageOptionsProvider): - ... def __init__(self, endpoint): - ... self.endpoint = endpoint - ... - ... def fetch_storage_options(self): - ... return {"expires_at_millis": "1234567890000"} - ... - ... def provider_id(self): - ... return f"MyProvider {{ endpoint: {self.endpoint!r} }}" - """ - return f"{self.__class__.__name__} {{ repr: {str(self)!r} }}" diff --git a/python/python/lance/lance/__init__.pyi b/python/python/lance/lance/__init__.pyi index 6b9e6e2ea0c..14c954dcd66 100644 --- a/python/python/lance/lance/__init__.pyi +++ b/python/python/lance/lance/__init__.pyi @@ -28,6 +28,7 @@ from typing import ( ) import pyarrow as pa +from lance_namespace import LanceNamespace from .._arrow.bf16 import BFloat16Array from ..commit import CommitLock @@ -46,7 +47,6 @@ from ..fragment import ( DataFile, FragmentMetadata, ) -from ..io import StorageOptionsProvider from ..progress import FragmentWriteProgress as FragmentWriteProgress from ..types import ReaderLike as ReaderLike from ..udf import BatchUDF as BatchUDF @@ -100,7 +100,8 @@ class LanceFileWriter: data_cache_bytes: Optional[int], version: Optional[str], storage_options: Optional[Dict[str, str]], - storage_options_provider: Optional[StorageOptionsProvider], + namespace_client: Optional[LanceNamespace], + table_id: Optional[List[str]], keep_original_array: Optional[bool], max_page_bytes: Optional[int], ): ... @@ -114,7 +115,8 @@ class LanceFileSession: self, base_path: str, storage_options: Optional[Dict[str, str]] = None, - storage_options_provider: Optional[StorageOptionsProvider] = None, + namespace_client: Optional[LanceNamespace] = None, + table_id: Optional[List[str]] = None, ): ... def open_reader( self, path: str, columns: Optional[List[str]] = None @@ -138,7 +140,8 @@ class LanceFileReader: self, path: str, storage_options: Optional[Dict[str, str]], - storage_options_provider: Optional[StorageOptionsProvider], + namespace_client: Optional[LanceNamespace], + table_id: Optional[List[str]], columns: Optional[List[str]] = None, ): ... def read_all( @@ -385,7 +388,6 @@ class _Dataset: read_version: Optional[int] = None, commit_lock: Optional[CommitLock] = None, storage_options: Optional[Dict[str, str]] = None, - storage_options_provider: Optional[StorageOptionsProvider] = None, enable_v2_manifest_paths: Optional[bool] = None, detached: Optional[bool] = None, max_retries: Optional[int] = None, @@ -398,7 +400,6 @@ class _Dataset: transactions: Sequence[Transaction], commit_lock: Optional[CommitLock] = None, storage_options: Optional[Dict[str, str]] = None, - storage_options_provider: Optional[StorageOptionsProvider] = None, enable_v2_manifest_paths: Optional[bool] = None, detached: Optional[bool] = None, max_retries: Optional[int] = None, @@ -509,6 +510,8 @@ def _write_fragments( progress: Optional[FragmentWriteProgress], data_storage_version: Optional[str], storage_options: Optional[Dict[str, str]], + namespace_client: Optional[LanceNamespace], + table_id: Optional[List[str]], enable_stable_row_ids: bool, ): ... def _write_fragments_transaction( @@ -521,6 +524,8 @@ def _write_fragments_transaction( progress: Optional[FragmentWriteProgress], data_storage_version: Optional[str], storage_options: Optional[Dict[str, str]], + namespace_client: Optional[LanceNamespace], + table_id: Optional[List[str]], enable_stable_row_ids: bool, ) -> Transaction: ... def _json_to_schema(schema_json: str) -> pa.Schema: ... diff --git a/python/python/lance/namespace.py b/python/python/lance/namespace.py index 63ad2abd007..ae6316becf8 100644 --- a/python/python/lance/namespace.py +++ b/python/python/lance/namespace.py @@ -13,7 +13,7 @@ """ from abc import ABC, abstractmethod -from typing import Dict, List, Optional +from typing import Dict, Optional from lance_namespace import ( CreateNamespaceRequest, @@ -47,7 +47,6 @@ TableExistsRequest, ) -from .io import StorageOptionsProvider from .lance import PyDirectoryNamespace # Low-level Rust binding try: @@ -64,7 +63,6 @@ "DirectoryNamespace", "RestNamespace", "RestAdapter", - "LanceNamespaceStorageOptionsProvider", "DynamicContextProvider", ] @@ -265,6 +263,16 @@ class DirectoryNamespace(LanceNamespace): - credential_vendor.azure_duration_millis (optional): Duration in ms (default: 3600000, up to 7 days) + Testing properties: + - ops_metrics_enabled (optional): "true" to enable operation metrics + tracking. Use `retrieve_ops_metrics()` to get call counts. + - vend_input_storage_options (optional): "true" to return input storage + options in describe_table() when no credential vendor is configured. + Useful for testing credential refresh. + - vend_input_storage_options_refresh_interval_millis (optional): When set + with vend_input_storage_options, adds expires_at_millis to storage + options. Value is current_time_millis + this interval. + Examples -------- >>> import lance.namespace @@ -451,6 +459,33 @@ def batch_delete_table_versions(self, request: dict) -> dict: """ return self._inner.batch_delete_table_versions(request) + # Operation metrics methods + + def retrieve_ops_metrics(self) -> Dict[str, int]: + """Retrieve operation metrics as a dictionary. + + Returns a dict where keys are operation names (e.g., "list_tables", + "describe_table") and values are the number of times each operation + was called. + + Returns an empty dict if `ops_metrics_enabled` was false when creating + the namespace. + + Returns + ------- + Dict[str, int] + Operation name to call count mapping + """ + return self._inner.retrieve_ops_metrics() + + def reset_ops_metrics(self) -> None: + """Reset all operation metrics counters to zero. + + Does nothing if `ops_metrics_enabled` was false when creating the + namespace. + """ + self._inner.reset_ops_metrics() + class RestNamespace(LanceNamespace): """REST-based Lance Namespace implementation backed by Rust. @@ -658,6 +693,33 @@ def batch_delete_table_versions(self, request: dict) -> dict: """ return self._inner.batch_delete_table_versions(request) + # Operation metrics methods + + def retrieve_ops_metrics(self) -> Dict[str, int]: + """Retrieve operation metrics as a dictionary. + + Returns a dict where keys are operation names (e.g., "list_tables", + "describe_table") and values are the number of times each operation + was called. + + Returns an empty dict if `ops_metrics_enabled` was false when creating + the namespace. + + Returns + ------- + Dict[str, int] + Operation name to call count mapping + """ + return self._inner.retrieve_ops_metrics() + + def reset_ops_metrics(self) -> None: + """Reset all operation metrics counters to zero. + + Does nothing if `ops_metrics_enabled` was false when creating the + namespace. + """ + self._inner.reset_ops_metrics() + class RestAdapter: """REST adapter server that creates a namespace backend and exposes it via REST. @@ -669,11 +731,12 @@ class RestAdapter: Parameters ---------- - namespace_impl : str - Namespace implementation type ("dir", "rest", etc.) - namespace_properties : dict, optional - Configuration properties for the backend namespace. + namespace_client_impl : str + Namespace client implementation type ("dir", "rest", etc.) + namespace_client_properties : dict, optional + Configuration properties for the backend namespace client. For DirectoryNamespace ("dir"): + - root (required): Root directory path or URI - manifest_enabled (optional): Enable manifest tracking (default: "true") - dir_listing_enabled (optional): Enable directory listing fallback @@ -701,8 +764,8 @@ class RestAdapter: def __init__( self, - namespace_impl: str, - namespace_properties: Dict[str, str] = None, + namespace_client_impl: str, + namespace_client_properties: Dict[str, str] = None, session=None, host: str = None, port: int = None, @@ -715,14 +778,18 @@ def __init__( ) # Convert to string properties - if namespace_properties is None: - namespace_properties = {} - str_properties = {str(k): str(v) for k, v in namespace_properties.items()} + if namespace_client_properties is None: + namespace_client_properties = {} + str_properties = { + str(k): str(v) for k, v in namespace_client_properties.items() + } # Create the underlying Rust adapter - self._inner = PyRestAdapter(namespace_impl, str_properties, session, host, port) + self._inner = PyRestAdapter( + namespace_client_impl, str_properties, session, host, port + ) self.host = host - self.namespace_impl = namespace_impl + self.namespace_client_impl = namespace_client_impl @property def port(self) -> int: @@ -752,117 +819,3 @@ def __exit__(self, exc_type, exc_value, traceback): def __repr__(self) -> str: return f"RestAdapter(host='{self.host}', port={self.port})" - - -class LanceNamespaceStorageOptionsProvider(StorageOptionsProvider): - """Storage options provider that fetches storage options from a LanceNamespace. - - This provider automatically fetches fresh storage options by calling the - namespace's describe_table() method, which returns both the table location - and time-limited storage options. This is currently only used for refreshing - AWS temporary access credentials. - - This is the recommended approach for LanceDB Cloud and other namespace-based - deployments, as it handles storage options refresh automatically. - - Parameters - ---------- - namespace : LanceNamespace - The namespace instance to fetch storage options from. Use - lance.namespace.connect() to create a namespace instance. - table_id : List[str] - The table identifier (e.g., ["workspace", "table_name"]) - - Example - ------- - This example shows how to use the storage options provider with a namespace. - - .. code-block:: python - - import lance - import lance.namespace - - # Connect to a namespace - namespace = lance.namespace.connect("rest", {"uri": "http://localhost:4099"}) - - # Create storage options provider - provider = lance.LanceNamespaceStorageOptionsProvider( - namespace=namespace, - table_id=["workspace", "table_name"] - ) - - # Use with dataset - storage options auto-refresh! - dataset = lance.dataset( - "s3://bucket/table.lance", - storage_options_provider=provider - ) - """ - - def __init__(self, namespace: LanceNamespace, table_id: List[str]): - """Initialize with namespace and table ID. - - Parameters - ---------- - namespace : LanceNamespace - The namespace instance with a describe_table() method - table_id : List[str] - The table identifier - """ - self._namespace = namespace - self._table_id = table_id - - def fetch_storage_options(self) -> Dict[str, str]: - """Fetch storage options from the namespace. - - This calls namespace.describe_table() to get the latest storage options - and optionally their expiration time. - - Returns - ------- - Dict[str, str] - Flat dictionary of string key-value pairs containing storage options. - May optionally include expires_at_millis. If expires_at_millis is not - provided, credentials are treated as non-expiring and will not be - automatically refreshed. - - Raises - ------ - RuntimeError - If the namespace doesn't return storage options - """ - request = DescribeTableRequest(id=self._table_id, version=None) - response = self._namespace.describe_table(request) - storage_options = response.storage_options - if storage_options is None: - raise RuntimeError( - "Namespace did not return storage_options. " - "Ensure the namespace supports storage options providing." - ) - - # Return the storage_options directly - it's already a flat Map - # Note: expires_at_millis is optional. If not provided, credentials are treated - # as non-expiring and will not be automatically refreshed. - return storage_options - - def provider_id(self) -> str: - """Return a human-readable unique identifier for this provider instance. - - This creates a semantic ID based on the namespace's ID and the table ID, - enabling proper equality comparison and caching. - - Returns - ------- - str - A human-readable unique identifier string combining namespace and table info - """ - # Try to call namespace_id() if available (lance-namespace >= 0.0.20) - if hasattr(self._namespace, "namespace_id"): - namespace_id = self._namespace.namespace_id() - else: - # Fallback for older namespace versions - namespace_id = str(self._namespace) - - return ( - f"LanceNamespaceStorageOptionsProvider {{ " - f"namespace: {namespace_id}, table_id: {self._table_id!r} }}" - ) diff --git a/python/python/lance/tf/data.py b/python/python/lance/tf/data.py index 7b6ff8e51a4..68280f9211f 100644 --- a/python/python/lance/tf/data.py +++ b/python/python/lance/tf/data.py @@ -141,7 +141,7 @@ def from_lance( filter: Optional[str] = None, fragments: Union[Iterable[int], Iterable[LanceFragment], tf.data.Dataset] = None, output_signature: Optional[Dict[str, tf.TypeSpec]] = None, - namespace: Optional["LanceNamespace"] = None, + namespace_client: Optional["LanceNamespace"] = None, table_id: Optional[List[str]] = None, ignore_namespace_table_storage_options: bool = False, ) -> tf.data.Dataset: @@ -151,7 +151,7 @@ def from_lance( ---------- dataset : Union[str, Path, LanceDataset], optional Lance dataset or dataset URI/path. Either ``dataset`` or both - ``namespace`` and ``table_id`` must be provided. + ``namespace_client`` and ``table_id`` must be provided. columns : Optional[List[str]], optional List of columns to include in the output dataset. If not set, all columns will be read. @@ -165,13 +165,15 @@ def from_lance( output_signature : Optional[tf.TypeSpec], optional Override output signature of the returned tensors. If not provided, the output signature is inferred from the projection Schema. - namespace : Optional[LanceNamespace], optional - Namespace to resolve the table location when ``table_id`` is provided. + namespace_client : Optional[LanceNamespace], optional + Namespace client to resolve the table location when ``table_id`` is + provided. table_id : Optional[List[str]], optional - Table identifier used together with ``namespace`` to locate the table. + Table identifier used together with ``namespace_client`` to locate + the table. ignore_namespace_table_storage_options : bool, default False - When using ``namespace``/``table_id``, ignore storage options returned - by the namespace. + When using ``namespace_client``/``table_id``, ignore storage options + returned by the namespace. Examples -------- @@ -212,15 +214,15 @@ def from_lance( """ if isinstance(dataset, LanceDataset): - if namespace is not None or table_id is not None: + if namespace_client is not None or table_id is not None: raise ValueError( - "Cannot specify 'namespace' or 'table_id' when passing " + "Cannot specify 'namespace_client' or 'table_id' when passing " "a LanceDataset instance" ) else: dataset = lance.dataset( dataset, - namespace=namespace, + namespace_client=namespace_client, table_id=table_id, ignore_namespace_table_storage_options=ignore_namespace_table_storage_options, ) diff --git a/python/python/tests/test_namespace_dir.py b/python/python/tests/test_namespace_dir.py index 49523a58e81..0e8e4dc8fb8 100644 --- a/python/python/tests/test_namespace_dir.py +++ b/python/python/tests/test_namespace_dir.py @@ -8,36 +8,53 @@ namespace implementation for organizing Lance tables and nested namespaces. These tests mirror the Rust tests in rust/lance-namespace-impls/src/dir.rs + +Additionally, tests are parameterized to run with both the native DirectoryNamespace +and a CustomNamespace wrapper to verify Python-Rust binding works correctly for +custom namespace implementations. """ import sys import tempfile import uuid from threading import Lock +from typing import Dict, Optional import lance import lance.namespace import pyarrow as pa import pytest +from lance.namespace import LanceNamespace from lance_namespace import ( CreateNamespaceRequest, + CreateNamespaceResponse, CreateTableRequest, + CreateTableResponse, CreateTableVersionRequest, CreateTableVersionResponse, DeclareTableRequest, + DeclareTableResponse, DeregisterTableRequest, + DeregisterTableResponse, DescribeNamespaceRequest, + DescribeNamespaceResponse, DescribeTableRequest, + DescribeTableResponse, DescribeTableVersionRequest, DescribeTableVersionResponse, DropNamespaceRequest, + DropNamespaceResponse, DropTableRequest, + DropTableResponse, ListNamespacesRequest, + ListNamespacesResponse, ListTablesRequest, + ListTablesResponse, ListTableVersionsRequest, ListTableVersionsResponse, NamespaceExistsRequest, RegisterTableRequest, + RegisterTableResponse, TableExistsRequest, connect, ) @@ -49,6 +66,86 @@ ) +class CustomNamespace(LanceNamespace): + """A custom namespace wrapper that delegates to DirectoryNamespace. + + This class verifies that the Python-Rust binding works correctly for + custom namespace implementations that wrap the native DirectoryNamespace. + All methods simply delegate to the underlying DirectoryNamespace instance. + """ + + def __init__(self, inner: lance.namespace.DirectoryNamespace): + self._inner = inner + + def namespace_id(self) -> str: + return f"CustomNamespace[{self._inner.namespace_id()}]" + + def create_namespace( + self, request: CreateNamespaceRequest + ) -> CreateNamespaceResponse: + return self._inner.create_namespace(request) + + def describe_namespace( + self, request: DescribeNamespaceRequest + ) -> DescribeNamespaceResponse: + return self._inner.describe_namespace(request) + + def namespace_exists(self, request: NamespaceExistsRequest) -> None: + return self._inner.namespace_exists(request) + + def drop_namespace(self, request: DropNamespaceRequest) -> DropNamespaceResponse: + return self._inner.drop_namespace(request) + + def list_namespaces(self, request: ListNamespacesRequest) -> ListNamespacesResponse: + return self._inner.list_namespaces(request) + + def create_table( + self, request: CreateTableRequest, data: bytes + ) -> CreateTableResponse: + return self._inner.create_table(request, data) + + def declare_table(self, request: DeclareTableRequest) -> DeclareTableResponse: + return self._inner.declare_table(request) + + def describe_table(self, request: DescribeTableRequest) -> DescribeTableResponse: + return self._inner.describe_table(request) + + def table_exists(self, request: TableExistsRequest) -> None: + return self._inner.table_exists(request) + + def drop_table(self, request: DropTableRequest) -> DropTableResponse: + return self._inner.drop_table(request) + + def list_tables(self, request: ListTablesRequest) -> ListTablesResponse: + return self._inner.list_tables(request) + + def register_table(self, request: RegisterTableRequest) -> RegisterTableResponse: + return self._inner.register_table(request) + + def deregister_table( + self, request: DeregisterTableRequest + ) -> DeregisterTableResponse: + return self._inner.deregister_table(request) + + def list_table_versions( + self, request: ListTableVersionsRequest + ) -> ListTableVersionsResponse: + return self._inner.list_table_versions(request) + + def describe_table_version( + self, request: DescribeTableVersionRequest + ) -> DescribeTableVersionResponse: + return self._inner.describe_table_version(request) + + def create_table_version( + self, request: CreateTableVersionRequest + ) -> CreateTableVersionResponse: + return self._inner.create_table_version(request) + + def retrieve_ops_metrics(self) -> Optional[Dict[str, int]]: + return self._inner.retrieve_ops_metrics() + + def create_test_data(): """Create test PyArrow table data.""" return pa.Table.from_pylist( @@ -70,42 +167,57 @@ def table_to_ipc_bytes(table): return sink.getvalue() -@pytest.fixture -def temp_namespace(): - """Create a temporary DirectoryNamespace for testing.""" +def _wrap_if_custom(ns_client, use_custom: bool): + """Wrap namespace client in CustomNamespace if use_custom is True.""" + if use_custom: + return CustomNamespace(ns_client) + return ns_client + + +@pytest.fixture(params=[False, True], ids=["DirectoryNamespace", "CustomNamespace"]) +def temp_ns_client(request): + """Create a temporary namespace client for testing. + + Parameterized to test both DirectoryNamespace and CustomNamespace wrapper. + """ + use_custom = request.param with tempfile.TemporaryDirectory() as tmpdir: # Use lance.namespace.connect() for consistency # Use high commit_retries for concurrent operation tests - ns = connect( + ns_client = connect( "dir", {"root": f"file://{tmpdir}", "commit_retries": "2147483647"} ) - yield ns + yield _wrap_if_custom(ns_client, use_custom) + +@pytest.fixture(params=[False, True], ids=["DirectoryNamespace", "CustomNamespace"]) +def memory_ns_client(request): + """Create a memory-based namespace client for testing. -@pytest.fixture -def memory_namespace(): - """Create a memory-based DirectoryNamespace for testing.""" + Parameterized to test both DirectoryNamespace and CustomNamespace wrapper. + """ + use_custom = request.param unique_id = uuid.uuid4().hex[:8] # Use lance.namespace.connect() for consistency - ns = connect("dir", {"root": f"memory://test_{unique_id}"}) - yield ns + ns_client = connect("dir", {"root": f"memory://test_{unique_id}"}) + yield _wrap_if_custom(ns_client, use_custom) class TestCreateTable: """Tests for create_table operation - mirrors Rust test_create_table.""" - def test_create_table(self, memory_namespace): + def test_create_table(self, memory_ns_client): """Test creating a table with data.""" # Create parent namespace first create_ns_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) # Create table with data table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) create_req = CreateTableRequest(id=["workspace", "test_table"]) - response = memory_namespace.create_table(create_req, ipc_data) + response = memory_ns_client.create_table(create_req, ipc_data) assert response is not None assert response.location is not None @@ -114,20 +226,20 @@ def test_create_table(self, memory_namespace): assert "test_table" in response.location assert response.version == 1 - def test_create_table_without_data(self, memory_namespace): + def test_create_table_without_data(self, memory_ns_client): """Test creating a table without data should fail.""" # Create parent namespace first create_ns_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) create_req = CreateTableRequest(id=["workspace", "test_table"]) with pytest.raises(InvalidInputError) as exc_info: - memory_namespace.create_table(create_req, b"") + memory_ns_client.create_table(create_req, b"") assert "Arrow IPC" in str(exc_info.value) or "required" in str(exc_info.value) - def test_create_table_with_invalid_id(self, memory_namespace): + def test_create_table_with_invalid_id(self, memory_ns_client): """Test creating a table with invalid ID should fail.""" table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) @@ -135,19 +247,19 @@ def test_create_table_with_invalid_id(self, memory_namespace): # Test with empty ID create_req = CreateTableRequest(id=[]) with pytest.raises(InvalidInputError): - memory_namespace.create_table(create_req, ipc_data) + memory_ns_client.create_table(create_req, ipc_data) - def test_create_table_in_child_namespace(self, memory_namespace): + def test_create_table_in_child_namespace(self, memory_ns_client): """Test creating table in child namespace works with manifest enabled.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["test_namespace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) # Create table in the namespace table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) create_req = CreateTableRequest(id=["test_namespace", "table"]) - response = memory_namespace.create_table(create_req, ipc_data) + response = memory_ns_client.create_table(create_req, ipc_data) # Should succeed with manifest enabled assert response is not None @@ -157,52 +269,52 @@ def test_create_table_in_child_namespace(self, memory_namespace): class TestListTables: """Tests for list_tables operation - mirrors Rust test_list_tables.""" - def test_list_tables_empty(self, memory_namespace): + def test_list_tables_empty(self, memory_ns_client): """Test listing tables in empty namespace.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) # Initially, no tables list_req = ListTablesRequest(id=["workspace"]) - response = memory_namespace.list_tables(list_req) + response = memory_ns_client.list_tables(list_req) assert len(response.tables) == 0 - def test_list_tables_with_tables(self, memory_namespace): + def test_list_tables_with_tables(self, memory_ns_client): """Test listing tables after creating them.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) # Create table1 create_req = CreateTableRequest(id=["workspace", "table1"]) - memory_namespace.create_table(create_req, ipc_data) + memory_ns_client.create_table(create_req, ipc_data) # Create table2 create_req = CreateTableRequest(id=["workspace", "table2"]) - memory_namespace.create_table(create_req, ipc_data) + memory_ns_client.create_table(create_req, ipc_data) # List tables should return both list_req = ListTablesRequest(id=["workspace"]) - response = memory_namespace.list_tables(list_req) + response = memory_ns_client.list_tables(list_req) assert len(response.tables) == 2 # List tables returns table names as strings assert "table1" in response.tables assert "table2" in response.tables - def test_list_tables_with_namespace_id(self, memory_namespace): + def test_list_tables_with_namespace_id(self, memory_ns_client): """Test listing tables in a child namespace.""" # Create child namespace create_ns_req = CreateNamespaceRequest(id=["test_namespace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) # List tables in the child namespace list_req = ListTablesRequest(id=["test_namespace"]) - response = memory_namespace.list_tables(list_req) + response = memory_ns_client.list_tables(list_req) # Should succeed and return empty list (no tables yet) assert len(response.tables) == 0 @@ -211,100 +323,100 @@ def test_list_tables_with_namespace_id(self, memory_namespace): class TestDescribeTable: """Tests for describe_table operation - mirrors Rust test_describe_table.""" - def test_describe_table(self, memory_namespace): + def test_describe_table(self, memory_ns_client): """Test describing a table.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) # Create a table table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) create_req = CreateTableRequest(id=["workspace", "test_table"]) - memory_namespace.create_table(create_req, ipc_data) + memory_ns_client.create_table(create_req, ipc_data) # Describe the table describe_req = DescribeTableRequest(id=["workspace", "test_table"]) - response = memory_namespace.describe_table(describe_req) + response = memory_ns_client.describe_table(describe_req) assert response is not None assert response.location is not None assert "test_table" in response.location - def test_describe_nonexistent_table(self, memory_namespace): + def test_describe_nonexistent_table(self, memory_ns_client): """Test describing a table that doesn't exist.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) describe_req = DescribeTableRequest(id=["workspace", "nonexistent"]) with pytest.raises(TableNotFoundError): - memory_namespace.describe_table(describe_req) + memory_ns_client.describe_table(describe_req) class TestTableOperations: """Tests for various table operations.""" - def test_table_exists(self, memory_namespace): + def test_table_exists(self, memory_ns_client): """Test checking if a table exists.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) # Create a table table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) create_req = CreateTableRequest(id=["workspace", "test_table"]) - memory_namespace.create_table(create_req, ipc_data) + memory_ns_client.create_table(create_req, ipc_data) # Check it exists (should not raise) exists_req = TableExistsRequest(id=["workspace", "test_table"]) - memory_namespace.table_exists(exists_req) + memory_ns_client.table_exists(exists_req) - def test_table_not_exists(self, memory_namespace): + def test_table_not_exists(self, memory_ns_client): """Test checking if a non-existent table exists raises TableNotFoundError.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) exists_req = TableExistsRequest(id=["workspace", "nonexistent"]) with pytest.raises(TableNotFoundError): - memory_namespace.table_exists(exists_req) + memory_ns_client.table_exists(exists_req) - def test_drop_table(self, memory_namespace): + def test_drop_table(self, memory_ns_client): """Test dropping a table.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) # Create table table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) create_req = CreateTableRequest(id=["workspace", "test_table"]) - memory_namespace.create_table(create_req, ipc_data) + memory_ns_client.create_table(create_req, ipc_data) # Drop the table drop_req = DropTableRequest(id=["workspace", "test_table"]) - response = memory_namespace.drop_table(drop_req) + response = memory_ns_client.drop_table(drop_req) assert response is not None # Verify table no longer exists exists_req = TableExistsRequest(id=["workspace", "test_table"]) with pytest.raises(TableNotFoundError): - memory_namespace.table_exists(exists_req) + memory_ns_client.table_exists(exists_req) - def test_deregister_table(self, temp_namespace): + def test_deregister_table(self, temp_ns_client): """Test deregistering a table.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - temp_namespace.create_namespace(create_ns_req) + temp_ns_client.create_namespace(create_ns_req) # Create table using lance directly table_data = create_test_data() # Get root path from namespace - ns_id = temp_namespace.namespace_id() + ns_id = temp_ns_client.namespace_id() import re match = re.search(r'root: "([^"]+)"', ns_id) @@ -319,11 +431,11 @@ def test_deregister_table(self, temp_namespace): register_req = RegisterTableRequest( id=["workspace", "test_table"], location="workspace/physical_table.lance" ) - temp_namespace.register_table(register_req) + temp_ns_client.register_table(register_req) # Deregister it deregister_req = DeregisterTableRequest(id=["workspace", "test_table"]) - response = temp_namespace.deregister_table(deregister_req) + response = temp_ns_client.deregister_table(deregister_req) assert response is not None # Should return full URI to deregistered table # (use endswith to handle path canonicalization) @@ -333,15 +445,15 @@ def test_deregister_table(self, temp_namespace): ) assert response.id == ["workspace", "test_table"] - def test_register_table(self, temp_namespace): + def test_register_table(self, temp_ns_client): """Test registering an existing table.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - temp_namespace.create_namespace(create_ns_req) + temp_ns_client.create_namespace(create_ns_req) # Create physical table using lance table_data = create_test_data() - ns_id = temp_namespace.namespace_id() + ns_id = temp_ns_client.namespace_id() import re match = re.search(r'root: "([^"]+)"', ns_id) @@ -357,17 +469,17 @@ def test_register_table(self, temp_namespace): id=["workspace", "registered_table"], location="workspace/physical_table.lance", ) - response = temp_namespace.register_table(register_req) + response = temp_ns_client.register_table(register_req) assert response is not None assert response.location == "workspace/physical_table.lance" # Verify table exists exists_req = TableExistsRequest(id=["workspace", "registered_table"]) - temp_namespace.table_exists(exists_req) + temp_ns_client.table_exists(exists_req) # Verify we can read from it describe_req = DescribeTableRequest(id=["workspace", "registered_table"]) - desc_response = temp_namespace.describe_table(describe_req) + desc_response = temp_ns_client.describe_table(describe_req) assert desc_response is not None # Should point to the same physical location # (use endswith to handle path canonicalization) @@ -376,57 +488,57 @@ def test_register_table(self, temp_namespace): f"got {desc_response.location}" ) - def test_register_table_rejects_absolute_uri(self, temp_namespace): + def test_register_table_rejects_absolute_uri(self, temp_ns_client): """Test that register_table rejects absolute URIs.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - temp_namespace.create_namespace(create_ns_req) + temp_ns_client.create_namespace(create_ns_req) # Try to register with absolute URI - should fail register_req = RegisterTableRequest( id=["workspace", "test_table"], location="s3://bucket/table.lance" ) with pytest.raises(InvalidInputError) as exc_info: - temp_namespace.register_table(register_req) + temp_ns_client.register_table(register_req) assert "Absolute URIs are not allowed" in str(exc_info.value) - def test_register_table_rejects_absolute_path(self, temp_namespace): + def test_register_table_rejects_absolute_path(self, temp_ns_client): """Test that register_table rejects absolute paths.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - temp_namespace.create_namespace(create_ns_req) + temp_ns_client.create_namespace(create_ns_req) # Try to register with absolute path - should fail register_req = RegisterTableRequest( id=["workspace", "test_table"], location="/tmp/table.lance" ) with pytest.raises(InvalidInputError) as exc_info: - temp_namespace.register_table(register_req) + temp_ns_client.register_table(register_req) assert "Absolute paths are not allowed" in str(exc_info.value) - def test_register_table_rejects_path_traversal(self, temp_namespace): + def test_register_table_rejects_path_traversal(self, temp_ns_client): """Test that register_table rejects path traversal attempts.""" # Create parent namespace create_ns_req = CreateNamespaceRequest(id=["workspace"]) - temp_namespace.create_namespace(create_ns_req) + temp_ns_client.create_namespace(create_ns_req) # Try to register with path traversal - should fail register_req = RegisterTableRequest( id=["workspace", "test_table"], location="../outside/table.lance" ) with pytest.raises(InvalidInputError) as exc_info: - temp_namespace.register_table(register_req) + temp_ns_client.register_table(register_req) assert "Path traversal is not allowed" in str(exc_info.value) class TestChildNamespaceOperations: """Tests for operations in child namespaces - mirrors Rust tests.""" - def test_create_table_in_child_namespace(self, memory_namespace): + def test_create_table_in_child_namespace(self, memory_ns_client): """Test creating multiple tables in a child namespace.""" # Create child namespace create_ns_req = CreateNamespaceRequest(id=["test_ns"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) @@ -434,11 +546,11 @@ def test_create_table_in_child_namespace(self, memory_namespace): # Create three tables for i in range(1, 4): create_req = CreateTableRequest(id=["test_ns", f"table{i}"]) - memory_namespace.create_table(create_req, ipc_data) + memory_ns_client.create_table(create_req, ipc_data) # List tables list_req = ListTablesRequest(id=["test_ns"]) - response = memory_namespace.list_tables(list_req) + response = memory_ns_client.list_tables(list_req) assert len(response.tables) == 3 # List tables returns table names as strings @@ -446,40 +558,40 @@ def test_create_table_in_child_namespace(self, memory_namespace): assert "table2" in response.tables assert "table3" in response.tables - def test_drop_table_in_child_namespace(self, memory_namespace): + def test_drop_table_in_child_namespace(self, memory_ns_client): """Test dropping a table in a child namespace.""" # Create child namespace create_ns_req = CreateNamespaceRequest(id=["test_ns"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) # Create table table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) create_req = CreateTableRequest(id=["test_ns", "table1"]) - memory_namespace.create_table(create_req, ipc_data) + memory_ns_client.create_table(create_req, ipc_data) # Drop table drop_req = DropTableRequest(id=["test_ns", "table1"]) - memory_namespace.drop_table(drop_req) + memory_ns_client.drop_table(drop_req) # Verify table no longer exists exists_req = TableExistsRequest(id=["test_ns", "table1"]) with pytest.raises(TableNotFoundError): - memory_namespace.table_exists(exists_req) + memory_ns_client.table_exists(exists_req) - def test_declared_table_in_child_namespace(self, memory_namespace): + def test_declared_table_in_child_namespace(self, memory_ns_client): """Test declaring a table in a child namespace.""" # Create child namespace create_ns_req = CreateNamespaceRequest(id=["test_ns"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) # Declare table declare_req = DeclareTableRequest(id=["test_ns", "declared_table"]) - memory_namespace.declare_table(declare_req) + memory_ns_client.declare_table(declare_req) # Verify table exists exists_req = TableExistsRequest(id=["test_ns", "declared_table"]) - memory_namespace.table_exists(exists_req) + memory_ns_client.table_exists(exists_req) class TestDeeplyNestedNamespaces: @@ -488,14 +600,14 @@ class TestDeeplyNestedNamespaces: Mirrors Rust test_deeply_nested_namespace. """ - def test_deeply_nested_namespace(self, memory_namespace): + def test_deeply_nested_namespace(self, memory_ns_client): """Test creating deeply nested namespace hierarchy.""" # Create deeply nested namespace hierarchy - memory_namespace.create_namespace(CreateNamespaceRequest(id=["level1"])) - memory_namespace.create_namespace( + memory_ns_client.create_namespace(CreateNamespaceRequest(id=["level1"])) + memory_ns_client.create_namespace( CreateNamespaceRequest(id=["level1", "level2"]) ) - memory_namespace.create_namespace( + memory_ns_client.create_namespace( CreateNamespaceRequest(id=["level1", "level2", "level3"]) ) @@ -503,17 +615,17 @@ def test_deeply_nested_namespace(self, memory_namespace): table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) create_req = CreateTableRequest(id=["level1", "level2", "level3", "table1"]) - memory_namespace.create_table(create_req, ipc_data) + memory_ns_client.create_table(create_req, ipc_data) # Verify table exists exists_req = TableExistsRequest(id=["level1", "level2", "level3", "table1"]) - memory_namespace.table_exists(exists_req) + memory_ns_client.table_exists(exists_req) class TestNamespaceProperties: """Tests for namespace properties - mirrors Rust test_namespace_with_properties.""" - def test_namespace_with_properties(self, memory_namespace): + def test_namespace_with_properties(self, memory_ns_client): """Test creating a namespace with properties.""" # Create namespace with properties properties = { @@ -522,11 +634,11 @@ def test_namespace_with_properties(self, memory_namespace): } create_req = CreateNamespaceRequest(id=["test_ns"], properties=properties) - memory_namespace.create_namespace(create_req) + memory_ns_client.create_namespace(create_req) # Describe namespace and verify properties describe_req = DescribeNamespaceRequest(id=["test_ns"]) - response = memory_namespace.describe_namespace(describe_req) + response = memory_ns_client.describe_namespace(describe_req) assert response.properties is not None assert response.properties.get("owner") == "test_user" @@ -536,121 +648,121 @@ def test_namespace_with_properties(self, memory_namespace): class TestNamespaceConstraints: """Tests for namespace constraints and isolation.""" - def test_cannot_drop_namespace_with_tables(self, memory_namespace): + def test_cannot_drop_namespace_with_tables(self, memory_ns_client): """Test that dropping a namespace with tables should fail.""" # Create namespace create_ns_req = CreateNamespaceRequest(id=["test_ns"]) - memory_namespace.create_namespace(create_ns_req) + memory_ns_client.create_namespace(create_ns_req) # Create table in namespace table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) create_req = CreateTableRequest(id=["test_ns", "table1"]) - memory_namespace.create_table(create_req, ipc_data) + memory_ns_client.create_table(create_req, ipc_data) # Try to drop namespace - should fail drop_req = DropNamespaceRequest(id=["test_ns"]) with pytest.raises(NamespaceNotEmptyError): - memory_namespace.drop_namespace(drop_req) + memory_ns_client.drop_namespace(drop_req) - def test_isolation_between_namespaces(self, memory_namespace): + def test_isolation_between_namespaces(self, memory_ns_client): """Test that namespaces are isolated from each other.""" # Create two namespaces - memory_namespace.create_namespace(CreateNamespaceRequest(id=["ns1"])) - memory_namespace.create_namespace(CreateNamespaceRequest(id=["ns2"])) + memory_ns_client.create_namespace(CreateNamespaceRequest(id=["ns1"])) + memory_ns_client.create_namespace(CreateNamespaceRequest(id=["ns2"])) # Create table with same name in both namespaces table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) create_req1 = CreateTableRequest(id=["ns1", "table1"]) - memory_namespace.create_table(create_req1, ipc_data) + memory_ns_client.create_table(create_req1, ipc_data) create_req2 = CreateTableRequest(id=["ns2", "table1"]) - memory_namespace.create_table(create_req2, ipc_data) + memory_ns_client.create_table(create_req2, ipc_data) # List tables in each namespace list_req = ListTablesRequest(id=["ns1"]) - response = memory_namespace.list_tables(list_req) + response = memory_ns_client.list_tables(list_req) assert len(response.tables) == 1 assert "table1" in response.tables list_req = ListTablesRequest(id=["ns2"]) - response = memory_namespace.list_tables(list_req) + response = memory_ns_client.list_tables(list_req) assert len(response.tables) == 1 assert "table1" in response.tables # Drop table in ns1 shouldn't affect ns2 drop_req = DropTableRequest(id=["ns1", "table1"]) - memory_namespace.drop_table(drop_req) + memory_ns_client.drop_table(drop_req) # ns1 should have no tables list_req = ListTablesRequest(id=["ns1"]) - response = memory_namespace.list_tables(list_req) + response = memory_ns_client.list_tables(list_req) assert len(response.tables) == 0 # ns2 should still have the table list_req = ListTablesRequest(id=["ns2"]) - response = memory_namespace.list_tables(list_req) + response = memory_ns_client.list_tables(list_req) assert len(response.tables) == 1 class TestBasicNamespaceOperations: """Tests for basic namespace CRUD operations.""" - def test_create_and_describe_namespace(self, memory_namespace): + def test_create_and_describe_namespace(self, memory_ns_client): """Test creating and describing a namespace.""" # Create namespace create_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_req) + memory_ns_client.create_namespace(create_req) # Describe it describe_req = DescribeNamespaceRequest(id=["workspace"]) - response = memory_namespace.describe_namespace(describe_req) + response = memory_ns_client.describe_namespace(describe_req) assert response is not None - def test_namespace_exists(self, memory_namespace): + def test_namespace_exists(self, memory_ns_client): """Test checking if a namespace exists.""" # Create namespace create_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_req) + memory_ns_client.create_namespace(create_req) # Check it exists (should not raise) exists_req = NamespaceExistsRequest(id=["workspace"]) - memory_namespace.namespace_exists(exists_req) + memory_ns_client.namespace_exists(exists_req) - def test_namespace_not_exists(self, memory_namespace): + def test_namespace_not_exists(self, memory_ns_client): """Test that a non-existent namespace raises NamespaceNotFoundError.""" exists_req = NamespaceExistsRequest(id=["nonexistent"]) with pytest.raises(NamespaceNotFoundError): - memory_namespace.namespace_exists(exists_req) + memory_ns_client.namespace_exists(exists_req) - def test_drop_empty_namespace(self, memory_namespace): + def test_drop_empty_namespace(self, memory_ns_client): """Test dropping an empty namespace.""" # Create namespace create_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_req) + memory_ns_client.create_namespace(create_req) # Drop it drop_req = DropNamespaceRequest(id=["workspace"]) - response = memory_namespace.drop_namespace(drop_req) + response = memory_ns_client.drop_namespace(drop_req) assert response is not None - def test_list_namespaces(self, memory_namespace): + def test_list_namespaces(self, memory_ns_client): """Test listing namespaces.""" # Create some child namespaces under a parent - memory_namespace.create_namespace(CreateNamespaceRequest(id=["parent"])) - memory_namespace.create_namespace( + memory_ns_client.create_namespace(CreateNamespaceRequest(id=["parent"])) + memory_ns_client.create_namespace( CreateNamespaceRequest(id=["parent", "child1"]) ) - memory_namespace.create_namespace( + memory_ns_client.create_namespace( CreateNamespaceRequest(id=["parent", "child2"]) ) # List namespaces under parent list_req = ListNamespacesRequest(id=["parent"]) - response = memory_namespace.list_namespaces(list_req) + response = memory_ns_client.list_namespaces(list_req) assert response is not None # Should find the child namespaces @@ -660,10 +772,11 @@ def test_list_namespaces(self, memory_namespace): class TestLanceNamespaceConnect: """Tests for lance.namespace.connect integration.""" - def test_connect_with_properties(self): + @pytest.mark.parametrize( + "use_custom", [False, True], ids=["DirectoryNS", "CustomNS"] + ) + def test_connect_with_properties(self, use_custom): """Test creating DirectoryNamespace via lance.namespace.connect().""" - import uuid - unique_id = uuid.uuid4().hex[:8] properties = { "root": f"memory://test_connect_{unique_id}", @@ -673,29 +786,33 @@ def test_connect_with_properties(self): # Connect via lance.namespace.connect # should use lance.namespace.DirectoryNamespace - ns = connect("dir", properties) + inner_ns_client = connect("dir", properties) # Verify it's a DirectoryNamespace instance - assert isinstance(ns, lance.namespace.DirectoryNamespace) + assert isinstance(inner_ns_client, lance.namespace.DirectoryNamespace) + + # Wrap if testing CustomNamespace + ns_client = _wrap_if_custom(inner_ns_client, use_custom) # Verify it works create_req = CreateTableRequest(id=["test_table"]) table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) - response = ns.create_table(create_req, ipc_data) + response = ns_client.create_table(create_req, ipc_data) assert response is not None # Verify we can list the table list_req = ListTablesRequest(id=[]) - list_response = ns.list_tables(list_req) + list_response = ns_client.list_tables(list_req) assert len(list_response.tables) == 1 # tables is a list of strings assert list_response.tables[0] == "test_table" - def test_connect_with_storage_options(self): + @pytest.mark.parametrize( + "use_custom", [False, True], ids=["DirectoryNS", "CustomNS"] + ) + def test_connect_with_storage_options(self, use_custom): """Test creating DirectoryNamespace with storage options via connect().""" - import uuid - unique_id = uuid.uuid4().hex[:8] properties = { "root": f"memory://test_storage_{unique_id}", @@ -703,98 +820,29 @@ def test_connect_with_storage_options(self): } # This should work without errors - ns = connect("dir", properties) - assert isinstance(ns, lance.namespace.DirectoryNamespace) - - -class TableVersionTrackingNamespace(lance.namespace.DirectoryNamespace): - """Namespace wrapper that tracks table version API calls. + inner_ns_client = connect("dir", properties) + assert isinstance(inner_ns_client, lance.namespace.DirectoryNamespace) - Similar to the Rust TrackingNamespace and Java TableVersionTrackingNamespace, - this extends DirectoryNamespace with table_version_tracking_enabled=true and - counts create_table_version and describe_table_version calls. + # Wrap if testing CustomNamespace + ns_client = _wrap_if_custom(inner_ns_client, use_custom) - This class implements the JSON bridge methods that PyLanceNamespace calls, - allowing API call tracking to work even when the calls go through Rust. - - Unlike a wrapper approach, this extends DirectoryNamespace directly so that - Rust can detect it as a DirectoryNamespace subclass and use the native handle. - """ - - def __init__(self, root: str): - dir_props = { - "root": root, - "table_version_tracking_enabled": "true", - "manifest_enabled": "true", - } - super().__init__(**dir_props) - self.create_table_version_count = 0 - self.describe_table_version_count = 0 - self.list_table_versions_count = 0 - self._lock = Lock() - - def namespace_id(self) -> str: - return f"TableVersionTrackingNamespace {{ inner: {super().namespace_id()} }}" - - def create_table_version( - self, request: CreateTableVersionRequest - ) -> CreateTableVersionResponse: - with self._lock: - self.create_table_version_count += 1 - return super().create_table_version(request) + # Verify it can be used for basic operations + create_ns_req = CreateNamespaceRequest(id=["test_ns"]) + ns_client.create_namespace(create_ns_req) - def describe_table_version( - self, request: DescribeTableVersionRequest - ) -> DescribeTableVersionResponse: - with self._lock: - self.describe_table_version_count += 1 - return super().describe_table_version(request) - def list_table_versions( - self, request: ListTableVersionsRequest - ) -> ListTableVersionsResponse: - with self._lock: - self.list_table_versions_count += 1 - return super().list_table_versions(request) - - # JSON bridge methods for Rust PyLanceNamespace callbacks - # These call the parent's _inner (PyDirectoryNamespace) directly with dict API - def describe_table_version_json(self, request_json: str) -> str: - """JSON bridge that increments counter before delegating.""" - import json - - with self._lock: - self.describe_table_version_count += 1 - request_dict = json.loads(request_json) - response_dict = self._inner.describe_table_version(request_dict) - return json.dumps(response_dict) - - def create_table_version_json(self, request_json: str) -> str: - """JSON bridge that increments counter before delegating.""" - import json - - with self._lock: - self.create_table_version_count += 1 - request_dict = json.loads(request_json) - response_dict = self._inner.create_table_version(request_dict) - return json.dumps(response_dict) - - def list_table_versions_json(self, request_json: str) -> str: - """JSON bridge that increments counter before delegating.""" - import json - - with self._lock: - self.list_table_versions_count += 1 - request_dict = json.loads(request_json) - response_dict = self._inner.list_table_versions(request_dict) - return json.dumps(response_dict) +def _get_ops_metric(ns_client, metric_name: str) -> int: + """Helper to get a specific metric count from namespace client ops metrics.""" + metrics = ns_client.retrieve_ops_metrics() + return metrics.get(metric_name, 0) @pytest.mark.skipif( sys.platform == "win32", reason="External manifest store has known issues on Windows", ) -def test_external_manifest_store_invokes_namespace_apis(): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_external_manifest_store_invokes_namespace_apis(use_custom): """Test that namespace APIs are invoked correctly for managed versioning. This test mirrors: @@ -805,72 +853,90 @@ def test_external_manifest_store_invokes_namespace_apis(): 1. list_table_versions is called when opening dataset (latest version) 2. create_table_version is called exactly once during append 3. describe_table_version is called when opening specific version + + Uses native ops_metrics_enabled to track API calls instead of custom wrapper. + The test is parameterized to verify both DirectoryNamespace and CustomNamespace. """ with tempfile.TemporaryDirectory() as tmpdir: - namespace = TableVersionTrackingNamespace(root=tmpdir) + # Use native namespace with ops metrics enabled + inner_ns_client = lance.namespace.DirectoryNamespace( + root=tmpdir, + table_version_tracking_enabled="true", + manifest_enabled="true", + ops_metrics_enabled="true", + ) + ns_client = _wrap_if_custom(inner_ns_client, use_custom) # Create parent namespace first (like Rust/Java tests) - namespace.create_namespace(CreateNamespaceRequest(id=["workspace"])) + ns_client.create_namespace(CreateNamespaceRequest(id=["workspace"])) table_id = ["workspace", "test_table"] # Create initial table table1 = pa.Table.from_pylist([{"a": 1, "b": 2}, {"a": 10, "b": 20}]) ds = lance.write_dataset( - table1, namespace=namespace, table_id=table_id, mode="create" + table1, namespace_client=ns_client, table_id=table_id, mode="create" ) assert ds.count_rows() == 2 assert len(ds.versions()) == 1 # Verify describe_table returns managed_versioning=True - describe_resp = namespace.describe_table(DescribeTableRequest(id=table_id)) + describe_resp = ns_client.describe_table(DescribeTableRequest(id=table_id)) assert describe_resp.managed_versioning is True, ( f"Expected managed_versioning=True, got {describe_resp.managed_versioning}" ) # Open dataset through namespace - should call list_table_versions for latest - initial_list_count = namespace.list_table_versions_count - ds_from_namespace = lance.dataset(namespace=namespace, table_id=table_id) - assert ds_from_namespace.count_rows() == 2 - assert ds_from_namespace.version == 1 - assert namespace.list_table_versions_count == initial_list_count + 1, ( - "list_table_versions should be called once when opening latest version" - ) + # Use inner_ns_client for metrics since CustomNamespace delegates to it + initial_list_count = _get_ops_metric(inner_ns_client, "list_table_versions") + ds_from_ns_client = lance.dataset(namespace_client=ns_client, table_id=table_id) + assert ds_from_ns_client.count_rows() == 2 + assert ds_from_ns_client.version == 1 + assert ( + _get_ops_metric(inner_ns_client, "list_table_versions") + == initial_list_count + 1 + ), "list_table_versions should be called once when opening latest version" # Verify create_table_version was called once during CREATE - assert namespace.create_table_version_count == 1, ( + assert _get_ops_metric(inner_ns_client, "create_table_version") == 1, ( "create_table_version should have been called once during CREATE" ) # Append data - should call create_table_version again table2 = pa.Table.from_pylist([{"a": 100, "b": 200}, {"a": 1000, "b": 2000}]) ds = lance.write_dataset( - table2, namespace=namespace, table_id=table_id, mode="append" + table2, namespace_client=ns_client, table_id=table_id, mode="append" ) assert ds.count_rows() == 4 assert len(ds.versions()) == 2 - assert namespace.create_table_version_count == 2, ( + assert _get_ops_metric(inner_ns_client, "create_table_version") == 2, ( "create_table_version should be called twice (CREATE + APPEND)" ) # Open latest version - should call list_table_versions - list_count_before_latest = namespace.list_table_versions_count - latest_ds = lance.dataset(namespace=namespace, table_id=table_id) + list_count_before_latest = _get_ops_metric( + inner_ns_client, "list_table_versions" + ) + latest_ds = lance.dataset(namespace_client=ns_client, table_id=table_id) assert latest_ds.count_rows() == 4 assert latest_ds.version == 2 - assert namespace.list_table_versions_count == list_count_before_latest + 1, ( - "list_table_versions should be called once when opening latest version" - ) + assert ( + _get_ops_metric(inner_ns_client, "list_table_versions") + == list_count_before_latest + 1 + ), "list_table_versions should be called once when opening latest version" # Open specific version (v1) - should call describe_table_version - describe_count_before_v1 = namespace.describe_table_version_count - v1_ds = lance.dataset(namespace=namespace, table_id=table_id, version=1) + describe_count_before_v1 = _get_ops_metric( + inner_ns_client, "describe_table_version" + ) + v1_ds = lance.dataset(namespace_client=ns_client, table_id=table_id, version=1) assert v1_ds.count_rows() == 2 assert v1_ds.version == 1 - assert namespace.describe_table_version_count == describe_count_before_v1 + 1, ( - "describe_table_version should be called once when opening version 1" - ) + assert ( + _get_ops_metric(inner_ns_client, "describe_table_version") + == describe_count_before_v1 + 1 + ), "describe_table_version should be called once when opening version 1" @pytest.mark.skipif( @@ -882,16 +948,18 @@ class TestConcurrentOperations: These tests mirror the Rust and Java concurrent tests to ensure the DirectoryNamespace handles concurrent create/drop operations correctly. + Tests are parameterized via temp_ns_client fixture to test both + DirectoryNamespace and CustomNamespace. """ - def test_concurrent_create_and_drop_single_instance(self, temp_namespace): + def test_concurrent_create_and_drop_single_instance(self, temp_ns_client): """Test concurrent create/drop with single namespace instance.""" import concurrent.futures # Initialize namespace first - create parent namespace to ensure __manifest # table is created before concurrent operations create_ns_req = CreateNamespaceRequest(id=["test_ns"]) - temp_namespace.create_namespace(create_ns_req) + temp_ns_client.create_namespace(create_ns_req) num_tables = 10 success_count = 0 @@ -908,11 +976,11 @@ def create_and_drop_table(table_index): # Create table create_req = CreateTableRequest(id=table_id) - temp_namespace.create_table(create_req, ipc_data) + temp_ns_client.create_table(create_req, ipc_data) # Drop table drop_req = DropTableRequest(id=table_id) - temp_namespace.drop_table(drop_req) + temp_ns_client.drop_table(drop_req) with lock: success_count += 1 @@ -934,22 +1002,26 @@ def create_and_drop_table(table_index): # Verify all tables are dropped list_req = ListTablesRequest(id=["test_ns"]) - response = temp_namespace.list_tables(list_req) + response = temp_ns_client.list_tables(list_req) assert len(response.tables) == 0, "All tables should be dropped" - def test_concurrent_create_and_drop_multiple_instances(self): + @pytest.mark.parametrize( + "use_custom", [False, True], ids=["DirectoryNS", "CustomNS"] + ) + def test_concurrent_create_and_drop_multiple_instances(self, use_custom): """Test concurrent create/drop with multiple namespace instances.""" import concurrent.futures with tempfile.TemporaryDirectory() as tmpdir: # Initialize namespace first with a single instance to ensure __manifest # table is created and parent namespace exists before concurrent operations - init_ns = connect( + init_ns_client = connect( "dir", {"root": f"file://{tmpdir}", "commit_retries": "2147483647"}, ) + init_ns_client = _wrap_if_custom(init_ns_client, use_custom) create_ns_req = CreateNamespaceRequest(id=["test_ns"]) - init_ns.create_namespace(create_ns_req) + init_ns_client.create_namespace(create_ns_req) num_tables = 10 success_count = 0 @@ -959,12 +1031,13 @@ def test_concurrent_create_and_drop_multiple_instances(self): def create_and_drop_table(table_index): nonlocal success_count, fail_count try: - # Each thread creates its own namespace instance + # Each thread creates its own namespace client instance # Use high commit_retries to handle version collisions - ns = connect( + local_ns_client = connect( "dir", {"root": f"file://{tmpdir}", "commit_retries": "2147483647"}, ) + local_ns_client = _wrap_if_custom(local_ns_client, use_custom) table_name = f"multi_ns_table_{table_index}" table_id = ["test_ns", table_name] @@ -973,11 +1046,11 @@ def create_and_drop_table(table_index): # Create table create_req = CreateTableRequest(id=table_id) - ns.create_table(create_req, ipc_data) + local_ns_client.create_table(create_req, ipc_data) # Drop table drop_req = DropTableRequest(id=table_id) - ns.drop_table(drop_req) + local_ns_client.drop_table(drop_req) with lock: success_count += 1 @@ -999,27 +1072,32 @@ def create_and_drop_table(table_index): ) assert fail_count == 0, f"Expected 0 failures, got {fail_count}" - # Verify with a fresh namespace instance - verify_ns = connect( + # Verify with a fresh namespace client instance + verify_ns_client = connect( "dir", {"root": f"file://{tmpdir}", "commit_retries": "2147483647"} ) + verify_ns_client = _wrap_if_custom(verify_ns_client, use_custom) list_req = ListTablesRequest(id=["test_ns"]) - response = verify_ns.list_tables(list_req) + response = verify_ns_client.list_tables(list_req) assert len(response.tables) == 0, "All tables should be dropped" - def test_concurrent_create_then_drop_from_different_instance(self): + @pytest.mark.parametrize( + "use_custom", [False, True], ids=["DirectoryNS", "CustomNS"] + ) + def test_concurrent_create_then_drop_from_different_instance(self, use_custom): """Test creating from one set of instances, dropping from different ones.""" import concurrent.futures with tempfile.TemporaryDirectory() as tmpdir: # Initialize namespace first with a single instance to ensure __manifest # table is created and parent namespace exists before concurrent operations - init_ns = connect( + init_ns_client = connect( "dir", {"root": f"file://{tmpdir}", "commit_retries": "2147483647"}, ) + init_ns_client = _wrap_if_custom(init_ns_client, use_custom) create_ns_req = CreateNamespaceRequest(id=["test_ns"]) - init_ns.create_namespace(create_ns_req) + init_ns_client.create_namespace(create_ns_req) num_tables = 10 @@ -1032,10 +1110,11 @@ def create_table(table_index): nonlocal create_success_count, create_fail_count try: # Use high commit_retries to handle version collisions - ns = connect( + local_ns_client = connect( "dir", {"root": f"file://{tmpdir}", "commit_retries": "2147483647"}, ) + local_ns_client = _wrap_if_custom(local_ns_client, use_custom) table_name = f"cross_instance_table_{table_index}" table_id = ["test_ns", table_name] @@ -1043,7 +1122,7 @@ def create_table(table_index): ipc_data = table_to_ipc_bytes(table_data) create_req = CreateTableRequest(id=table_id) - ns.create_table(create_req, ipc_data) + local_ns_client.create_table(create_req, ipc_data) with create_lock: create_success_count += 1 @@ -1071,16 +1150,17 @@ def drop_table(table_index): nonlocal drop_success_count, drop_fail_count try: # Use high commit_retries to handle version collisions - ns = connect( + local_ns_client = connect( "dir", {"root": f"file://{tmpdir}", "commit_retries": "2147483647"}, ) + local_ns_client = _wrap_if_custom(local_ns_client, use_custom) table_name = f"cross_instance_table_{table_index}" table_id = ["test_ns", table_name] drop_req = DropTableRequest(id=table_id) - ns.drop_table(drop_req) + local_ns_client.drop_table(drop_req) with drop_lock: drop_success_count += 1 @@ -1101,9 +1181,10 @@ def drop_table(table_index): assert drop_fail_count == 0, f"No drops should fail, got {drop_fail_count}" # Verify all tables are dropped - verify_ns = connect( + verify_ns_client = connect( "dir", {"root": f"file://{tmpdir}", "commit_retries": "2147483647"} ) + verify_ns_client = _wrap_if_custom(verify_ns_client, use_custom) list_req = ListTablesRequest(id=["test_ns"]) - response = verify_ns.list_tables(list_req) + response = verify_ns_client.list_tables(list_req) assert len(response.tables) == 0, "All tables should be dropped" diff --git a/python/python/tests/test_namespace_integration.py b/python/python/tests/test_namespace_integration.py index 9cb87011e9c..4605b755816 100644 --- a/python/python/tests/test_namespace_integration.py +++ b/python/python/tests/test_namespace_integration.py @@ -4,8 +4,11 @@ """ Integration tests for Lance Namespace with S3 and credential refresh. -This test simulates a namespace server that returns incrementing credentials -and verifies that the credential refresh mechanism works correctly. +This test uses DirectoryNamespace with native ops_metrics and vend_input_storage_options +features to track API calls and test credential refresh mechanisms. + +Tests are parameterized to run with both DirectoryNamespace and a CustomNamespace +wrapper to verify Python-Rust binding works correctly for custom implementations. See DEVELOPMENT.md under heading "Integration Tests" for more information. """ @@ -14,18 +17,135 @@ import time import uuid from threading import Lock -from typing import Dict +from typing import Dict, Optional import lance import pyarrow as pa import pytest from lance.namespace import ( DeclareTableRequest, - DeclareTableResponse, DescribeTableRequest, - DescribeTableResponse, + DirectoryNamespace, LanceNamespace, ) +from lance_namespace import ( + CreateNamespaceRequest, + CreateNamespaceResponse, + CreateTableRequest, + CreateTableResponse, + CreateTableVersionRequest, + CreateTableVersionResponse, + DeclareTableResponse, + DeregisterTableRequest, + DeregisterTableResponse, + DescribeNamespaceRequest, + DescribeNamespaceResponse, + DescribeTableResponse, + DescribeTableVersionRequest, + DescribeTableVersionResponse, + DropNamespaceRequest, + DropNamespaceResponse, + DropTableRequest, + DropTableResponse, + ListNamespacesRequest, + ListNamespacesResponse, + ListTablesRequest, + ListTablesResponse, + ListTableVersionsRequest, + ListTableVersionsResponse, + NamespaceExistsRequest, + RegisterTableRequest, + RegisterTableResponse, + TableExistsRequest, +) + + +class CustomNamespace(LanceNamespace): + """A custom namespace wrapper that delegates to DirectoryNamespace. + + This class verifies that the Python-Rust binding works correctly for + custom namespace implementations that wrap the native DirectoryNamespace. + All methods simply delegate to the underlying DirectoryNamespace instance. + """ + + def __init__(self, inner: DirectoryNamespace): + self._inner = inner + + def namespace_id(self) -> str: + return f"CustomNamespace[{self._inner.namespace_id()}]" + + def create_namespace( + self, request: CreateNamespaceRequest + ) -> CreateNamespaceResponse: + return self._inner.create_namespace(request) + + def describe_namespace( + self, request: DescribeNamespaceRequest + ) -> DescribeNamespaceResponse: + return self._inner.describe_namespace(request) + + def namespace_exists(self, request: NamespaceExistsRequest) -> None: + return self._inner.namespace_exists(request) + + def drop_namespace(self, request: DropNamespaceRequest) -> DropNamespaceResponse: + return self._inner.drop_namespace(request) + + def list_namespaces(self, request: ListNamespacesRequest) -> ListNamespacesResponse: + return self._inner.list_namespaces(request) + + def create_table( + self, request: CreateTableRequest, data: bytes + ) -> CreateTableResponse: + return self._inner.create_table(request, data) + + def declare_table(self, request: DeclareTableRequest) -> DeclareTableResponse: + return self._inner.declare_table(request) + + def describe_table(self, request: DescribeTableRequest) -> DescribeTableResponse: + return self._inner.describe_table(request) + + def table_exists(self, request: TableExistsRequest) -> None: + return self._inner.table_exists(request) + + def drop_table(self, request: DropTableRequest) -> DropTableResponse: + return self._inner.drop_table(request) + + def list_tables(self, request: ListTablesRequest) -> ListTablesResponse: + return self._inner.list_tables(request) + + def register_table(self, request: RegisterTableRequest) -> RegisterTableResponse: + return self._inner.register_table(request) + + def deregister_table( + self, request: DeregisterTableRequest + ) -> DeregisterTableResponse: + return self._inner.deregister_table(request) + + def list_table_versions( + self, request: ListTableVersionsRequest + ) -> ListTableVersionsResponse: + return self._inner.list_table_versions(request) + + def describe_table_version( + self, request: DescribeTableVersionRequest + ) -> DescribeTableVersionResponse: + return self._inner.describe_table_version(request) + + def create_table_version( + self, request: CreateTableVersionRequest + ) -> CreateTableVersionResponse: + return self._inner.create_table_version(request) + + def retrieve_ops_metrics(self) -> Optional[Dict[str, int]]: + return self._inner.retrieve_ops_metrics() + + +def _wrap_if_custom(ns_client: DirectoryNamespace, use_custom: bool): + """Wrap namespace client in CustomNamespace if use_custom is True.""" + if use_custom: + return CustomNamespace(ns_client) + return ns_client + # These are all keys that are accepted by storage_options CONFIG = { @@ -74,263 +194,256 @@ def delete_bucket(s3, bucket_name): pass -class TrackingNamespace(LanceNamespace): - """Mock namespace that wraps DirectoryNamespace and tracks API calls.""" - - def __init__( - self, - bucket_name: str, - storage_options: Dict[str, str], - credential_expires_in_seconds: int = 60, - ): - from lance.namespace import DirectoryNamespace - - self.bucket_name = bucket_name - self.base_storage_options = storage_options - self.credential_expires_in_seconds = credential_expires_in_seconds - self.describe_call_count = 0 - self.create_call_count = 0 - self.lock = Lock() - - # Create underlying DirectoryNamespace with storage options - dir_props = {f"storage.{k}": v for k, v in storage_options.items()} - - if bucket_name.startswith("/") or bucket_name.startswith("file://"): - dir_props["root"] = f"{bucket_name}/namespace_root" - else: - dir_props["root"] = f"s3://{bucket_name}/namespace_root" - - self.inner = DirectoryNamespace(**dir_props) - - def get_describe_call_count(self) -> int: - with self.lock: - return self.describe_call_count +def create_tracking_namespace( + bucket_name: str, + storage_options: dict, + credential_expires_in_seconds: int = 60, + use_custom: bool = False, +): + """Create a DirectoryNamespace with ops metrics and credential vending enabled. + + Uses native DirectoryNamespace features: + - ops_metrics_enabled=true: Tracks API call counts via retrieve_ops_metrics() + - vend_input_storage_options=true: Returns input storage options in responses + - vend_input_storage_options_refresh_interval_millis: Adds expires_at_millis + + Args: + bucket_name: S3 bucket name or local path + storage_options: Storage options to pass through (credentials, endpoint, etc.) + credential_expires_in_seconds: Interval in seconds for credential expiration + use_custom: If True, wrap in CustomNamespace for testing custom implementations + + Returns: + Tuple of (namespace_client, inner_namespace_client) where inner is always + the DirectoryNamespace (used for metrics retrieval) + """ + # Add refresh_offset_millis to storage options so that credentials are not + # considered expired immediately. Set to 1 second (1000ms) so that refresh + # checks work correctly with short-lived credentials in tests. + storage_options_with_refresh = dict(storage_options) + storage_options_with_refresh["refresh_offset_millis"] = "1000" - def get_create_call_count(self) -> int: - with self.lock: - return self.create_call_count + dir_props = {f"storage.{k}": v for k, v in storage_options_with_refresh.items()} - def namespace_id(self) -> str: - return f"TrackingNamespace {{ inner: {self.inner.namespace_id()} }}" - - def _vend_storage_options(self, count: int) -> Dict[str, str]: - """Simulate a credential vendor returning only vended credentials. - - Returns only credential keys with expiration metadata, not connection - config. Clients are expected to provide their own connection config - (endpoint, region, allow_http) via storage_options. - """ - return { - "aws_access_key_id": f"AKID_{count}", - "aws_secret_access_key": f"SECRET_{count}", - "aws_session_token": f"TOKEN_{count}", - "expires_at_millis": str( - int((time.time() + self.credential_expires_in_seconds) * 1000) - ), - # Set refresh offset to 1 second (1000ms) for short-lived credential tests - "refresh_offset_millis": "1000", - } + if bucket_name.startswith("/") or bucket_name.startswith("file://"): + dir_props["root"] = f"{bucket_name}/namespace_root" + else: + dir_props["root"] = f"s3://{bucket_name}/namespace_root" - def declare_table(self, request: DeclareTableRequest) -> DeclareTableResponse: - with self.lock: - self.create_call_count += 1 - count = self.create_call_count + # Enable ops metrics tracking + dir_props["ops_metrics_enabled"] = "true" + # Enable storage options vending + dir_props["vend_input_storage_options"] = "true" + # Set refresh interval in milliseconds + dir_props["vend_input_storage_options_refresh_interval_millis"] = str( + credential_expires_in_seconds * 1000 + ) - response = self.inner.declare_table(request) - response.storage_options = self._vend_storage_options(count) + inner_ns_client = DirectoryNamespace(**dir_props) + ns_client = _wrap_if_custom(inner_ns_client, use_custom) + return ns_client, inner_ns_client - return response - def describe_table(self, request: DescribeTableRequest) -> DescribeTableResponse: - with self.lock: - self.describe_call_count += 1 - count = self.describe_call_count +def get_describe_call_count(namespace_client) -> int: + """Get the number of describe_table calls made to the namespace client.""" + return namespace_client.retrieve_ops_metrics().get("describe_table", 0) - response = self.inner.describe_table(request) - response.storage_options = self._vend_storage_options(count) - return response +def get_declare_call_count(namespace_client) -> int: + """Get the number of declare_table calls made to the namespace client.""" + return namespace_client.retrieve_ops_metrics().get("declare_table", 0) @pytest.mark.integration -def test_namespace_open_dataset(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_namespace_open_dataset(s3_bucket: str, use_custom: bool): """Test creating and opening datasets through namespace with credential tracking.""" storage_options = copy.deepcopy(CONFIG) - namespace = TrackingNamespace( + ns_client, inner_ns_client = create_tracking_namespace( bucket_name=s3_bucket, storage_options=storage_options, credential_expires_in_seconds=3600, + use_custom=use_custom, ) table1 = pa.Table.from_pylist([{"a": 1, "b": 2}, {"a": 10, "b": 20}]) table_name = uuid.uuid4().hex table_id = ["test_ns", table_name] - assert namespace.get_create_call_count() == 0 - assert namespace.get_describe_call_count() == 0 + assert get_declare_call_count(inner_ns_client) == 0 + assert get_describe_call_count(inner_ns_client) == 0 ds = lance.write_dataset( table1, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="create", storage_options=storage_options, ) assert len(ds.versions()) == 1 assert ds.count_rows() == 2 - assert namespace.get_create_call_count() == 1 + assert get_declare_call_count(inner_ns_client) == 1 - ds_from_namespace = lance.dataset( - namespace=namespace, + ds_from_ns_client = lance.dataset( + namespace_client=ns_client, table_id=table_id, storage_options=storage_options, ) - assert namespace.get_describe_call_count() == 1 - assert ds_from_namespace.count_rows() == 2 - result = ds_from_namespace.to_table() + # 1 describe call from lance.dataset() to get location + assert get_describe_call_count(inner_ns_client) == 1 + assert ds_from_ns_client.count_rows() == 2 + result = ds_from_ns_client.to_table() assert result == table1 # Test credential caching - call_count_before_reads = namespace.get_describe_call_count() + call_count_before_reads = get_describe_call_count(inner_ns_client) for _ in range(3): - assert ds_from_namespace.count_rows() == 2 - assert namespace.get_describe_call_count() == call_count_before_reads + assert ds_from_ns_client.count_rows() == 2 + assert get_describe_call_count(inner_ns_client) == call_count_before_reads @pytest.mark.integration -def test_namespace_with_refresh(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_namespace_with_refresh(s3_bucket: str, use_custom: bool): """Test credential refresh when credentials expire.""" storage_options = copy.deepcopy(CONFIG) - namespace = TrackingNamespace( + ns_client, inner_ns_client = create_tracking_namespace( bucket_name=s3_bucket, storage_options=storage_options, credential_expires_in_seconds=3, + use_custom=use_custom, ) table1 = pa.Table.from_pylist([{"a": 1, "b": 2}, {"a": 10, "b": 20}]) table_name = uuid.uuid4().hex table_id = ["test_ns", table_name] - assert namespace.get_create_call_count() == 0 - assert namespace.get_describe_call_count() == 0 + assert get_declare_call_count(inner_ns_client) == 0 + assert get_describe_call_count(inner_ns_client) == 0 ds = lance.write_dataset( table1, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="create", storage_options=storage_options, ) assert ds.count_rows() == 2 - assert namespace.get_create_call_count() == 1 + assert get_declare_call_count(inner_ns_client) == 1 - ds_from_namespace = lance.dataset( - namespace=namespace, + ds_from_ns_client = lance.dataset( + namespace_client=ns_client, table_id=table_id, storage_options=storage_options, ) - initial_call_count = namespace.get_describe_call_count() + # 1 describe call from lance.dataset() to get location + initial_call_count = get_describe_call_count(inner_ns_client) assert initial_call_count == 1 - assert ds_from_namespace.count_rows() == 2 - result = ds_from_namespace.to_table() + assert ds_from_ns_client.count_rows() == 2 + result = ds_from_ns_client.to_table() assert result == table1 - call_count_after_initial_reads = namespace.get_describe_call_count() + call_count_after_initial_reads = get_describe_call_count(inner_ns_client) time.sleep(5) - assert ds_from_namespace.count_rows() == 2 - result2 = ds_from_namespace.to_table() + assert ds_from_ns_client.count_rows() == 2 + result2 = ds_from_ns_client.to_table() assert result2 == table1 - final_call_count = namespace.get_describe_call_count() + final_call_count = get_describe_call_count(inner_ns_client) assert final_call_count == call_count_after_initial_reads + 1 @pytest.mark.integration -def test_namespace_append_through_namespace(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_namespace_append_through_namespace(s3_bucket: str, use_custom: bool): """Test appending to dataset through namespace.""" storage_options = copy.deepcopy(CONFIG) - namespace = TrackingNamespace( + ns_client, inner_ns_client = create_tracking_namespace( bucket_name=s3_bucket, storage_options=storage_options, credential_expires_in_seconds=3600, + use_custom=use_custom, ) table1 = pa.Table.from_pylist([{"a": 1, "b": 2}]) table_name = uuid.uuid4().hex table_id = ["test_ns", table_name] - assert namespace.get_create_call_count() == 0 - assert namespace.get_describe_call_count() == 0 + assert get_declare_call_count(inner_ns_client) == 0 + assert get_describe_call_count(inner_ns_client) == 0 ds = lance.write_dataset( table1, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="create", storage_options=storage_options, ) assert ds.count_rows() == 1 assert len(ds.versions()) == 1 - assert namespace.get_create_call_count() == 1 - initial_describe_count = namespace.get_describe_call_count() + assert get_declare_call_count(inner_ns_client) == 1 + initial_describe_count = get_describe_call_count(inner_ns_client) table2 = pa.Table.from_pylist([{"a": 10, "b": 20}]) ds = lance.write_dataset( table2, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="append", storage_options=storage_options, ) assert ds.count_rows() == 2 assert len(ds.versions()) == 2 - assert namespace.get_create_call_count() == 1 - assert namespace.get_describe_call_count() == initial_describe_count + 1 + assert get_declare_call_count(inner_ns_client) == 1 + assert get_describe_call_count(inner_ns_client) == initial_describe_count + 1 - ds_from_namespace = lance.dataset( - namespace=namespace, + ds_from_ns_client = lance.dataset( + namespace_client=ns_client, table_id=table_id, storage_options=storage_options, ) - assert ds_from_namespace.count_rows() == 2 - assert len(ds_from_namespace.versions()) == 2 - assert namespace.get_describe_call_count() == initial_describe_count + 2 + assert ds_from_ns_client.count_rows() == 2 + assert len(ds_from_ns_client.versions()) == 2 + # +1 for describe from lance.dataset() + assert get_describe_call_count(inner_ns_client) == initial_describe_count + 2 @pytest.mark.integration -def test_namespace_write_create_mode(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_namespace_write_create_mode(s3_bucket: str, use_custom: bool): """Test writing dataset through namespace in CREATE mode.""" storage_options = copy.deepcopy(CONFIG) - namespace = TrackingNamespace( + ns_client, inner_ns_client = create_tracking_namespace( bucket_name=s3_bucket, storage_options=storage_options, credential_expires_in_seconds=3600, + use_custom=use_custom, ) table1 = pa.Table.from_pylist([{"a": 1, "b": 2}, {"a": 10, "b": 20}]) table_name = uuid.uuid4().hex - assert namespace.get_create_call_count() == 0 - assert namespace.get_describe_call_count() == 0 + assert get_declare_call_count(inner_ns_client) == 0 + assert get_describe_call_count(inner_ns_client) == 0 ds = lance.write_dataset( table1, - namespace=namespace, + namespace_client=ns_client, table_id=["test_ns", table_name], mode="create", storage_options=storage_options, ) - assert namespace.get_create_call_count() == 1 + assert get_declare_call_count(inner_ns_client) == 1 assert ds.count_rows() == 2 assert len(ds.versions()) == 1 result = ds.to_table() @@ -338,143 +451,143 @@ def test_namespace_write_create_mode(s3_bucket: str): @pytest.mark.integration -def test_namespace_write_append_mode(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_namespace_write_append_mode(s3_bucket: str, use_custom: bool): """Test writing dataset through namespace in APPEND mode.""" storage_options = copy.deepcopy(CONFIG) - namespace = TrackingNamespace( + ns_client, inner_ns_client = create_tracking_namespace( bucket_name=s3_bucket, storage_options=storage_options, credential_expires_in_seconds=3600, + use_custom=use_custom, ) table1 = pa.Table.from_pylist([{"a": 1, "b": 2}]) table_name = uuid.uuid4().hex table_id = ["test_ns", table_name] - assert namespace.get_create_call_count() == 0 - assert namespace.get_describe_call_count() == 0 + assert get_declare_call_count(inner_ns_client) == 0 + assert get_describe_call_count(inner_ns_client) == 0 ds = lance.write_dataset( table1, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="create", storage_options=storage_options, ) assert ds.count_rows() == 1 - assert namespace.get_create_call_count() == 1 - assert namespace.get_describe_call_count() == 0 + assert get_declare_call_count(inner_ns_client) == 1 + assert get_describe_call_count(inner_ns_client) == 0 table2 = pa.Table.from_pylist([{"a": 10, "b": 20}]) ds = lance.write_dataset( table2, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="append", storage_options=storage_options, ) - assert namespace.get_create_call_count() == 1 - describe_count_after_append = namespace.get_describe_call_count() + assert get_declare_call_count(inner_ns_client) == 1 + describe_count_after_append = get_describe_call_count(inner_ns_client) assert describe_count_after_append == 1 assert ds.count_rows() == 2 assert len(ds.versions()) == 2 - call_count_before_reads = namespace.get_describe_call_count() + call_count_before_reads = get_describe_call_count(inner_ns_client) for _ in range(3): assert ds.count_rows() == 2 - assert namespace.get_describe_call_count() == call_count_before_reads + assert get_describe_call_count(inner_ns_client) == call_count_before_reads @pytest.mark.integration -def test_namespace_write_overwrite_mode(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_namespace_write_overwrite_mode(s3_bucket: str, use_custom: bool): """Test writing dataset through namespace in OVERWRITE mode.""" storage_options = copy.deepcopy(CONFIG) - namespace = TrackingNamespace( + ns_client, inner_ns_client = create_tracking_namespace( bucket_name=s3_bucket, storage_options=storage_options, credential_expires_in_seconds=3600, + use_custom=use_custom, ) table1 = pa.Table.from_pylist([{"a": 1, "b": 2}]) table_name = uuid.uuid4().hex table_id = ["test_ns", table_name] - assert namespace.get_create_call_count() == 0 - assert namespace.get_describe_call_count() == 0 + assert get_declare_call_count(inner_ns_client) == 0 + assert get_describe_call_count(inner_ns_client) == 0 ds = lance.write_dataset( table1, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="create", storage_options=storage_options, ) assert ds.count_rows() == 1 - assert namespace.get_create_call_count() == 1 - assert namespace.get_describe_call_count() == 0 + assert get_declare_call_count(inner_ns_client) == 1 + assert get_describe_call_count(inner_ns_client) == 0 table2 = pa.Table.from_pylist([{"a": 10, "b": 20}, {"a": 100, "b": 200}]) ds = lance.write_dataset( table2, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="overwrite", storage_options=storage_options, ) - assert namespace.get_create_call_count() == 1 - describe_count_after_overwrite = namespace.get_describe_call_count() + assert get_declare_call_count(inner_ns_client) == 1 + describe_count_after_overwrite = get_describe_call_count(inner_ns_client) assert describe_count_after_overwrite == 1 assert ds.count_rows() == 2 assert len(ds.versions()) == 2 result = ds.to_table() assert result == table2 - call_count_before_reads = namespace.get_describe_call_count() + call_count_before_reads = get_describe_call_count(inner_ns_client) for _ in range(3): assert ds.count_rows() == 2 - assert namespace.get_describe_call_count() == call_count_before_reads + assert get_describe_call_count(inner_ns_client) == call_count_before_reads @pytest.mark.integration -def test_namespace_distributed_write(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_namespace_distributed_write(s3_bucket: str, use_custom: bool): """Test distributed write pattern through namespace.""" storage_options = copy.deepcopy(CONFIG) - namespace = TrackingNamespace( + ns_client, inner_ns_client = create_tracking_namespace( bucket_name=s3_bucket, storage_options=storage_options, credential_expires_in_seconds=3600, + use_custom=use_custom, ) table_name = uuid.uuid4().hex table_id = ["test_ns", table_name] request = DeclareTableRequest(id=table_id, location=None) - response = namespace.declare_table(request) + response = ns_client.declare_table(request) - assert namespace.get_create_call_count() == 1 - assert namespace.get_describe_call_count() == 0 + assert get_declare_call_count(inner_ns_client) == 1 + assert get_describe_call_count(inner_ns_client) == 0 table_uri = response.location assert table_uri is not None - from lance.namespace import LanceNamespaceStorageOptionsProvider - - namespace_storage_options = response.storage_options - assert namespace_storage_options is not None - - storage_options_provider = LanceNamespaceStorageOptionsProvider( - namespace=namespace, table_id=table_id - ) + ns_client_storage_options = response.storage_options + assert ns_client_storage_options is not None merged_options = dict(storage_options) - merged_options.update(namespace_storage_options) + merged_options.update(ns_client_storage_options) from lance.fragment import write_fragments @@ -483,7 +596,8 @@ def test_namespace_distributed_write(s3_bucket: str): fragment1_data, table_uri, storage_options=merged_options, - storage_options_provider=storage_options_provider, + namespace_client=ns_client, + table_id=table_id, ) fragment2_data = pa.Table.from_pylist([{"a": 10, "b": 20}, {"a": 30, "b": 40}]) @@ -491,7 +605,8 @@ def test_namespace_distributed_write(s3_bucket: str): fragment2_data, table_uri, storage_options=merged_options, - storage_options_provider=storage_options_provider, + namespace_client=ns_client, + table_id=table_id, ) fragment3_data = pa.Table.from_pylist([{"a": 100, "b": 200}]) @@ -499,7 +614,8 @@ def test_namespace_distributed_write(s3_bucket: str): fragment3_data, table_uri, storage_options=merged_options, - storage_options_provider=storage_options_provider, + namespace_client=ns_client, + table_id=table_id, ) all_fragments = fragment1 + fragment2 + fragment3 @@ -510,7 +626,8 @@ def test_namespace_distributed_write(s3_bucket: str): table_uri, operation, storage_options=merged_options, - storage_options_provider=storage_options_provider, + namespace_client=ns_client, + table_id=table_id, ) assert ds.count_rows() == 5 @@ -528,57 +645,54 @@ def test_namespace_distributed_write(s3_bucket: str): ) assert result == expected - ds_from_namespace = lance.dataset( - namespace=namespace, + ds_from_ns_client = lance.dataset( + namespace_client=ns_client, table_id=table_id, storage_options=storage_options, ) - assert ds_from_namespace.count_rows() == 5 + assert ds_from_ns_client.count_rows() == 5 @pytest.mark.integration -def test_file_writer_with_storage_options_provider(s3_bucket: str): - """Test LanceFileWriter with storage_options_provider and credential refresh.""" - from lance import LanceNamespaceStorageOptionsProvider +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_file_writer_with_namespace_client(s3_bucket: str, use_custom: bool): + """Test LanceFileWriter with namespace_client and credential refresh.""" from lance.file import LanceFileReader, LanceFileWriter storage_options = copy.deepcopy(CONFIG) - namespace = TrackingNamespace( + ns_client, inner_ns_client = create_tracking_namespace( bucket_name=s3_bucket, storage_options=storage_options, credential_expires_in_seconds=3, + use_custom=use_custom, ) table1 = pa.Table.from_pylist([{"a": 1, "b": 2}, {"a": 10, "b": 20}]) table_name = uuid.uuid4().hex table_id = ["test_ns", table_name] - assert namespace.get_create_call_count() == 0 - assert namespace.get_describe_call_count() == 0 + assert get_declare_call_count(inner_ns_client) == 0 + assert get_describe_call_count(inner_ns_client) == 0 ds = lance.write_dataset( table1, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="create", storage_options=storage_options, ) assert ds.count_rows() == 2 - assert namespace.get_create_call_count() == 1 + assert get_declare_call_count(inner_ns_client) == 1 - describe_response = namespace.describe_table( + describe_response = ns_client.describe_table( DescribeTableRequest(id=table_id, version=None) ) merged_options = dict(storage_options) if describe_response.storage_options: merged_options.update(describe_response.storage_options) - provider = LanceNamespaceStorageOptionsProvider( - namespace=namespace, table_id=table_id - ) - - initial_describe_count = namespace.get_describe_call_count() + initial_describe_count = get_describe_call_count(inner_ns_client) file_uri = f"s3://{s3_bucket}/{table_name}_file_test.lance" schema = pa.schema([pa.field("x", pa.int64()), pa.field("y", pa.int64())]) @@ -587,7 +701,8 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): file_uri, schema=schema, storage_options=merged_options, - storage_options_provider=provider, + namespace_client=ns_client, + table_id=table_id, ) batch = pa.RecordBatch.from_pydict({"x": [1, 2, 3], "y": [4, 5, 6]}, schema=schema) @@ -599,13 +714,14 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): writer.write_batch(batch2) writer.close() - describe_count_after_write = namespace.get_describe_call_count() + describe_count_after_write = get_describe_call_count(inner_ns_client) assert describe_count_after_write == initial_describe_count reader = LanceFileReader( file_uri, storage_options=merged_options, - storage_options_provider=provider, + namespace_client=ns_client, + table_id=table_id, ) result = reader.read_all(batch_size=1024) result_table = result.to_table() @@ -624,7 +740,8 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): file_uri2, schema=schema, storage_options=merged_options, - storage_options_provider=provider, + namespace_client=ns_client, + table_id=table_id, ) batch3 = pa.RecordBatch.from_pydict( @@ -633,13 +750,14 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): writer2.write_batch(batch3) writer2.close() - final_describe_count = namespace.get_describe_call_count() + final_describe_count = get_describe_call_count(inner_ns_client) assert final_describe_count == describe_count_after_write + 1 reader2 = LanceFileReader( file_uri2, storage_options=merged_options, - storage_options_provider=provider, + namespace_client=ns_client, + table_id=table_id, ) result2 = reader2.read_all(batch_size=1024) result_table2 = result2.to_table() @@ -649,17 +767,18 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): @pytest.mark.integration -def test_file_reader_with_storage_options_provider(s3_bucket: str): - """Test LanceFileReader with storage_options_provider and credential refresh.""" - from lance import LanceNamespaceStorageOptionsProvider +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_file_reader_with_namespace_client(s3_bucket: str, use_custom: bool): + """Test LanceFileReader with namespace_client and credential refresh.""" from lance.file import LanceFileReader, LanceFileWriter storage_options = copy.deepcopy(CONFIG) - namespace = TrackingNamespace( + ns_client, inner_ns_client = create_tracking_namespace( bucket_name=s3_bucket, storage_options=storage_options, credential_expires_in_seconds=3, + use_custom=use_custom, ) table1 = pa.Table.from_pylist([{"a": 1, "b": 2}, {"a": 10, "b": 20}]) @@ -668,28 +787,24 @@ def test_file_reader_with_storage_options_provider(s3_bucket: str): ds = lance.write_dataset( table1, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="create", storage_options=storage_options, ) assert ds.count_rows() == 2 - describe_response = namespace.describe_table( + describe_response = ns_client.describe_table( DescribeTableRequest(id=table_id, version=None) ) merged_options = dict(storage_options) if describe_response.storage_options: merged_options.update(describe_response.storage_options) - provider = LanceNamespaceStorageOptionsProvider( - namespace=namespace, table_id=table_id - ) - file_uri = f"s3://{s3_bucket}/{table_name}_file_reader_test.lance" schema = pa.schema([pa.field("x", pa.int64()), pa.field("y", pa.int64())]) - # Write a file first (without provider to keep it simple) + # Write a file first (without namespace_client to keep it simple) writer = LanceFileWriter( file_uri, schema=schema, @@ -700,27 +815,28 @@ def test_file_reader_with_storage_options_provider(s3_bucket: str): writer.close() # Get fresh credentials for reading - describe_response = namespace.describe_table( + describe_response = ns_client.describe_table( DescribeTableRequest(id=table_id, version=None) ) merged_options = dict(storage_options) if describe_response.storage_options: merged_options.update(describe_response.storage_options) - initial_describe_count = namespace.get_describe_call_count() + initial_describe_count = get_describe_call_count(inner_ns_client) # First read should work without needing refresh reader = LanceFileReader( file_uri, storage_options=merged_options, - storage_options_provider=provider, + namespace_client=ns_client, + table_id=table_id, ) result = reader.read_all(batch_size=1024) result_table = result.to_table() assert result_table.num_rows == 3 assert result_table.schema == schema - describe_count_after_first_read = namespace.get_describe_call_count() + describe_count_after_first_read = get_describe_call_count(inner_ns_client) assert describe_count_after_first_read == initial_describe_count # Wait for credentials to expire @@ -743,7 +859,8 @@ def test_file_reader_with_storage_options_provider(s3_bucket: str): reader2 = LanceFileReader( file_uri2, storage_options=merged_options, - storage_options_provider=provider, + namespace_client=ns_client, + table_id=table_id, ) result2 = reader2.read_all(batch_size=1024) result_table2 = result2.to_table() @@ -751,22 +868,23 @@ def test_file_reader_with_storage_options_provider(s3_bucket: str): expected_table2 = pa.table({"x": [100, 200], "y": [300, 400]}, schema=schema) assert result_table2 == expected_table2 - final_describe_count = namespace.get_describe_call_count() + final_describe_count = get_describe_call_count(inner_ns_client) assert final_describe_count == describe_count_after_first_read + 1 @pytest.mark.integration -def test_file_session_with_storage_options_provider(s3_bucket: str): - """Test LanceFileSession with storage_options_provider and credential refresh.""" - from lance import LanceNamespaceStorageOptionsProvider +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_file_session_with_namespace_client(s3_bucket: str, use_custom: bool): + """Test LanceFileSession with namespace_client and credential refresh.""" from lance.file import LanceFileSession storage_options = copy.deepcopy(CONFIG) - namespace = TrackingNamespace( + ns_client, inner_ns_client = create_tracking_namespace( bucket_name=s3_bucket, storage_options=storage_options, credential_expires_in_seconds=3, + use_custom=use_custom, ) table1 = pa.Table.from_pylist([{"a": 1, "b": 2}, {"a": 10, "b": 20}]) @@ -775,31 +893,28 @@ def test_file_session_with_storage_options_provider(s3_bucket: str): ds = lance.write_dataset( table1, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="create", storage_options=storage_options, ) assert ds.count_rows() == 2 - describe_response = namespace.describe_table( + describe_response = ns_client.describe_table( DescribeTableRequest(id=table_id, version=None) ) merged_options = dict(storage_options) if describe_response.storage_options: merged_options.update(describe_response.storage_options) - provider = LanceNamespaceStorageOptionsProvider( - namespace=namespace, table_id=table_id - ) - - initial_describe_count = namespace.get_describe_call_count() + initial_describe_count = get_describe_call_count(inner_ns_client) - # Create session with storage_options_provider + # Create session with namespace_client session = LanceFileSession( f"s3://{s3_bucket}/{table_name}_session", storage_options=merged_options, - storage_options_provider=provider, + namespace_client=ns_client, + table_id=table_id, ) # Test contains method @@ -820,7 +935,7 @@ def test_file_session_with_storage_options_provider(s3_bucket: str): writer.write_batch(batch) writer.close() - describe_count_after_first_write = namespace.get_describe_call_count() + describe_count_after_first_write = get_describe_call_count(inner_ns_client) assert describe_count_after_first_write == initial_describe_count # Test contains method after write @@ -836,7 +951,7 @@ def test_file_session_with_storage_options_provider(s3_bucket: str): expected_table = pa.table({"x": [1, 2, 3], "y": [4, 5, 6]}, schema=schema) assert result_table == expected_table - describe_count_after_first_read = namespace.get_describe_call_count() + describe_count_after_first_read = get_describe_call_count(inner_ns_client) assert describe_count_after_first_read == describe_count_after_first_write # Wait for credentials to expire @@ -853,7 +968,7 @@ def test_file_session_with_storage_options_provider(s3_bucket: str): writer2.write_batch(batch2) writer2.close() - describe_count_after_second_write = namespace.get_describe_call_count() + describe_count_after_second_write = get_describe_call_count(inner_ns_client) assert describe_count_after_second_write == describe_count_after_first_read + 1 # Read the second file - should not trigger another refresh since we just refreshed @@ -864,7 +979,7 @@ def test_file_session_with_storage_options_provider(s3_bucket: str): expected_table2 = pa.table({"x": [100, 200], "y": [300, 400]}, schema=schema) assert result_table2 == expected_table2 - final_describe_count = namespace.get_describe_call_count() + final_describe_count = get_describe_call_count(inner_ns_client) assert final_describe_count == describe_count_after_second_write @@ -890,22 +1005,18 @@ def table_to_ipc_bytes(table): @pytest.mark.integration -def test_basic_create_and_drop_on_s3(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_basic_create_and_drop_on_s3(s3_bucket: str, use_custom: bool): """Test basic create and drop table operations on S3. Mirrors Java: testBasicCreateAndDropOnS3 """ - from lance.namespace import DirectoryNamespace - from lance_namespace import ( - DropTableRequest, - TableExistsRequest, - ) - test_prefix = f"test-{uuid.uuid4().hex[:8]}" storage_options = copy.deepcopy(CONFIG) dir_props = {f"storage.{k}": v for k, v in storage_options.items()} dir_props["root"] = f"s3://{s3_bucket}/{test_prefix}" - namespace = DirectoryNamespace(**dir_props) + inner_ns_client = DirectoryNamespace(**dir_props) + ns_client = _wrap_if_custom(inner_ns_client, use_custom) table_name = "basic_test_table" table_data = create_test_table_data() @@ -914,7 +1025,7 @@ def test_basic_create_and_drop_on_s3(s3_bucket: str): # Create table using lance.write_dataset ds = lance.write_dataset( table_data, - namespace=namespace, + namespace_client=ns_client, table_id=table_id, mode="create", storage_options=storage_options, @@ -924,39 +1035,36 @@ def test_basic_create_and_drop_on_s3(s3_bucket: str): # Drop table drop_req = DropTableRequest(id=table_id) - drop_resp = namespace.drop_table(drop_req) + drop_resp = ns_client.drop_table(drop_req) assert drop_resp is not None # Verify table no longer exists exists_req = TableExistsRequest(id=table_id) with pytest.raises(Exception): - namespace.table_exists(exists_req) + ns_client.table_exists(exists_req) @pytest.mark.integration -def test_concurrent_create_and_drop_single_instance_on_s3(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_concurrent_create_and_drop_single_instance_on_s3( + s3_bucket: str, use_custom: bool +): """Test concurrent create/drop with single namespace instance on S3.""" import concurrent.futures - from lance.namespace import DirectoryNamespace - from lance_namespace import ( - CreateNamespaceRequest, - CreateTableRequest, - DropTableRequest, - ) - test_prefix = f"test-{uuid.uuid4().hex[:8]}" storage_options = copy.deepcopy(CONFIG) dir_props = {f"storage.{k}": v for k, v in storage_options.items()} dir_props["root"] = f"s3://{s3_bucket}/{test_prefix}" # Very high retry count to guarantee all operations succeed dir_props["commit_retries"] = "2147483647" - namespace = DirectoryNamespace(**dir_props) + inner_ns_client = DirectoryNamespace(**dir_props) + ns_client = _wrap_if_custom(inner_ns_client, use_custom) # Initialize namespace first - create parent namespace to ensure __manifest table # is created before concurrent operations create_ns_req = CreateNamespaceRequest(id=["test_ns"]) - namespace.create_namespace(create_ns_req) + ns_client.create_namespace(create_ns_req) num_tables = 10 success_count = 0 @@ -973,11 +1081,11 @@ def create_and_drop_table(table_index): # Create table using atomic create_table API create_req = CreateTableRequest(id=table_id) - namespace.create_table(create_req, ipc_data) + ns_client.create_table(create_req, ipc_data) # Drop table drop_req = DropTableRequest(id=table_id) - namespace.drop_table(drop_req) + ns_client.drop_table(drop_req) with lock: success_count += 1 @@ -998,18 +1106,13 @@ def create_and_drop_table(table_index): @pytest.mark.integration -def test_concurrent_create_and_drop_multiple_instances_on_s3(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_concurrent_create_and_drop_multiple_instances_on_s3( + s3_bucket: str, use_custom: bool +): """Test concurrent create/drop with multiple namespace instances on S3.""" import concurrent.futures - from lance.namespace import DirectoryNamespace - from lance_namespace import ( - CreateNamespaceRequest, - CreateTableRequest, - DropTableRequest, - ListTablesRequest, - ) - test_prefix = f"test-{uuid.uuid4().hex[:8]}" storage_options = copy.deepcopy(CONFIG) base_dir_props = {f"storage.{k}": v for k, v in storage_options.items()} @@ -1019,9 +1122,10 @@ def test_concurrent_create_and_drop_multiple_instances_on_s3(s3_bucket: str): # Initialize namespace first with a single instance to ensure __manifest # table is created and parent namespace exists before concurrent operations - init_ns = DirectoryNamespace(**base_dir_props.copy()) + inner_init_ns_client = DirectoryNamespace(**base_dir_props.copy()) + init_ns_client = _wrap_if_custom(inner_init_ns_client, use_custom) create_ns_req = CreateNamespaceRequest(id=["test_ns"]) - init_ns.create_namespace(create_ns_req) + init_ns_client.create_namespace(create_ns_req) num_tables = 10 success_count = 0 @@ -1031,8 +1135,9 @@ def test_concurrent_create_and_drop_multiple_instances_on_s3(s3_bucket: str): def create_and_drop_table(table_index): nonlocal success_count, fail_count try: - # Each thread creates its own namespace instance - ns = DirectoryNamespace(**base_dir_props.copy()) + # Each thread creates its own namespace client instance + inner_local_ns_client = DirectoryNamespace(**base_dir_props.copy()) + local_ns_client = _wrap_if_custom(inner_local_ns_client, use_custom) table_name = f"s3_multi_ns_table_{table_index}" table_data = create_test_table_data() @@ -1041,11 +1146,11 @@ def create_and_drop_table(table_index): # Create table using atomic create_table API create_req = CreateTableRequest(id=table_id) - ns.create_table(create_req, ipc_data) + local_ns_client.create_table(create_req, ipc_data) # Drop table drop_req = DropTableRequest(id=table_id) - ns.drop_table(drop_req) + local_ns_client.drop_table(drop_req) with lock: success_count += 1 @@ -1065,24 +1170,20 @@ def create_and_drop_table(table_index): assert fail_count == 0, f"Expected 0 failures, got {fail_count}" # Verify remaining state is consistent (no corruption) - verify_ns = DirectoryNamespace(**base_dir_props) + inner_verify_ns_client = DirectoryNamespace(**base_dir_props) + verify_ns_client = _wrap_if_custom(inner_verify_ns_client, use_custom) list_req = ListTablesRequest(id=["test_ns"]) - _ = verify_ns.list_tables(list_req) # Should not error + _ = verify_ns_client.list_tables(list_req) # Should not error @pytest.mark.integration -def test_concurrent_create_then_drop_from_different_instance_on_s3(s3_bucket: str): +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_concurrent_create_then_drop_from_different_instance_on_s3( + s3_bucket: str, use_custom: bool +): """Test creating from one set of instances, dropping from different ones on S3.""" import concurrent.futures - from lance.namespace import DirectoryNamespace - from lance_namespace import ( - CreateNamespaceRequest, - CreateTableRequest, - DropTableRequest, - ListTablesRequest, - ) - test_prefix = f"test-{uuid.uuid4().hex[:8]}" storage_options = copy.deepcopy(CONFIG) base_dir_props = {f"storage.{k}": v for k, v in storage_options.items()} @@ -1092,13 +1193,14 @@ def test_concurrent_create_then_drop_from_different_instance_on_s3(s3_bucket: st # Initialize namespace first with a single instance to ensure __manifest # table is created and parent namespace exists before concurrent operations - init_ns = DirectoryNamespace(**base_dir_props.copy()) + inner_init_ns_client = DirectoryNamespace(**base_dir_props.copy()) + init_ns_client = _wrap_if_custom(inner_init_ns_client, use_custom) create_ns_req = CreateNamespaceRequest(id=["test_ns"]) - init_ns.create_namespace(create_ns_req) + init_ns_client.create_namespace(create_ns_req) num_tables = 10 - # Phase 1: Create all tables concurrently using separate namespace instances + # Phase 1: Create all tables concurrently using separate namespace client instances create_success_count = 0 create_fail_count = 0 create_lock = Lock() @@ -1107,7 +1209,8 @@ def create_table(table_index): nonlocal create_success_count, create_fail_count table_name = f"s3_cross_instance_table_{table_index}" try: - ns = DirectoryNamespace(**base_dir_props.copy()) + inner_local_ns_client = DirectoryNamespace(**base_dir_props.copy()) + local_ns_client = _wrap_if_custom(inner_local_ns_client, use_custom) table_data = create_test_table_data() table_id = ["test_ns", table_name] @@ -1115,7 +1218,7 @@ def create_table(table_index): # Create table using atomic create_table API create_req = CreateTableRequest(id=table_id) - ns.create_table(create_req, ipc_data) + local_ns_client.create_table(create_req, ipc_data) with create_lock: create_success_count += 1 @@ -1144,13 +1247,14 @@ def create_table(table_index): def drop_table(table_index): nonlocal drop_success_count, drop_fail_count try: - ns = DirectoryNamespace(**base_dir_props.copy()) + inner_local_ns_client = DirectoryNamespace(**base_dir_props.copy()) + local_ns_client = _wrap_if_custom(inner_local_ns_client, use_custom) table_name = f"s3_cross_instance_table_{table_index}" table_id = ["test_ns", table_name] drop_req = DropTableRequest(id=table_id) - ns.drop_table(drop_req) + local_ns_client.drop_table(drop_req) with drop_lock: drop_success_count += 1 @@ -1171,6 +1275,7 @@ def drop_table(table_index): assert drop_fail_count == 0, f"Expected 0 drop failures, got {drop_fail_count}" # Verify remaining state is consistent (no corruption) - verify_ns = DirectoryNamespace(**base_dir_props) + inner_verify_ns_client = DirectoryNamespace(**base_dir_props) + verify_ns_client = _wrap_if_custom(inner_verify_ns_client, use_custom) list_req = ListTablesRequest(id=["test_ns"]) - _ = verify_ns.list_tables(list_req) # Should not error + _ = verify_ns_client.list_tables(list_req) # Should not error diff --git a/python/python/tests/test_namespace_rest.py b/python/python/tests/test_namespace_rest.py index 429066f5cff..140d9168c05 100644 --- a/python/python/tests/test_namespace_rest.py +++ b/python/python/tests/test_namespace_rest.py @@ -650,18 +650,18 @@ def test_connect_with_rest(self): with lance.namespace.RestAdapter("dir", backend_config, port=0) as adapter: properties = {"uri": f"http://127.0.0.1:{adapter.port}"} - ns = connect("rest", properties) + ns_client = connect("rest", properties) - assert isinstance(ns, lance.namespace.RestNamespace) + assert isinstance(ns_client, lance.namespace.RestNamespace) create_req = CreateTableRequest(id=["test_table"]) table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) - response = ns.create_table(create_req, ipc_data) + response = ns_client.create_table(create_req, ipc_data) assert response is not None list_req = ListTablesRequest(id=[]) - list_response = ns.list_tables(list_req) + list_response = ns_client.list_tables(list_req) assert len(list_response.tables) == 1 assert list_response.tables[0] == "test_table" @@ -675,14 +675,14 @@ def test_connect_with_custom_delimiter(self): "uri": f"http://127.0.0.1:{adapter.port}", "delimiter": "@", } - ns = connect("rest", properties) + ns_client = connect("rest", properties) - assert isinstance(ns, lance.namespace.RestNamespace) + assert isinstance(ns_client, lance.namespace.RestNamespace) create_req = CreateTableRequest(id=["test_table"]) table_data = create_test_data() ipc_data = table_to_ipc_bytes(table_data) - response = ns.create_table(create_req, ipc_data) + response = ns_client.create_table(create_req, ipc_data) assert response is not None @@ -705,17 +705,17 @@ def provide_context(self, info): backend_config = {"root": tmpdir} with lance.namespace.RestAdapter("dir", backend_config, port=0) as adapter: - ns = lance.namespace.RestNamespace( + ns_client = lance.namespace.RestNamespace( uri=f"http://127.0.0.1:{adapter.port}", context_provider=TestProvider(), ) # Perform operations create_req = CreateNamespaceRequest(id=["workspace"]) - ns.create_namespace(create_req) + ns_client.create_namespace(create_req) list_req = ListNamespacesRequest(id=[]) - ns.list_namespaces(list_req) + ns_client.list_namespaces(list_req) # Context provider should have been called assert call_count["count"] >= 2 @@ -734,7 +734,7 @@ def provide_context(self, info): with lance.namespace.RestAdapter("dir", backend_config, port=0) as adapter: # Pass both explicit provider and class path - explicit should win - ns = lance.namespace.RestNamespace( + ns_client = lance.namespace.RestNamespace( context_provider=ExplicitProvider(), **{ "uri": f"http://127.0.0.1:{adapter.port}", @@ -743,7 +743,7 @@ def provide_context(self, info): ) create_req = CreateNamespaceRequest(id=["workspace"]) - ns.create_namespace(create_req) + ns_client.create_namespace(create_req) # Explicit provider should have been used assert explicit_called["called"] diff --git a/python/python/tests/test_tf.py b/python/python/tests/test_tf.py index 432be52b482..3652df0e938 100644 --- a/python/python/tests/test_tf.py +++ b/python/python/tests/test_tf.py @@ -116,12 +116,12 @@ def fake_dataset(uri=None, **kwargs): ns = object() ds = from_lance( None, - namespace=ns, + namespace_client=ns, table_id=["tbl"], ignore_namespace_table_storage_options=True, ) - assert calls["kwargs"]["namespace"] is ns + assert calls["kwargs"]["namespace_client"] is ns assert calls["kwargs"]["table_id"] == ["tbl"] assert calls["kwargs"]["ignore_namespace_table_storage_options"] is True diff --git a/python/src/dataset.rs b/python/src/dataset.rs index e1f219b8f9b..567a1851768 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -78,7 +78,9 @@ use lance_index::{ use lance_index::{ infer_system_index_type, metrics::NoOpMetricsCollector, scalar::inverted::query::Occur, }; -use lance_io::object_store::ObjectStoreParams; +use lance_io::object_store::{ + LanceNamespaceStorageOptionsProvider, ObjectStoreParams, StorageOptionsAccessor, +}; use lance_linalg::distance::MetricType; use lance_table::format::{BasePath, Fragment, IndexMetadata}; use lance_table::io::commit::CommitHandler; @@ -482,7 +484,7 @@ impl Dataset { #[allow(clippy::too_many_arguments)] #[allow(deprecated)] #[new] - #[pyo3(signature=(uri, version=None, block_size=None, index_cache_size=None, metadata_cache_size=None, commit_handler=None, storage_options=None, manifest=None, metadata_cache_size_bytes=None, index_cache_size_bytes=None, read_params=None, session=None, storage_options_provider=None, namespace=None, table_id=None))] + #[pyo3(signature=(uri, version=None, block_size=None, index_cache_size=None, metadata_cache_size=None, commit_handler=None, storage_options=None, manifest=None, metadata_cache_size_bytes=None, index_cache_size_bytes=None, read_params=None, session=None, namespace_client=None, table_id=None, namespace_client_managed_versioning=false))] fn new( py: Python, uri: String, @@ -497,9 +499,9 @@ impl Dataset { index_cache_size_bytes: Option, read_params: Option<&Bound>, session: Option, - storage_options_provider: Option<&Bound<'_, PyAny>>, - namespace: Option<&Bound<'_, PyAny>>, + namespace_client: Option<&Bound<'_, PyAny>>, table_id: Option>, + namespace_client_managed_versioning: bool, ) -> PyResult { let mut params = ReadParams::default(); if let Some(metadata_cache_size_bytes) = metadata_cache_size_bytes { @@ -569,6 +571,9 @@ impl Dataset { )); }; } + // Save a copy of storage options for potential namespace-based credential refresh + let initial_storage_options = storage_options.clone(); + if let Some(mut storage_options) = storage_options { if let Some(user_agent) = storage_options.get_mut("user_agent") { user_agent.push_str(&format!(" pylance/{}", env!("CARGO_PKG_VERSION"))); @@ -589,21 +594,34 @@ impl Dataset { builder = builder.with_session(session.inner.clone()); } - // Add storage options provider if provided - if let Some(provider_obj) = storage_options_provider { - use crate::storage_options::py_object_to_storage_options_provider; - let provider = py_object_to_storage_options_provider(provider_obj)?; - builder = builder.with_storage_options_provider(provider); - } + // Set up namespace-based features if namespace_client and table_id are provided + if let (Some(ns_client), Some(tid)) = (&namespace_client, &table_id) { + let ns_client = extract_namespace_arc(py, ns_client)?; - // Set up namespace commit handler if namespace and table_id are provided - if let (Some(ns), Some(tid)) = (namespace, table_id) { - let ns_arc = extract_namespace_arc(py, ns)?; - let external_store = LanceNamespaceExternalManifestStore::new(ns_arc, tid); - let commit_handler: Arc = Arc::new(ExternalManifestCommitHandler { - external_manifest_store: Arc::new(external_store), - }); - builder = builder.with_commit_handler(commit_handler); + // Auto-create storage options provider from namespace client + // when storage_options are present (meaning credentials came from namespace.describe_table) + if initial_storage_options.is_some() { + let provider: Arc = Arc::new( + LanceNamespaceStorageOptionsProvider::new(ns_client.clone(), tid.clone()), + ); + // Create accessor with initial options and provider for credential refresh + let accessor = Arc::new(StorageOptionsAccessor::with_initial_and_provider( + initial_storage_options.clone().unwrap_or_default(), + provider, + )); + builder = builder.with_storage_options_accessor(accessor); + } + + // Set up commit handler only if namespace manages versioning + if namespace_client_managed_versioning { + let external_store = + LanceNamespaceExternalManifestStore::new(ns_client, tid.clone()); + let commit_handler: Arc = + Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }); + builder = builder.with_commit_handler(commit_handler); + } } let dataset = rt().block_on(Some(py), builder.load())?; @@ -2174,21 +2192,21 @@ impl Dataset { #[allow(clippy::too_many_arguments)] #[staticmethod] - #[pyo3(signature = (dest, operation, read_version = None, commit_lock = None, storage_options = None, storage_options_provider = None, enable_v2_manifest_paths = None, detached = None, max_retries = None, commit_message = None, enable_stable_row_ids = None, namespace = None, table_id = None))] + #[pyo3(signature = (dest, operation, read_version = None, commit_lock = None, storage_options = None, enable_v2_manifest_paths = None, detached = None, max_retries = None, commit_message = None, enable_stable_row_ids = None, namespace_client = None, table_id = None, namespace_client_managed_versioning = false))] fn commit( dest: PyWriteDest, operation: PyLance, read_version: Option, commit_lock: Option<&Bound<'_, PyAny>>, storage_options: Option>, - storage_options_provider: Option<&Bound<'_, PyAny>>, enable_v2_manifest_paths: Option, detached: Option, max_retries: Option, commit_message: Option, enable_stable_row_ids: Option, - namespace: Option<&Bound<'_, PyAny>>, + namespace_client: Option<&Bound<'_, PyAny>>, table_id: Option>, + namespace_client_managed_versioning: bool, ) -> PyResult { let mut transaction = Transaction::new(read_version.unwrap_or_default(), operation.0, None); @@ -2204,37 +2222,35 @@ impl Dataset { PyLance(transaction), commit_lock, storage_options, - storage_options_provider, enable_v2_manifest_paths, detached, max_retries, enable_stable_row_ids, - namespace, + namespace_client, table_id, + namespace_client_managed_versioning, ) } #[allow(clippy::too_many_arguments)] #[allow(deprecated)] #[staticmethod] - #[pyo3(signature = (dest, transaction, commit_lock = None, storage_options = None, storage_options_provider = None, enable_v2_manifest_paths = None, detached = None, max_retries = None, enable_stable_row_ids = None, namespace = None, table_id = None))] + #[pyo3(signature = (dest, transaction, commit_lock = None, storage_options = None, enable_v2_manifest_paths = None, detached = None, max_retries = None, enable_stable_row_ids = None, namespace_client = None, table_id = None, namespace_client_managed_versioning = false))] fn commit_transaction( dest: PyWriteDest, transaction: PyLance, commit_lock: Option<&Bound<'_, PyAny>>, storage_options: Option>, - storage_options_provider: Option<&Bound<'_, PyAny>>, enable_v2_manifest_paths: Option, detached: Option, max_retries: Option, enable_stable_row_ids: Option, - namespace: Option<&Bound<'_, PyAny>>, + namespace_client: Option<&Bound<'_, PyAny>>, table_id: Option>, + namespace_client_managed_versioning: bool, ) -> PyResult { - let accessor = crate::storage_options::create_accessor_from_python( - storage_options.clone(), - storage_options_provider, - )?; + let accessor = + crate::storage_options::create_accessor_from_storage_options(storage_options.clone())?; let object_store_params = if accessor.is_some() { Some(ObjectStoreParams { @@ -2245,7 +2261,8 @@ impl Dataset { None }; - // Create commit_handler: prefer user-provided commit_lock, then namespace-based handler + // Create commit_handler: prefer user-provided commit_lock, then namespace client-based handler + // (only if namespace_client_managed_versioning is true) let commit_handler: Option> = if let Some(commit_lock) = commit_lock.as_ref() { // User provided a commit_lock @@ -2254,10 +2271,13 @@ impl Dataset { .into_py_any(commit_lock.py()) .map(|cl| Arc::new(PyCommitLock::new(cl)) as Arc)?, ) - } else if let (Some(ns), Some(tid)) = (namespace, table_id) { - // Create ExternalManifestCommitHandler from namespace and table_id - let ns_arc = extract_namespace_arc(ns.py(), ns)?; - let external_store = LanceNamespaceExternalManifestStore::new(ns_arc, tid); + } else if namespace_client_managed_versioning + && let (Some(ns_client), Some(tid)) = (namespace_client, table_id) + { + // Create ExternalManifestCommitHandler from namespace client and table_id + // only when namespace manages versioning + let ns_client = extract_namespace_arc(ns_client.py(), ns_client)?; + let external_store = LanceNamespaceExternalManifestStore::new(ns_client, tid); Some(Arc::new(ExternalManifestCommitHandler { external_manifest_store: Arc::new(external_store), }) as Arc) @@ -2299,21 +2319,18 @@ impl Dataset { #[allow(clippy::too_many_arguments)] #[allow(deprecated)] #[staticmethod] - #[pyo3(signature = (dest, transactions, commit_lock = None, storage_options = None, storage_options_provider = None, enable_v2_manifest_paths = None, detached = None, max_retries = None))] + #[pyo3(signature = (dest, transactions, commit_lock = None, storage_options = None, enable_v2_manifest_paths = None, detached = None, max_retries = None))] fn commit_batch( dest: PyWriteDest, transactions: Vec>, commit_lock: Option<&Bound<'_, PyAny>>, storage_options: Option>, - storage_options_provider: Option<&Bound<'_, PyAny>>, enable_v2_manifest_paths: Option, detached: Option, max_retries: Option, ) -> PyResult<(Self, PyLance)> { - let accessor = crate::storage_options::create_accessor_from_python( - storage_options.clone(), - storage_options_provider, - )?; + let accessor = + crate::storage_options::create_accessor_from_storage_options(storage_options.clone())?; let object_store_params = if accessor.is_some() { Some(ObjectStoreParams { @@ -3115,20 +3132,35 @@ pub fn get_write_params(options: &Bound<'_, PyDict>) -> PyResult>(options, "storage_options")?; - let storage_options_provider = - get_dict_opt::>(options, "storage_options_provider")?; - - if storage_options.is_some() || storage_options_provider.is_some() { - let accessor = crate::storage_options::create_accessor_from_python( - storage_options, - storage_options_provider - .as_ref() - .map(|py_obj| py_obj.bind(options.py())), - )?; - p.store_params = Some(ObjectStoreParams { - storage_options_accessor: accessor, - ..Default::default() - }); + + // Extract namespace_client and table_id for storage options provider creation + let namespace_client_opt = get_dict_opt::>(options, "namespace_client")?; + let table_id_opt = get_dict_opt::>(options, "table_id")?; + + if let Some(so) = storage_options.clone() { + // If namespace_client and table_id are provided, create storage options provider from them + if let (Some(ns_client), Some(table_id)) = + (namespace_client_opt.as_ref(), table_id_opt.as_ref()) + { + let ns_client = extract_namespace_arc(options.py(), ns_client)?; + let provider: Arc = Arc::new( + LanceNamespaceStorageOptionsProvider::new(ns_client, table_id.clone()), + ); + let accessor = Arc::new(StorageOptionsAccessor::with_initial_and_provider( + so, provider, + )); + p.store_params = Some(ObjectStoreParams { + storage_options_accessor: Some(accessor), + ..Default::default() + }); + } else { + // No namespace, just use storage options directly + let accessor = Arc::new(StorageOptionsAccessor::with_static_options(so)); + p.store_params = Some(ObjectStoreParams { + storage_options_accessor: Some(accessor), + ..Default::default() + }); + } } if let Some(enable_stable_row_ids) = get_dict_opt::(options, "enable_stable_row_ids")? @@ -3213,21 +3245,22 @@ pub fn get_write_params(options: &Bound<'_, PyDict>) -> PyResult>(options, "namespace")?; - let table_id_opt = get_dict_opt::>(options, "table_id")?; - - if let (Some(ns), Some(table_id)) = (namespace_opt, table_id_opt) { - let ns_arc = extract_namespace_arc(options.py(), &ns)?; - let external_store = LanceNamespaceExternalManifestStore::new(ns_arc, table_id); - let commit_handler: Arc = - Arc::new(ExternalManifestCommitHandler { - external_manifest_store: Arc::new(external_store), - }); - p.commit_handler = Some(commit_handler); - } + // Handle namespace_client and table_id for managed versioning (external manifest store) + // Only set if commit_handler is not already set by user and namespace_client_managed_versioning is true + let namespace_client_managed_versioning = + get_dict_opt::(options, "namespace_client_managed_versioning")?.unwrap_or(false); + if p.commit_handler.is_none() + && namespace_client_managed_versioning + && let (Some(ns_client), Some(table_id)) = + (namespace_client_opt.as_ref(), table_id_opt.as_ref()) + { + let ns_client = extract_namespace_arc(options.py(), ns_client)?; + let external_store = + LanceNamespaceExternalManifestStore::new(ns_client, table_id.clone()); + let commit_handler: Arc = Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }); + p.commit_handler = Some(commit_handler); } Some(p) diff --git a/python/src/file.rs b/python/src/file.rs index da8ba3e76bb..a25383ccea7 100644 --- a/python/src/file.rs +++ b/python/src/file.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use crate::namespace::extract_namespace_arc; use crate::{error::PythonErrorExt, rt}; use arrow::pyarrow::PyArrowType; use arrow_array::{RecordBatch, RecordBatchReader, UInt32Array}; @@ -28,7 +29,7 @@ use lance_file::reader::{ }; use lance_file::writer::{FileWriter, FileWriterOptions}; use lance_file::{LanceEncodingsIo, version::LanceFileVersion}; -use lance_io::object_store::ObjectStoreParams; +use lance_io::object_store::{LanceNamespaceStorageOptionsProvider, ObjectStoreParams}; use lance_io::{ ReadBatchParams, scheduler::{ScanScheduler, SchedulerConfig}, @@ -298,7 +299,7 @@ impl LanceFileWriter { #[pymethods] impl LanceFileWriter { #[new] - #[pyo3(signature=(path, schema=None, data_cache_bytes=None, version=None, storage_options=None, storage_options_provider=None, keep_original_array=None, max_page_bytes=None))] + #[pyo3(signature=(path, schema=None, data_cache_bytes=None, version=None, storage_options=None, namespace_client=None, table_id=None, keep_original_array=None, max_page_bytes=None))] #[allow(clippy::too_many_arguments)] pub fn new( path: String, @@ -306,14 +307,22 @@ impl LanceFileWriter { data_cache_bytes: Option, version: Option, storage_options: Option>, - storage_options_provider: Option<&Bound<'_, PyAny>>, + namespace_client: Option<&Bound<'_, PyAny>>, + table_id: Option>, keep_original_array: Option, max_page_bytes: Option, ) -> PyResult { - // Convert Python StorageOptionsProvider to Rust trait object - let provider = storage_options_provider - .map(crate::storage_options::py_object_to_storage_options_provider) - .transpose()?; + // Create storage options provider from namespace_client and table_id if both are provided + let provider = if let (Some(ns_client), Some(tid)) = (&namespace_client, &table_id) { + let ns_client = extract_namespace_arc(ns_client.py(), ns_client)?; + Some(Arc::new(LanceNamespaceStorageOptionsProvider::new( + ns_client, + tid.clone(), + )) + as Arc) + } else { + None + }; rt().block_on( None, @@ -447,15 +456,24 @@ impl LanceFileSession { #[pymethods] impl LanceFileSession { #[new] - #[pyo3(signature=(uri_or_path, storage_options=None, storage_options_provider=None))] + #[pyo3(signature=(uri_or_path, storage_options=None, namespace_client=None, table_id=None))] pub fn new( uri_or_path: String, storage_options: Option>, - storage_options_provider: Option<&Bound<'_, PyAny>>, + namespace_client: Option<&Bound<'_, PyAny>>, + table_id: Option>, ) -> PyResult { - let provider = storage_options_provider - .map(crate::storage_options::py_object_to_storage_options_provider) - .transpose()?; + // Create storage options provider from namespace_client and table_id if both are provided + let provider = if let (Some(ns_client), Some(tid)) = (&namespace_client, &table_id) { + let ns_client = extract_namespace_arc(ns_client.py(), ns_client)?; + Some(Arc::new(LanceNamespaceStorageOptionsProvider::new( + ns_client, + tid.clone(), + )) + as Arc) + } else { + None + }; rt().block_on(None, Self::try_new(uri_or_path, storage_options, provider))? } @@ -736,16 +754,25 @@ impl LanceFileReader { #[pymethods] impl LanceFileReader { #[new] - #[pyo3(signature=(path, storage_options=None, storage_options_provider=None, columns=None))] + #[pyo3(signature=(path, storage_options=None, namespace_client=None, table_id=None, columns=None))] pub fn new( path: String, storage_options: Option>, - storage_options_provider: Option<&Bound<'_, PyAny>>, + namespace_client: Option<&Bound<'_, PyAny>>, + table_id: Option>, columns: Option>, ) -> PyResult { - let provider = storage_options_provider - .map(crate::storage_options::py_object_to_storage_options_provider) - .transpose()?; + // Create storage options provider from namespace_client and table_id if both are provided + let provider = if let (Some(ns_client), Some(tid)) = (&namespace_client, &table_id) { + let ns_client = extract_namespace_arc(ns_client.py(), ns_client)?; + Some(Arc::new(LanceNamespaceStorageOptionsProvider::new( + ns_client, + tid.clone(), + )) + as Arc) + } else { + None + }; rt().block_on(None, Self::open(path, storage_options, provider, columns))? } diff --git a/python/src/namespace.rs b/python/src/namespace.rs index b4876ac5559..40752b660dc 100644 --- a/python/src/namespace.rs +++ b/python/src/namespace.rs @@ -10,12 +10,16 @@ use async_trait::async_trait; use bytes::Bytes; use lance_namespace::LanceNamespace as LanceNamespaceTrait; use lance_namespace::models::{ - CreateTableVersionRequest, CreateTableVersionResponse, DescribeTableVersionRequest, - DescribeTableVersionResponse, ListTableVersionsRequest, ListTableVersionsResponse, + CreateTableVersionRequest, CreateTableVersionResponse, DescribeTableRequest, + DescribeTableResponse, DescribeTableVersionRequest, DescribeTableVersionResponse, + ListTableVersionsRequest, ListTableVersionsResponse, }; use lance_namespace_impls::RestNamespaceBuilder; use lance_namespace_impls::{ConnectBuilder, RestAdapter, RestAdapterConfig, RestAdapterHandle}; -use lance_namespace_impls::{DirectoryNamespaceBuilder, DynamicContextProvider, OperationInfo}; +use lance_namespace_impls::{ + DirectoryNamespace, DirectoryNamespaceBuilder, DynamicContextProvider, OperationInfo, + RestNamespace, +}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict}; use pythonize::{depythonize, pythonize}; @@ -104,7 +108,7 @@ fn dict_to_hashmap(dict: &Bound<'_, PyDict>) -> PyResult /// Python wrapper for DirectoryNamespace #[pyclass(name = "PyDirectoryNamespace", module = "lance.lance")] pub struct PyDirectoryNamespace { - pub(crate) inner: Arc, + pub(crate) inner: Arc, } #[pymethods] @@ -365,12 +369,31 @@ impl PyDirectoryNamespace { .infer_error()?; pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } + + // Operation metrics methods + + /// Retrieve operation metrics as a dictionary. + /// + /// Returns a dict where keys are operation names (e.g., "list_tables", "describe_table") + /// and values are the number of times each operation was called. + /// + /// Returns an empty dict if `ops_metrics_enabled` was false when creating the namespace. + fn retrieve_ops_metrics(&self) -> HashMap { + self.inner.retrieve_ops_metrics() + } + + /// Reset all operation metrics counters to zero. + /// + /// Does nothing if `ops_metrics_enabled` was false when creating the namespace. + fn reset_ops_metrics(&self) { + self.inner.reset_ops_metrics() + } } /// Python wrapper for RestNamespace #[pyclass(name = "PyRestNamespace", module = "lance.lance")] pub struct PyRestNamespace { - pub(crate) inner: Arc, + pub(crate) inner: Arc, } #[pymethods] @@ -640,6 +663,112 @@ impl PyRestNamespace { .infer_error()?; pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } + + // Operation metrics methods + + /// Retrieve operation metrics as a dictionary. + /// + /// Returns a dict where keys are operation names (e.g., "list_tables", "describe_table") + /// and values are the number of times each operation was called. + /// + /// Returns an empty dict if `ops_metrics_enabled` was false when creating the namespace. + fn retrieve_ops_metrics(&self) -> HashMap { + self.inner.retrieve_ops_metrics() + } + + /// Reset all operation metrics counters to zero. + /// + /// Does nothing if `ops_metrics_enabled` was false when creating the namespace. + fn reset_ops_metrics(&self) { + self.inner.reset_ops_metrics() + } +} + +/// Get or create the DictWithModelDump class in Python. +/// This class acts like a dict but also has model_dump() method. +/// This allows it to work with both: +/// - depythonize (which expects a dict/Mapping) +/// - Python code that calls .model_dump() (like DirectoryNamespace wrapper) +fn get_dict_with_model_dump_class(py: Python<'_>) -> PyResult> { + // Use a module-level cache via __builtins__ + let builtins = py.import("builtins")?; + if builtins.hasattr("_DictWithModelDump")? { + return builtins.getattr("_DictWithModelDump"); + } + + // Create the class using exec + let locals = PyDict::new(py); + py.run( + c"class DictWithModelDump(dict): + def model_dump(self): + return dict(self)", + None, + Some(&locals), + )?; + let class = locals.get_item("DictWithModelDump")?.ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err("Failed to create DictWithModelDump class") + })?; + + // Cache it + builtins.setattr("_DictWithModelDump", &class)?; + Ok(class) +} + +/// Helper to call a Python namespace method with JSON serialization. +/// For methods that take a request and return a response. +/// Uses DictWithModelDump to pass a dict that also has model_dump() method, +/// making it compatible with both depythonize and Python wrappers. +async fn call_py_method( + py_namespace: Arc>, + method_name: &'static str, + request: Req, +) -> lance_core::Result +where + Req: serde::Serialize + Send + 'static, + Resp: serde::de::DeserializeOwned + Send + 'static, +{ + let request_json = serde_json::to_string(&request).map_err(|e| { + lance_core::Error::io(format!( + "Failed to serialize request for {}: {}", + method_name, e + )) + })?; + + let response_json = tokio::task::spawn_blocking(move || { + Python::attach(|py| { + let json_module = py.import("json")?; + let request_dict = json_module.call_method1("loads", (&request_json,))?; + + // Wrap dict in DictWithModelDump so it works with both depythonize and .model_dump() + let dict_class = get_dict_with_model_dump_class(py)?; + let request_arg = dict_class.call1((request_dict,))?; + + // Call the Python method + let result = py_namespace.call_method1(py, method_name, (request_arg,))?; + + // Convert response to dict, then to JSON + // Pydantic models have model_dump() method + let result_dict = if result.bind(py).hasattr("model_dump")? { + result.call_method0(py, "model_dump")? + } else { + result + }; + let response_json: String = json_module + .call_method1("dumps", (result_dict,))? + .extract()?; + Ok::<_, PyErr>(response_json) + }) + }) + .await + .map_err(|e| lance_core::Error::io(format!("Task join error for {}: {}", method_name, e)))? + .map_err(|e: PyErr| lance_core::Error::io(format!("Python error in {}: {}", method_name, e)))?; + + serde_json::from_str(&response_json).map_err(|e| { + lance_core::Error::io(format!( + "Failed to deserialize response from {}: {}", + method_name, e + )) + }) } /// Wrapper that allows any Python object implementing LanceNamespace protocol @@ -692,163 +821,32 @@ impl LanceNamespaceTrait for PyLanceNamespace { self.namespace_id.clone() } + async fn describe_table( + &self, + request: DescribeTableRequest, + ) -> lance_core::Result { + call_py_method(self.py_namespace.clone(), "describe_table", request).await + } + async fn describe_table_version( &self, request: DescribeTableVersionRequest, ) -> lance_core::Result { - // Clone the Arc (doesn't need GIL) to pass to spawn_blocking - let py_namespace = self.py_namespace.clone(); - let request_json = serde_json::to_string(&request).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to serialize request: {}", - e - )))) - })?; - - let response_json = tokio::task::spawn_blocking(move || { - Python::attach(|py| { - let result = - py_namespace.call_method1(py, "describe_table_version_json", (request_json,)); - - match result { - Ok(response_py) => { - let response_str: String = response_py.extract(py).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to extract response string: {}", - e - )))) - })?; - Ok(response_str) - } - Err(e) => Err(lance_core::Error::io_source(Box::new( - std::io::Error::other(format!( - "Failed to call describe_table_version_json: {}", - e - )), - ))), - } - }) - }) - .await - .map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Task join error: {}", - e - )))) - })??; - - serde_json::from_str(&response_json).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to deserialize response: {}", - e - )))) - }) + call_py_method(self.py_namespace.clone(), "describe_table_version", request).await } async fn create_table_version( &self, request: CreateTableVersionRequest, ) -> lance_core::Result { - // Clone the Arc (doesn't need GIL) to pass to spawn_blocking - let py_namespace = self.py_namespace.clone(); - let request_json = serde_json::to_string(&request).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to serialize request: {}", - e - )))) - })?; - - let response_json = tokio::task::spawn_blocking(move || { - Python::attach(|py| { - let result = - py_namespace.call_method1(py, "create_table_version_json", (request_json,)); - - match result { - Ok(response_py) => { - let response_str: String = response_py.extract(py).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to extract response string: {}", - e - )))) - })?; - Ok(response_str) - } - Err(e) => Err(lance_core::Error::io_source(Box::new( - std::io::Error::other(format!( - "Failed to call create_table_version_json: {}", - e - )), - ))), - } - }) - }) - .await - .map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Task join error: {}", - e - )))) - })??; - - serde_json::from_str(&response_json).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to deserialize response: {}", - e - )))) - }) + call_py_method(self.py_namespace.clone(), "create_table_version", request).await } async fn list_table_versions( &self, request: ListTableVersionsRequest, ) -> lance_core::Result { - // Clone the Arc (doesn't need GIL) to pass to spawn_blocking - let py_namespace = self.py_namespace.clone(); - let request_json = serde_json::to_string(&request).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to serialize request: {}", - e - )))) - })?; - - let response_json = tokio::task::spawn_blocking(move || { - Python::attach(|py| { - let result = - py_namespace.call_method1(py, "list_table_versions_json", (request_json,)); - - match result { - Ok(response_py) => { - let response_str: String = response_py.extract(py).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to extract response string: {}", - e - )))) - })?; - Ok(response_str) - } - Err(e) => Err(lance_core::Error::io_source(Box::new( - std::io::Error::other(format!( - "Failed to call list_table_versions_json: {}", - e - )), - ))), - } - }) - }) - .await - .map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Task join error: {}", - e - )))) - })??; - - serde_json::from_str(&response_json).map_err(|e| { - lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to deserialize response: {}", - e - )))) - }) + call_py_method(self.py_namespace.clone(), "list_table_versions", request).await } } @@ -864,37 +862,39 @@ impl LanceNamespaceTrait for PyLanceNamespace { /// are wrapped with PyLanceNamespace to call through Python. pub fn extract_namespace_arc( py: Python<'_>, - ns: &Bound<'_, PyAny>, + namespace_client: &Bound<'_, PyAny>, ) -> PyResult> { // Direct PyO3 class - if let Ok(dir_ns) = ns.downcast::() { - return Ok(dir_ns.borrow().inner.clone()); + if let Ok(dir_namespace_client) = namespace_client.downcast::() { + return Ok(dir_namespace_client.borrow().inner.clone() as Arc); } - if let Ok(rest_ns) = ns.downcast::() { - return Ok(rest_ns.borrow().inner.clone()); + if let Ok(rest_namespace_client) = namespace_client.downcast::() { + return Ok(rest_namespace_client.borrow().inner.clone() as Arc); } // Python wrapper class - check if it's the exact wrapper class - if let Ok(inner) = ns.getattr("_inner") { - let type_name = ns + if let Ok(inner) = namespace_client.getattr("_inner") { + let type_name = namespace_client .get_type() .name() .map(|n| n.to_string()) .unwrap_or_default(); if type_name == "DirectoryNamespace" { - if let Ok(dir_ns) = inner.downcast::() { - return Ok(dir_ns.borrow().inner.clone()); + if let Ok(dir_namespace_client) = inner.downcast::() { + return Ok( + dir_namespace_client.borrow().inner.clone() as Arc + ); } } else if type_name == "RestNamespace" - && let Ok(rest_ns) = inner.downcast::() + && let Ok(rest_namespace_client) = inner.downcast::() { - return Ok(rest_ns.borrow().inner.clone()); + return Ok(rest_namespace_client.borrow().inner.clone() as Arc); } } // Custom Python implementation or subclass - wrap with PyLanceNamespace - PyLanceNamespace::create_arc(py, ns) + PyLanceNamespace::create_arc(py, namespace_client) } /// Python wrapper for REST adapter server @@ -911,21 +911,21 @@ impl PyRestAdapter { /// Default port is 2333 per REST spec. Use port 0 to let OS assign an ephemeral port. /// Use `port` property after `start()` to get the actual port. #[new] - #[pyo3(signature = (namespace_impl, namespace_properties, session = None, host = None, port = None))] + #[pyo3(signature = (namespace_client_impl, namespace_client_properties, session = None, host = None, port = None))] fn new( - namespace_impl: String, - namespace_properties: Option<&Bound<'_, PyDict>>, + namespace_client_impl: String, + namespace_client_properties: Option<&Bound<'_, PyDict>>, session: Option<&Bound<'_, Session>>, host: Option, port: Option, ) -> PyResult { let mut props = HashMap::new(); - if let Some(dict) = namespace_properties { + if let Some(dict) = namespace_client_properties { props = dict_to_hashmap(dict)?; } - let mut builder = ConnectBuilder::new(namespace_impl); + let mut builder = ConnectBuilder::new(namespace_client_impl); for (k, v) in props { builder = builder.property(k, v); } diff --git a/python/src/storage_options.rs b/python/src/storage_options.rs index ca37c984243..15f3aa2ce17 100644 --- a/python/src/storage_options.rs +++ b/python/src/storage_options.rs @@ -4,154 +4,11 @@ use std::collections::HashMap; use std::sync::Arc; -use async_trait::async_trait; -use lance_io::object_store::{StorageOptionsAccessor, StorageOptionsProvider}; +use lance_io::object_store::StorageOptionsAccessor; use pyo3::prelude::*; -use pyo3::types::PyDict; use crate::rt; -/// Internal wrapper for Python storage options providers -/// -/// This is not exposed to Python. Users pass their Python objects directly -/// to dataset functions, and we wrap them internally with this struct. -pub struct PyStorageOptionsProvider { - /// The Python object implementing get_storage_options() - inner: Py, -} - -impl std::fmt::Debug for PyStorageOptionsProvider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Note: We can't call provider_id() here because this is PyStorageOptionsProvider, - // not PyStorageOptionsProviderWrapper. Just use a simple format. - write!(f, "PyStorageOptionsProvider") - } -} - -impl Clone for PyStorageOptionsProvider { - fn clone(&self) -> Self { - Python::attach(|py| Self { - inner: self.inner.clone_ref(py), - }) - } -} - -impl PyStorageOptionsProvider { - pub fn new(obj: &Bound<'_, PyAny>) -> PyResult { - // Verify the object has a fetch_storage_options method - if !obj.hasattr("fetch_storage_options")? { - return Err(pyo3::exceptions::PyTypeError::new_err( - "StorageOptionsProvider must implement fetch_storage_options() method", - )); - } - Ok(Self { - inner: obj.clone().unbind(), - }) - } -} - -/// Rust wrapper that implements StorageOptionsProvider trait for Python objects -pub struct PyStorageOptionsProviderWrapper { - py_provider: PyStorageOptionsProvider, -} - -impl std::fmt::Debug for PyStorageOptionsProviderWrapper { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.provider_id()) - } -} - -impl std::fmt::Display for PyStorageOptionsProviderWrapper { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.provider_id()) - } -} - -impl PyStorageOptionsProviderWrapper { - pub fn new(py_provider: PyStorageOptionsProvider) -> Self { - Self { py_provider } - } -} - -#[async_trait] -impl StorageOptionsProvider for PyStorageOptionsProviderWrapper { - async fn fetch_storage_options(&self) -> lance_core::Result>> { - // Call Python method from async context - let py_provider = self.py_provider.clone(); - - rt().runtime - .spawn_blocking(move || { - Python::attach(|py| { - // Call the Python fetch_storage_options method - let result = py_provider - .inner - .bind(py) - .call_method0("fetch_storage_options") - .map_err(|e| lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to call fetch_storage_options: {}", - e - )))))?; - - // If result is None, return None - if result.is_none() { - return Ok(None); - } - - // Extract the result dict - should be a flat Map - let result_dict = result.downcast::().map_err(|_| { - lance_core::Error::invalid_input_source("fetch_storage_options() must return None or a dict of string key-value pairs" - .into()) - })?; - - // Convert all entries to HashMap - let mut storage_options = HashMap::new(); - for (key, value) in result_dict.iter() { - let key_str: String = - key.extract().map_err(|e| lance_core::Error::invalid_input_source(format!("storage option keys must be strings: {}", e).into()))?; - let value_str: String = - value - .extract() - .map_err(|e| lance_core::Error::invalid_input_source(format!("storage option values must be strings: {}", e) - .into()))?; - storage_options.insert(key_str, value_str); - } - - Ok(Some(storage_options)) - }) - }) - .await - .map_err(|e| lance_core::Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to call Python fetch_storage_options: {}", - e - )))))? - } - - fn provider_id(&self) -> String { - Python::attach(|py| { - // Call provider_id() method on the Python object - // This should always succeed since StorageOptionsProvider.provider_id() has a default implementation - let obj = self.py_provider.inner.bind(py); - obj.call_method0("provider_id") - .and_then(|result| result.extract::()) - .unwrap_or_else(|e| { - panic!( - "Failed to call provider_id() on Python StorageOptionsProvider: {}", - e - ) - }) - }) - } -} - -/// Convert a Python object to an Arc -/// This is the main entry point for converting Python storage options providers to Rust -pub fn py_object_to_storage_options_provider( - py_obj: &Bound<'_, PyAny>, -) -> PyResult> { - let py_provider = PyStorageOptionsProvider::new(py_obj)?; - Ok(Arc::new(PyStorageOptionsProviderWrapper::new(py_provider))) -} - /// Python wrapper for StorageOptionsAccessor /// /// This wraps a Rust StorageOptionsAccessor and exposes it to Python. @@ -181,34 +38,6 @@ impl PyStorageOptionsAccessor { } } - /// Create an accessor with a dynamic provider (no initial options) - /// - /// The refresh offset is extracted from storage options using the `refresh_offset_millis` key. - #[staticmethod] - fn with_provider(provider: &Bound<'_, PyAny>) -> PyResult { - let rust_provider = py_object_to_storage_options_provider(provider)?; - Ok(Self { - inner: Arc::new(StorageOptionsAccessor::with_provider(rust_provider)), - }) - } - - /// Create an accessor with initial options and a dynamic provider - /// - /// The refresh offset is extracted from initial_options using the `refresh_offset_millis` key. - #[staticmethod] - fn with_initial_and_provider( - initial_options: HashMap, - provider: &Bound<'_, PyAny>, - ) -> PyResult { - let rust_provider = py_object_to_storage_options_provider(provider)?; - Ok(Self { - inner: Arc::new(StorageOptionsAccessor::with_initial_and_provider( - initial_options, - rust_provider, - )), - }) - } - /// Get current valid storage options fn get_storage_options(&self, py: Python<'_>) -> PyResult> { let accessor = self.inner.clone(); @@ -247,31 +76,17 @@ impl PyStorageOptionsAccessor { } } -/// Create a StorageOptionsAccessor from Python parameters +/// Create a StorageOptionsAccessor from storage options /// -/// This handles the conversion from Python types to Rust StorageOptionsAccessor. -/// The refresh offset is extracted from storage_options using the `refresh_offset_millis` key. +/// This creates an accessor with static options only. #[allow(dead_code)] -pub fn create_accessor_from_python( +pub fn create_accessor_from_storage_options( storage_options: Option>, - storage_options_provider: Option<&Bound<'_, PyAny>>, ) -> PyResult>> { - match (storage_options, storage_options_provider) { - (Some(opts), Some(provider)) => { - let rust_provider = py_object_to_storage_options_provider(provider)?; - Ok(Some(Arc::new( - StorageOptionsAccessor::with_initial_and_provider(opts, rust_provider), - ))) - } - (None, Some(provider)) => { - let rust_provider = py_object_to_storage_options_provider(provider)?; - Ok(Some(Arc::new(StorageOptionsAccessor::with_provider( - rust_provider, - )))) - } - (Some(opts), None) => Ok(Some(Arc::new(StorageOptionsAccessor::with_static_options( + match storage_options { + Some(opts) => Ok(Some(Arc::new(StorageOptionsAccessor::with_static_options( opts, )))), - (None, None) => Ok(None), + None => Ok(None), } } diff --git a/python/uv.lock b/python/uv.lock index b589925acf0..d4158924cab 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", @@ -2479,11 +2479,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.20.0" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] diff --git a/rust/lance-datafusion/src/substrait.rs b/rust/lance-datafusion/src/substrait.rs index 8375c49abb9..1c465fcae4a 100644 --- a/rust/lance-datafusion/src/substrait.rs +++ b/rust/lance-datafusion/src/substrait.rs @@ -1127,4 +1127,40 @@ mod tests { agg.aggregates[1].schema_name() ); } + + // ==================== LIKE and starts_with tests ==================== + + #[tokio::test] + async fn test_substrait_roundtrip_like() { + use datafusion::logical_expr::Like; + + let schema = Schema::new(vec![Field::new("name", DataType::Utf8, true)]); + + let like_expr = Expr::Like(Like { + negated: false, + expr: Box::new(Expr::Column(Column::new_unqualified("name"))), + pattern: Box::new(Expr::Literal( + ScalarValue::Utf8(Some("test%".to_string())), + None, + )), + escape_char: None, + case_insensitive: false, + }); + + assert_substrait_roundtrip(schema, like_expr).await; + } + + #[tokio::test] + async fn test_substrait_roundtrip_starts_with() { + use datafusion::functions::string::starts_with; + + let schema = Schema::new(vec![Field::new("name", DataType::Utf8, true)]); + + let starts_with_expr = starts_with().call(vec![ + Expr::Column(Column::new_unqualified("name")), + Expr::Literal(ScalarValue::Utf8(Some("prefix".to_string())), None), + ]); + + assert_substrait_roundtrip(schema, starts_with_expr).await; + } } diff --git a/rust/lance-io/src/object_store/storage_options.rs b/rust/lance-io/src/object_store/storage_options.rs index b16281ea4be..7cff81ef213 100644 --- a/rust/lance-io/src/object_store/storage_options.rs +++ b/rust/lance-io/src/object_store/storage_options.rs @@ -94,7 +94,7 @@ pub trait StorageOptionsProvider: Send + Sync + fmt::Debug { /// StorageOptionsProvider implementation that fetches options from a LanceNamespace pub struct LanceNamespaceStorageOptionsProvider { - namespace: Arc, + namespace_client: Arc, table_id: Vec, } @@ -114,11 +114,11 @@ impl LanceNamespaceStorageOptionsProvider { /// Create a new LanceNamespaceStorageOptionsProvider /// /// # Arguments - /// * `namespace` - The namespace implementation to fetch storage options from + /// * `namespace_client` - The namespace implementation to fetch storage options from /// * `table_id` - The table identifier - pub fn new(namespace: Arc, table_id: Vec) -> Self { + pub fn new(namespace_client: Arc, table_id: Vec) -> Self { Self { - namespace, + namespace_client, table_id, } } @@ -132,20 +132,24 @@ impl StorageOptionsProvider for LanceNamespaceStorageOptionsProvider { ..Default::default() }; - let response = self.namespace.describe_table(request).await.map_err(|e| { - Error::io_source(Box::new(std::io::Error::other(format!( - "Failed to fetch storage options: {}", - e - )))) - })?; + let response = self + .namespace_client + .describe_table(request) + .await + .map_err(|e| { + Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to fetch storage options: {}", + e + )))) + })?; Ok(response.storage_options) } fn provider_id(&self) -> String { format!( - "LanceNamespaceStorageOptionsProvider {{ namespace: {}, table_id: {:?} }}", - self.namespace.namespace_id(), + "LanceNamespaceStorageOptionsProvider {{ namespace_client: {}, table_id: {:?} }}", + self.namespace_client.namespace_id(), self.table_id ) } @@ -310,14 +314,13 @@ impl StorageOptionsAccessor { } } - // If no provider, return initial options or error + // If no provider, return initial options or use defaults let Some(provider) = &self.provider else { return if let Some(initial) = &self.initial_options { Ok(Some(super::StorageOptions(initial.clone()))) } else { - Err(Error::io_source(Box::new(std::io::Error::other( - "No storage options available", - )))) + // No provider and no initial options - use default credentials + Ok(Some(super::StorageOptions(HashMap::new()))) }; }; @@ -333,7 +336,6 @@ impl StorageOptionsAccessor { { return Ok(Some(super::StorageOptions(cached_opts.options.clone()))); } - log::debug!( "Refreshing storage options from provider: {}", provider.provider_id() @@ -347,13 +349,18 @@ impl StorageOptionsAccessor { })?; let Some(options) = storage_options_map else { - // Provider returned None, fall back to initial options + // Provider returned None, fall back to initial options or use defaults if let Some(initial) = &self.initial_options { return Ok(Some(super::StorageOptions(initial.clone()))); } - return Err(Error::io_source(Box::new(std::io::Error::other( - "Provider returned no storage options", - )))); + // Provider returned None and no initial options - use default credentials + // This is valid when namespace doesn't vend credentials (e.g., directory namespace + // where environment credentials are used) + log::debug!( + "Provider {} returned no storage options, using default credentials", + provider.provider_id() + ); + return Ok(Some(super::StorageOptions(HashMap::new()))); }; let expires_at_millis = options diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index c0ee3f9a713..44467838118 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -8,6 +8,7 @@ pub mod manifest; +use arrow::array::Float32Array; use arrow::record_batch::RecordBatchIterator; use arrow_ipc::reader::StreamReader; use async_trait::async_trait; @@ -18,7 +19,9 @@ use lance::dataset::transaction::{Operation, Transaction}; use lance::dataset::{Dataset, WriteMode, WriteParams}; use lance::index::{DatasetIndexExt, IndexParams, vector::VectorIndexParams}; use lance::session::Session; -use lance_index::scalar::{BuiltinIndexType, InvertedIndexParams, ScalarIndexParams}; +use lance_index::scalar::{ + BuiltinIndexType, FullTextSearchQuery, InvertedIndexParams, ScalarIndexParams, +}; use lance_index::vector::{ bq::RQBuildParams, hnsw::builder::HnswBuildParams, ivf::IvfBuildParams, pq::PQBuildParams, sq::builder::SQBuildParams, @@ -31,7 +34,7 @@ use object_store::path::Path; use object_store::{Error as ObjectStoreError, ObjectStore as OSObjectStore, PutMode, PutOptions}; use std::collections::HashMap; use std::io::Cursor; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use crate::context::DynamicContextProvider; use lance_namespace::models::{ @@ -46,8 +49,8 @@ use lance_namespace::models::{ DropTableIndexRequest, DropTableIndexResponse, DropTableRequest, DropTableResponse, Identity, IndexContent, ListNamespacesRequest, ListNamespacesResponse, ListTableIndicesRequest, ListTableIndicesResponse, ListTableVersionsRequest, ListTableVersionsResponse, - ListTablesRequest, ListTablesResponse, NamespaceExistsRequest, TableExistsRequest, - TableVersion, + ListTablesRequest, ListTablesResponse, NamespaceExistsRequest, QueryTableRequest, + TableExistsRequest, TableVersion, }; use lance_core::Result; @@ -59,6 +62,36 @@ use crate::credentials::{ CredentialVendor, create_credential_vendor_for_location, has_credential_vendor_config, }; +/// Thread-safe metrics tracker for namespace operations. +/// +/// Tracks the count of each API operation when `ops_metrics_enabled` is true. +/// Use `retrieve()` to get a snapshot of all operation counts. +#[derive(Debug, Default)] +pub struct OpsMetrics { + counters: Mutex>, +} + +impl OpsMetrics { + /// Increment the counter for an operation. + pub fn increment(&self, operation: &str) { + if let Ok(mut counters) = self.counters.lock() { + *counters.entry(operation.to_string()).or_insert(0) += 1; + } + } + + /// Get a snapshot of all operation counts. + pub fn retrieve(&self) -> HashMap { + self.counters.lock().map(|c| c.clone()).unwrap_or_default() + } + + /// Reset all counters to zero. + pub fn reset(&self) { + if let Ok(mut counters) = self.counters.lock() { + counters.clear(); + } + } +} + /// Result of checking table status atomically. /// /// This struct captures the state of a table directory in a single snapshot, @@ -150,6 +183,16 @@ pub struct DirectoryNamespaceBuilder { credential_vendor_properties: HashMap, context_provider: Option>, commit_retries: Option, + /// When true, returns input storage options in describe_table/declare_table responses + /// when no credential vendor is configured. Useful for testing. Default: false. + vend_input_storage_options: bool, + /// When set, adds expires_at_millis to vended storage options. The value is calculated + /// as current_time_millis + this interval. This allows clients to know when to refresh + /// credentials by calling describe_table again. Only effective when vend_input_storage_options + /// is true. + vend_input_storage_options_refresh_interval_millis: Option, + /// When true, tracks operation metrics. Default: false. + ops_metrics_enabled: bool, } impl std::fmt::Debug for DirectoryNamespaceBuilder { @@ -175,6 +218,15 @@ impl std::fmt::Debug for DirectoryNamespaceBuilder { "context_provider", &self.context_provider.as_ref().map(|_| "Some(...)"), ) + .field( + "vend_input_storage_options", + &self.vend_input_storage_options, + ) + .field( + "vend_input_storage_options_refresh_interval_millis", + &self.vend_input_storage_options_refresh_interval_millis, + ) + .field("ops_metrics_enabled", &self.ops_metrics_enabled) .finish() } } @@ -198,6 +250,9 @@ impl DirectoryNamespaceBuilder { credential_vendor_properties: HashMap::new(), context_provider: None, commit_retries: None, + vend_input_storage_options: false, + vend_input_storage_options_refresh_interval_millis: None, + ops_metrics_enabled: false, } } @@ -389,6 +444,23 @@ impl DirectoryNamespaceBuilder { .get("commit_retries") .and_then(|v| v.parse::().ok()); + // Extract vend_input_storage_options (default: false) + let vend_input_storage_options = properties + .get("vend_input_storage_options") + .and_then(|v| v.parse::().ok()) + .unwrap_or(false); + + // Extract vend_input_storage_options_refresh_interval_millis (optional) + let vend_input_storage_options_refresh_interval_millis = properties + .get("vend_input_storage_options_refresh_interval_millis") + .and_then(|v| v.parse::().ok()); + + // Extract ops_metrics_enabled (default: false) + let ops_metrics_enabled = properties + .get("ops_metrics_enabled") + .and_then(|v| v.parse::().ok()) + .unwrap_or(false); + Ok(Self { root: root.trim_end_matches('/').to_string(), storage_options, @@ -401,6 +473,9 @@ impl DirectoryNamespaceBuilder { credential_vendor_properties, context_provider: None, commit_retries, + vend_input_storage_options, + vend_input_storage_options_refresh_interval_millis, + ops_metrics_enabled, }) } @@ -513,6 +588,50 @@ impl DirectoryNamespaceBuilder { self } + /// Enable or disable returning input storage options in responses. + /// + /// When enabled, `describe_table` and `declare_table` will return the storage + /// options passed to the builder when no credential vendor is configured. + /// This is useful for testing scenarios where you want to pass storage options + /// through to clients. + /// + /// Default is false (storage options are not returned unless credential vending is configured). + pub fn vend_input_storage_options(mut self, enabled: bool) -> Self { + self.vend_input_storage_options = enabled; + self + } + + /// Set the refresh interval for vended input storage options. + /// + /// When set, vended storage options will include an `expires_at_millis` field + /// calculated as `current_time_millis + interval_millis`. This allows clients + /// to know when to refresh credentials by calling `describe_table` again. + /// + /// This only has effect when `vend_input_storage_options` is enabled. + /// + /// # Arguments + /// + /// * `interval_millis` - The refresh interval in milliseconds + pub fn vend_input_storage_options_refresh_interval_millis( + mut self, + interval_millis: u64, + ) -> Self { + self.vend_input_storage_options_refresh_interval_millis = Some(interval_millis); + self + } + + /// Enable or disable operation metrics tracking. + /// + /// When enabled, the namespace will track how many times each API operation + /// is called. Use `retrieve_ops_metrics()` on the built namespace to get + /// the current counts. + /// + /// Default is false. + pub fn ops_metrics_enabled(mut self, enabled: bool) -> Self { + self.ops_metrics_enabled = enabled; + self + } + /// Build the DirectoryNamespace. /// /// # Returns @@ -575,6 +694,12 @@ impl DirectoryNamespaceBuilder { None }; + let ops_metrics = if self.ops_metrics_enabled { + Some(Arc::new(OpsMetrics::default())) + } else { + None + }; + Ok(DirectoryNamespace { root: self.root, storage_options: self.storage_options, @@ -587,6 +712,10 @@ impl DirectoryNamespaceBuilder { table_version_storage_enabled: self.table_version_storage_enabled, credential_vendor, context_provider: self.context_provider, + vend_input_storage_options: self.vend_input_storage_options, + vend_input_storage_options_refresh_interval_millis: self + .vend_input_storage_options_refresh_interval_millis, + ops_metrics, }) } @@ -668,6 +797,13 @@ pub struct DirectoryNamespace { /// Stored but not directly used in operations (available for future extensions). #[allow(dead_code)] context_provider: Option>, + /// When true, returns input storage options in responses when no credential vendor is configured. + vend_input_storage_options: bool, + /// Refresh interval in milliseconds for vended input storage options. + /// When set, expires_at_millis is added to storage options. + vend_input_storage_options_refresh_interval_millis: Option, + /// Operation metrics tracker, created when ops_metrics_enabled is true. + ops_metrics: Option>, } impl std::fmt::Debug for DirectoryNamespace { @@ -797,7 +933,8 @@ impl DirectoryNamespace { describe_req.id = id.clone(); describe_req.load_detailed_metadata = Some(false); - let describe_resp = self.describe_table(describe_req).await?; + // Use internal impl to avoid counting this as an external API call + let describe_resp = self.describe_table_impl(describe_req).await?; describe_resp.location.ok_or_else(|| { lance_core::Error::from(NamespaceError::TableNotFound { @@ -806,6 +943,180 @@ impl DirectoryNamespace { }) } + /// Internal describe_table implementation that doesn't record metrics. + /// Used by both the public describe_table (which records metrics) and + /// internal callers like resolve_table_location (which shouldn't). + async fn describe_table_impl( + &self, + request: DescribeTableRequest, + ) -> Result { + if let Some(ref manifest_ns) = self.manifest_ns { + match manifest_ns.describe_table(request.clone()).await { + Ok(mut response) => { + if let Some(ref table_uri) = response.table_uri { + // For backwards compatibility, only skip vending credentials when explicitly set to false + let vend = request.vend_credentials.unwrap_or(true); + let identity = request.identity.as_deref(); + response.storage_options = self + .get_storage_options_for_table(table_uri, vend, identity) + .await?; + } + // Set managed_versioning flag when table_version_tracking_enabled + if self.table_version_tracking_enabled { + response.managed_versioning = Some(true); + } + return Ok(response); + } + Err(_) + if self.dir_listing_enabled + && request.id.as_ref().is_some_and(|id| id.len() == 1) => + { + // Fall through to directory check only for single-level IDs + } + Err(e) => return Err(e), + } + } + + let table_name = Self::table_name_from_id(&request.id)?; + let table_uri = self.table_full_uri(&table_name); + + // Atomically check table existence and deregistration status + let status = self.check_table_status(&table_name).await; + + if !status.exists { + return Err(NamespaceError::TableNotFound { + message: table_name.to_string(), + } + .into()); + } + + if status.is_deregistered { + return Err(NamespaceError::InvalidTableState { + message: format!("Table is deregistered: {}", table_name), + } + .into()); + } + + let load_detailed_metadata = request.load_detailed_metadata.unwrap_or(false); + // For backwards compatibility, only skip vending credentials when explicitly set to false + let vend_credentials = request.vend_credentials.unwrap_or(true); + let identity = request.identity.as_deref(); + + // If not loading detailed metadata, return minimal response with just location + if !load_detailed_metadata { + let storage_options = self + .get_storage_options_for_table(&table_uri, vend_credentials, identity) + .await?; + return Ok(DescribeTableResponse { + table: Some(table_name), + namespace: request.id.as_ref().map(|id| { + if id.len() > 1 { + id[..id.len() - 1].to_vec() + } else { + vec![] + } + }), + location: Some(table_uri.clone()), + table_uri: Some(table_uri), + storage_options, + managed_versioning: if self.table_version_tracking_enabled { + Some(true) + } else { + None + }, + ..Default::default() + }); + } + + // Try to load the dataset to get real information + // Use DatasetBuilder with storage options to support S3 with custom endpoints + let mut builder = DatasetBuilder::from_uri(&table_uri); + if let Some(opts) = &self.storage_options { + builder = builder.with_storage_options(opts.clone()); + } + if let Some(sess) = &self.session { + builder = builder.with_session(sess.clone()); + } + match builder.load().await { + Ok(mut dataset) => { + // If a specific version is requested, checkout that version + if let Some(requested_version) = request.version { + dataset = dataset.checkout_version(requested_version as u64).await?; + } + + let version_info = dataset.version(); + let lance_schema = dataset.schema(); + let arrow_schema: arrow_schema::Schema = lance_schema.into(); + let json_schema = arrow_schema_to_json(&arrow_schema)?; + let storage_options = self + .get_storage_options_for_table(&table_uri, vend_credentials, identity) + .await?; + + // Convert BTreeMap to HashMap for the response + let metadata: std::collections::HashMap = + version_info.metadata.into_iter().collect(); + + Ok(DescribeTableResponse { + table: Some(table_name), + namespace: request.id.as_ref().map(|id| { + if id.len() > 1 { + id[..id.len() - 1].to_vec() + } else { + vec![] + } + }), + version: Some(version_info.version as i64), + location: Some(table_uri.clone()), + table_uri: Some(table_uri), + schema: Some(Box::new(json_schema)), + storage_options, + metadata: Some(metadata), + managed_versioning: if self.table_version_tracking_enabled { + Some(true) + } else { + None + }, + ..Default::default() + }) + } + Err(err) => { + // Use the reserved file status from the atomic check + if status.has_reserved_file { + let storage_options = self + .get_storage_options_for_table(&table_uri, vend_credentials, identity) + .await?; + Ok(DescribeTableResponse { + table: Some(table_name), + namespace: request.id.as_ref().map(|id| { + if id.len() > 1 { + id[..id.len() - 1].to_vec() + } else { + vec![] + } + }), + location: Some(table_uri.clone()), + table_uri: Some(table_uri), + storage_options, + managed_versioning: if self.table_version_tracking_enabled { + Some(true) + } else { + None + }, + ..Default::default() + }) + } else { + Err(NamespaceError::Internal { + message: format!( + "Table directory exists but cannot load dataset {}: {:?}", + table_name, err + ), + } + .into()) + } + } + } + } + async fn load_dataset( &self, table_uri: &str, @@ -1302,6 +1613,26 @@ impl DirectoryNamespace { let vended = vendor.vend_credentials(table_uri, identity).await?; return Ok(Some(vended.storage_options)); } + // When vend_input_storage_options is enabled and no credential vendor is configured, + // return the input storage options. This is useful for testing. + if self.vend_input_storage_options { + let mut options = self.storage_options.clone().unwrap_or_default(); + // Add expires_at_millis if refresh interval is configured + if let Some(refresh_interval_millis) = + self.vend_input_storage_options_refresh_interval_millis + { + let now_millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let expires_at_millis = now_millis + refresh_interval_millis; + options.insert( + "expires_at_millis".to_string(), + expires_at_millis.to_string(), + ); + } + return Ok(Some(options)); + } // When no credential vendor is configured, return None to avoid // leaking the namespace's own static credentials to clients. Ok(None) @@ -1441,6 +1772,35 @@ impl DirectoryNamespace { } Ok(deleted_count) } + + /// Retrieve a snapshot of operation metrics. + /// + /// Returns a HashMap where keys are operation names (e.g., "list_tables", "describe_table") + /// and values are the number of times each operation was called. + /// + /// Returns an empty HashMap if `ops_metrics_enabled` was false when building the namespace. + pub fn retrieve_ops_metrics(&self) -> HashMap { + self.ops_metrics + .as_ref() + .map(|m| m.retrieve()) + .unwrap_or_default() + } + + /// Reset all operation metrics counters to zero. + /// + /// Does nothing if `ops_metrics_enabled` was false when building the namespace. + pub fn reset_ops_metrics(&self) { + if let Some(ref metrics) = self.ops_metrics { + metrics.reset(); + } + } + + /// Increment the counter for an operation. + fn record_op(&self, operation: &str) { + if let Some(ref metrics) = self.ops_metrics { + metrics.increment(operation); + } + } } #[async_trait] @@ -1449,6 +1809,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: ListNamespacesRequest, ) -> Result { + self.record_op("list_namespaces"); if let Some(ref manifest_ns) = self.manifest_ns { return manifest_ns.list_namespaces(request).await; } @@ -1461,6 +1822,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: DescribeNamespaceRequest, ) -> Result { + self.record_op("describe_namespace"); if let Some(ref manifest_ns) = self.manifest_ns { return manifest_ns.describe_namespace(request).await; } @@ -1477,6 +1839,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: CreateNamespaceRequest, ) -> Result { + self.record_op("create_namespace"); if let Some(ref manifest_ns) = self.manifest_ns { return manifest_ns.create_namespace(request).await; } @@ -1496,6 +1859,7 @@ impl LanceNamespace for DirectoryNamespace { } async fn drop_namespace(&self, request: DropNamespaceRequest) -> Result { + self.record_op("drop_namespace"); if let Some(ref manifest_ns) = self.manifest_ns { return manifest_ns.drop_namespace(request).await; } @@ -1515,6 +1879,7 @@ impl LanceNamespace for DirectoryNamespace { } async fn namespace_exists(&self, request: NamespaceExistsRequest) -> Result<()> { + self.record_op("namespace_exists"); if let Some(ref manifest_ns) = self.manifest_ns { return manifest_ns.namespace_exists(request).await; } @@ -1531,6 +1896,7 @@ impl LanceNamespace for DirectoryNamespace { } async fn list_tables(&self, request: ListTablesRequest) -> Result { + self.record_op("list_tables"); // Validate that namespace ID is provided let namespace_id = request.id.as_ref().ok_or_else(|| { lance_core::Error::from(NamespaceError::InvalidInput { @@ -1605,174 +1971,12 @@ impl LanceNamespace for DirectoryNamespace { } async fn describe_table(&self, request: DescribeTableRequest) -> Result { - if let Some(ref manifest_ns) = self.manifest_ns { - match manifest_ns.describe_table(request.clone()).await { - Ok(mut response) => { - if let Some(ref table_uri) = response.table_uri { - // For backwards compatibility, only skip vending credentials when explicitly set to false - let vend = request.vend_credentials.unwrap_or(true); - let identity = request.identity.as_deref(); - response.storage_options = self - .get_storage_options_for_table(table_uri, vend, identity) - .await?; - } - // Set managed_versioning flag when table_version_tracking_enabled - if self.table_version_tracking_enabled { - response.managed_versioning = Some(true); - } - return Ok(response); - } - Err(_) - if self.dir_listing_enabled - && request.id.as_ref().is_some_and(|id| id.len() == 1) => - { - // Fall through to directory check only for single-level IDs - } - Err(e) => return Err(e), - } - } - - let table_name = Self::table_name_from_id(&request.id)?; - let table_uri = self.table_full_uri(&table_name); - - // Atomically check table existence and deregistration status - let status = self.check_table_status(&table_name).await; - - if !status.exists { - return Err(NamespaceError::TableNotFound { - message: table_name.to_string(), - } - .into()); - } - - if status.is_deregistered { - return Err(NamespaceError::InvalidTableState { - message: format!("Table is deregistered: {}", table_name), - } - .into()); - } - - let load_detailed_metadata = request.load_detailed_metadata.unwrap_or(false); - // For backwards compatibility, only skip vending credentials when explicitly set to false - let vend_credentials = request.vend_credentials.unwrap_or(true); - let identity = request.identity.as_deref(); - - // If not loading detailed metadata, return minimal response with just location - if !load_detailed_metadata { - let storage_options = self - .get_storage_options_for_table(&table_uri, vend_credentials, identity) - .await?; - return Ok(DescribeTableResponse { - table: Some(table_name), - namespace: request.id.as_ref().map(|id| { - if id.len() > 1 { - id[..id.len() - 1].to_vec() - } else { - vec![] - } - }), - location: Some(table_uri.clone()), - table_uri: Some(table_uri), - storage_options, - managed_versioning: if self.table_version_tracking_enabled { - Some(true) - } else { - None - }, - ..Default::default() - }); - } - - // Try to load the dataset to get real information - // Use DatasetBuilder with storage options to support S3 with custom endpoints - let mut builder = DatasetBuilder::from_uri(&table_uri); - if let Some(opts) = &self.storage_options { - builder = builder.with_storage_options(opts.clone()); - } - if let Some(sess) = &self.session { - builder = builder.with_session(sess.clone()); - } - match builder.load().await { - Ok(mut dataset) => { - // If a specific version is requested, checkout that version - if let Some(requested_version) = request.version { - dataset = dataset.checkout_version(requested_version as u64).await?; - } - - let version_info = dataset.version(); - let lance_schema = dataset.schema(); - let arrow_schema: arrow_schema::Schema = lance_schema.into(); - let json_schema = arrow_schema_to_json(&arrow_schema)?; - let storage_options = self - .get_storage_options_for_table(&table_uri, vend_credentials, identity) - .await?; - - // Convert BTreeMap to HashMap for the response - let metadata: std::collections::HashMap = - version_info.metadata.into_iter().collect(); - - Ok(DescribeTableResponse { - table: Some(table_name), - namespace: request.id.as_ref().map(|id| { - if id.len() > 1 { - id[..id.len() - 1].to_vec() - } else { - vec![] - } - }), - version: Some(version_info.version as i64), - location: Some(table_uri.clone()), - table_uri: Some(table_uri), - schema: Some(Box::new(json_schema)), - storage_options, - metadata: Some(metadata), - managed_versioning: if self.table_version_tracking_enabled { - Some(true) - } else { - None - }, - ..Default::default() - }) - } - Err(err) => { - // Use the reserved file status from the atomic check - if status.has_reserved_file { - let storage_options = self - .get_storage_options_for_table(&table_uri, vend_credentials, identity) - .await?; - Ok(DescribeTableResponse { - table: Some(table_name), - namespace: request.id.as_ref().map(|id| { - if id.len() > 1 { - id[..id.len() - 1].to_vec() - } else { - vec![] - } - }), - location: Some(table_uri.clone()), - table_uri: Some(table_uri), - storage_options, - managed_versioning: if self.table_version_tracking_enabled { - Some(true) - } else { - None - }, - ..Default::default() - }) - } else { - Err(NamespaceError::Internal { - message: format!( - "Table directory exists but cannot load dataset {}: {:?}", - table_name, err - ), - } - .into()) - } - } - } + self.record_op("describe_table"); + self.describe_table_impl(request).await } async fn table_exists(&self, request: TableExistsRequest) -> Result<()> { + self.record_op("table_exists"); if let Some(ref manifest_ns) = self.manifest_ns { match manifest_ns.table_exists(request.clone()).await { Ok(()) => return Ok(()), @@ -1809,6 +2013,7 @@ impl LanceNamespace for DirectoryNamespace { } async fn drop_table(&self, request: DropTableRequest) -> Result { + self.record_op("drop_table"); if let Some(ref manifest_ns) = self.manifest_ns { return manifest_ns.drop_table(request).await; } @@ -1838,6 +2043,7 @@ impl LanceNamespace for DirectoryNamespace { request: CreateTableRequest, request_data: Bytes, ) -> Result { + self.record_op("create_table"); if let Some(ref manifest_ns) = self.manifest_ns { return manifest_ns.create_table(request, request_data).await; } @@ -1911,6 +2117,7 @@ impl LanceNamespace for DirectoryNamespace { } async fn declare_table(&self, request: DeclareTableRequest) -> Result { + self.record_op("declare_table"); if let Some(ref manifest_ns) = self.manifest_ns { let mut response = manifest_ns.declare_table(request.clone()).await?; if let Some(ref location) = response.location { @@ -1997,6 +2204,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: lance_namespace::models::RegisterTableRequest, ) -> Result { + self.record_op("register_table"); // If manifest is enabled, delegate to manifest namespace if let Some(ref manifest_ns) = self.manifest_ns { return LanceNamespace::register_table(manifest_ns.as_ref(), request).await; @@ -2013,6 +2221,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: lance_namespace::models::DeregisterTableRequest, ) -> Result { + self.record_op("deregister_table"); // If manifest is enabled, delegate to manifest namespace if let Some(ref manifest_ns) = self.manifest_ns { return LanceNamespace::deregister_table(manifest_ns.as_ref(), request).await; @@ -2072,6 +2281,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: ListTableVersionsRequest, ) -> Result { + self.record_op("list_table_versions"); // When table_version_storage_enabled, query from __manifest if self.table_version_storage_enabled && let Some(ref manifest_ns) = self.manifest_ns @@ -2169,6 +2379,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: CreateTableVersionRequest, ) -> Result { + self.record_op("create_table_version"); let table_uri = self.resolve_table_location(&request.id).await?; let staging_manifest_path = &request.manifest_path; @@ -2303,6 +2514,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: DescribeTableVersionRequest, ) -> Result { + self.record_op("describe_table_version"); // When table_version_storage_enabled and a specific version is requested, // query from __manifest to avoid opening the entire dataset if self.table_version_storage_enabled @@ -2370,6 +2582,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: BatchDeleteTableVersionsRequest, ) -> Result { + self.record_op("batch_delete_table_versions"); // Single-table mode: use `id` (from path parameter) + `ranges` to delete // versions from one table. let ranges: Vec<(i64, i64)> = request @@ -2452,6 +2665,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: CreateTableIndexRequest, ) -> Result { + self.record_op("create_table_index"); let table_uri = self.resolve_table_location(&request.id).await?; let mut dataset = self .load_dataset(&table_uri, None, "create_table_index") @@ -2500,6 +2714,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: ListTableIndicesRequest, ) -> Result { + self.record_op("list_table_indices"); let table_uri = self.resolve_table_location(&request.id).await?; let dataset = self .load_dataset(&table_uri, request.version, "list_table_indices") @@ -2566,6 +2781,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: DescribeTableIndexStatsRequest, ) -> Result { + self.record_op("describe_table_index_stats"); let table_uri = self.resolve_table_location(&request.id).await?; let dataset = self .load_dataset(&table_uri, request.version, "describe_table_index_stats") @@ -2619,6 +2835,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: DescribeTransactionRequest, ) -> Result { + self.record_op("describe_transaction"); let mut request_id = request.id.ok_or_else(|| { lance_core::Error::from(NamespaceError::InvalidInput { message: "Transaction id must include table id and transaction identifier" @@ -2650,6 +2867,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: CreateTableIndexRequest, ) -> Result { + self.record_op("create_table_scalar_index"); let index_type = Self::parse_index_type(&request.index_type)?; if !index_type.is_scalar() { return Err(NamespaceError::InvalidInput { @@ -2671,6 +2889,7 @@ impl LanceNamespace for DirectoryNamespace { &self, request: DropTableIndexRequest, ) -> Result { + self.record_op("drop_table_index"); let table_uri = self.resolve_table_location(&request.id).await?; let index_name = request.index_name.as_deref().ok_or_else(|| { lance_core::Error::from(NamespaceError::InvalidInput { @@ -2726,6 +2945,240 @@ impl LanceNamespace for DirectoryNamespace { Ok(DropTableIndexResponse { transaction_id }) } + async fn query_table(&self, request: QueryTableRequest) -> Result { + use arrow::ipc::writer::FileWriter; + + self.record_op("query_table"); + let table_uri = self.resolve_table_location(&request.id).await?; + let dataset = self + .load_dataset(&table_uri, request.version, "query_table") + .await?; + + // Build scanner + let mut scanner = dataset.scan(); + + // Check if this is a vector search query + // vector is Box, not Option + let has_vector_query = request + .vector + .single_vector + .as_ref() + .map(|sv| !sv.is_empty()) + .unwrap_or(false) + || request + .vector + .multi_vector + .as_ref() + .map(|mv| !mv.is_empty()) + .unwrap_or(false); + + // Apply prefilter setting (must be set before nearest) + if let Some(prefilter) = request.prefilter { + scanner.prefilter(prefilter); + } + + // Apply vector search if query vector is provided + if has_vector_query { + let vector_column = request.vector_column.as_deref().unwrap_or("vector"); + + // Get the query vector(s) + let query_vector: Vec = request + .vector + .single_vector + .clone() + .or_else(|| { + request + .vector + .multi_vector + .as_ref() + .and_then(|mv| mv.first().cloned()) + }) + .unwrap_or_default(); + + if !query_vector.is_empty() { + let k = if request.k > 0 { + request.k as usize + } else { + 10 + }; + let query_array = Float32Array::from(query_vector); + scanner + .nearest(vector_column, &query_array, k) + .map_err(|e| NamespaceError::InvalidInput { + message: format!("Invalid vector search: {}", e), + })?; + + // Apply distance type if specified + if let Some(ref distance_type) = request.distance_type { + let metric = match distance_type.to_lowercase().as_str() { + "l2" | "euclidean" => MetricType::L2, + "cosine" => MetricType::Cosine, + "dot" | "inner_product" => MetricType::Dot, + "hamming" => MetricType::Hamming, + _ => { + return Err(NamespaceError::InvalidInput { + message: format!("Unknown distance type: {}", distance_type), + } + .into()); + } + }; + scanner.distance_metric(metric); + } + + // Apply nprobes if specified (maps to minimum_nprobes, matching lancedb behavior) + if let Some(nprobes) = request.nprobes { + scanner.minimum_nprobes(nprobes as usize); + } + + // Apply ef (HNSW search effort) if specified + if let Some(ef) = request.ef { + scanner.ef(ef as usize); + } + + // Apply refine_factor if specified + if let Some(refine_factor) = request.refine_factor { + scanner.refine(refine_factor as u32); + } + + // Apply distance bounds if specified + if request.lower_bound.is_some() || request.upper_bound.is_some() { + scanner.distance_range(request.lower_bound, request.upper_bound); + } + + // Apply use_index (inverse of bypass_vector_index) + if let Some(bypass) = request.bypass_vector_index { + scanner.use_index(!bypass); + } + + // Apply fast_search if specified + if request.fast_search == Some(true) { + scanner.fast_search(); + } + } + } + + // Apply full text search if specified + if let Some(ref fts_query) = request.full_text_query { + // Handle string_query (simple string FTS) + if let Some(ref string_query) = fts_query.string_query { + let mut fts = FullTextSearchQuery::new(string_query.query.clone()); + + // Apply column filter if specified + if let Some(ref columns) = string_query.columns + && !columns.is_empty() + { + fts = fts + .with_columns(columns) + .map_err(|e| NamespaceError::InvalidInput { + message: format!("Invalid FTS columns: {}", e), + })?; + } + + scanner + .full_text_search(fts) + .map_err(|e| NamespaceError::InvalidInput { + message: format!("Invalid full text search: {}", e), + })?; + } + // Note: structured_query would require more complex parsing + // For now, we only support string_query + } + + // Apply column projection if specified + if let Some(ref columns) = request.columns { + if let Some(ref column_names) = columns.column_names + && !column_names.is_empty() + { + scanner + .project(column_names) + .map_err(|e| NamespaceError::InvalidInput { + message: format!("Invalid column projection: {}", e), + })?; + } else if let Some(ref column_aliases) = columns.column_aliases + && !column_aliases.is_empty() + { + // column_aliases is HashMap where key is alias, value is SQL expression + let transform_pairs: Vec<(String, String)> = column_aliases + .iter() + .map(|(alias, sql)| (alias.clone(), sql.clone())) + .collect(); + scanner + .project_with_transform( + &transform_pairs + .iter() + .map(|(a, s)| (a.as_str(), s.as_str())) + .collect::>(), + ) + .map_err(|e| NamespaceError::InvalidInput { + message: format!("Invalid column alias expression: {}", e), + })?; + } + } + + // Apply filter if specified + if let Some(ref filter) = request.filter + && !filter.is_empty() + { + scanner + .filter(filter) + .map_err(|e| NamespaceError::InvalidInput { + message: format!("Invalid filter expression: {}", e), + })?; + } + + // Apply with_row_id if requested + if request.with_row_id == Some(true) { + scanner.with_row_id(); + } + + // Apply limit if specified (k is the number of results to return) + // k == 0 means no limit + // Note: For vector search, limit is already applied via nearest() + if !has_vector_query && request.k > 0 { + let offset = request.offset.map(|o| o as i64); + scanner.limit(Some(request.k as i64), offset).map_err(|e| { + NamespaceError::InvalidInput { + message: format!("Invalid limit/offset: {}", e), + } + })?; + } else if has_vector_query && request.offset.is_some() { + // For vector search, offset is handled separately + let offset = request.offset.map(|o| o as i64); + scanner + .limit(None, offset) + .map_err(|e| NamespaceError::InvalidInput { + message: format!("Invalid offset: {}", e), + })?; + } + + // Execute the scan and collect results + let batch = scanner + .try_into_batch() + .await + .map_err(|e| NamespaceError::Internal { + message: format!("Failed to execute query: {}", e), + })?; + + // Serialize to Arrow IPC file format + let schema = batch.schema(); + let mut buffer = Vec::new(); + { + let mut writer = FileWriter::try_new(&mut buffer, &schema).map_err(|e| { + NamespaceError::Internal { + message: format!("Failed to create IPC writer: {}", e), + } + })?; + writer.write(&batch).map_err(|e| NamespaceError::Internal { + message: format!("Failed to write batch to IPC: {}", e), + })?; + writer.finish().map_err(|e| NamespaceError::Internal { + message: format!("Failed to finish IPC writer: {}", e), + })?; + } + + Ok(Bytes::from(buffer)) + } + fn namespace_id(&self) -> String { format!("DirectoryNamespace {{ root: {:?} }}", self.root) } diff --git a/rust/lance-namespace-impls/src/lib.rs b/rust/lance-namespace-impls/src/lib.rs index a67aff1784d..7570e470b27 100644 --- a/rust/lance-namespace-impls/src/lib.rs +++ b/rust/lance-namespace-impls/src/lib.rs @@ -82,7 +82,9 @@ pub mod rest_adapter; // Re-export connect builder pub use connect::ConnectBuilder; pub use context::{DynamicContextProvider, OperationInfo}; -pub use dir::{DirectoryNamespace, DirectoryNamespaceBuilder, manifest::ManifestNamespace}; +pub use dir::{ + DirectoryNamespace, DirectoryNamespaceBuilder, OpsMetrics, manifest::ManifestNamespace, +}; // Re-export credential vending pub use credentials::{ diff --git a/rust/lance-namespace-impls/src/rest.rs b/rust/lance-namespace-impls/src/rest.rs index cd8a79cabed..3ef3221f377 100644 --- a/rust/lance-namespace-impls/src/rest.rs +++ b/rust/lance-namespace-impls/src/rest.rs @@ -7,6 +7,8 @@ use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; +use crate::OpsMetrics; + use async_trait::async_trait; use bytes::Bytes; use reqwest::header::{HeaderName, HeaderValue}; @@ -166,6 +168,8 @@ pub struct RestNamespaceBuilder { ssl_ca_cert: Option, assert_hostname: bool, context_provider: Option>, + /// When true, tracks operation metrics. Default: false. + ops_metrics_enabled: bool, } impl std::fmt::Debug for RestNamespaceBuilder { @@ -182,6 +186,7 @@ impl std::fmt::Debug for RestNamespaceBuilder { "context_provider", &self.context_provider.as_ref().map(|_| "Some(...)"), ) + .field("ops_metrics_enabled", &self.ops_metrics_enabled) .finish() } } @@ -205,6 +210,7 @@ impl RestNamespaceBuilder { ssl_ca_cert: None, assert_hostname: true, context_provider: None, + ops_metrics_enabled: false, } } @@ -282,6 +288,12 @@ impl RestNamespaceBuilder { .and_then(|v| v.parse::().ok()) .unwrap_or(true); + // Extract ops_metrics_enabled (default: false) + let ops_metrics_enabled = properties + .get("ops_metrics_enabled") + .and_then(|v| v.parse::().ok()) + .unwrap_or(false); + Ok(Self { uri, delimiter, @@ -291,6 +303,7 @@ impl RestNamespaceBuilder { ssl_ca_cert, assert_hostname, context_provider: None, + ops_metrics_enabled, }) } @@ -403,6 +416,18 @@ impl RestNamespaceBuilder { self } + /// Enable or disable operation metrics tracking. + /// + /// When enabled, the namespace will track how many times each API operation + /// is called. Use `retrieve_ops_metrics()` on the built namespace to get + /// the current counts. + /// + /// Default is false. + pub fn ops_metrics_enabled(mut self, enabled: bool) -> Self { + self.ops_metrics_enabled = enabled; + self + } + /// Build the RestNamespace. /// /// # Returns @@ -443,6 +468,8 @@ pub struct RestNamespace { delimiter: String, /// REST client that handles per-request header injection efficiently. rest_client: RestClient, + /// Operation metrics tracker, created when ops_metrics_enabled is true. + ops_metrics: Option>, } impl std::fmt::Debug for RestNamespace { @@ -494,9 +521,16 @@ impl RestNamespace { context_provider: builder.context_provider, }; + let ops_metrics = if builder.ops_metrics_enabled { + Some(Arc::new(OpsMetrics::default())) + } else { + None + }; + Self { delimiter: builder.delimiter, rest_client, + ops_metrics, } } @@ -751,6 +785,35 @@ impl RestNamespace { pub fn endpoint(&self) -> &str { self.rest_client.base_path() } + + /// Retrieve a snapshot of operation metrics. + /// + /// Returns a HashMap where keys are operation names (e.g., "list_tables", "describe_table") + /// and values are the number of times each operation was called. + /// + /// Returns an empty HashMap if `ops_metrics_enabled` was false when building the namespace. + pub fn retrieve_ops_metrics(&self) -> HashMap { + self.ops_metrics + .as_ref() + .map(|m| m.retrieve()) + .unwrap_or_default() + } + + /// Reset all operation metrics counters to zero. + /// + /// Does nothing if `ops_metrics_enabled` was false when building the namespace. + pub fn reset_ops_metrics(&self) { + if let Some(ref metrics) = self.ops_metrics { + metrics.reset(); + } + } + + /// Increment the counter for an operation. + fn record_op(&self, operation: &str) { + if let Some(ref metrics) = self.ops_metrics { + metrics.increment(operation); + } + } } #[async_trait] @@ -759,6 +822,7 @@ impl LanceNamespace for RestNamespace { &self, request: ListNamespacesRequest, ) -> Result { + self.record_op("list_namespaces"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/namespace/{}/list", encoded_id); @@ -780,6 +844,7 @@ impl LanceNamespace for RestNamespace { &self, request: DescribeNamespaceRequest, ) -> Result { + self.record_op("describe_namespace"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/namespace/{}/describe", encoded_id); @@ -792,6 +857,7 @@ impl LanceNamespace for RestNamespace { &self, request: CreateNamespaceRequest, ) -> Result { + self.record_op("create_namespace"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/namespace/{}/create", encoded_id); @@ -801,6 +867,7 @@ impl LanceNamespace for RestNamespace { } async fn drop_namespace(&self, request: DropNamespaceRequest) -> Result { + self.record_op("drop_namespace"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/namespace/{}/drop", encoded_id); @@ -810,6 +877,7 @@ impl LanceNamespace for RestNamespace { } async fn namespace_exists(&self, request: NamespaceExistsRequest) -> Result<()> { + self.record_op("namespace_exists"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/namespace/{}/exists", encoded_id); @@ -819,6 +887,7 @@ impl LanceNamespace for RestNamespace { } async fn list_tables(&self, request: ListTablesRequest) -> Result { + self.record_op("list_tables"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/namespace/{}/table/list", encoded_id); @@ -837,6 +906,7 @@ impl LanceNamespace for RestNamespace { } async fn describe_table(&self, request: DescribeTableRequest) -> Result { + self.record_op("describe_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/describe", encoded_id); @@ -856,6 +926,7 @@ impl LanceNamespace for RestNamespace { } async fn register_table(&self, request: RegisterTableRequest) -> Result { + self.record_op("register_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/register", encoded_id); @@ -865,6 +936,7 @@ impl LanceNamespace for RestNamespace { } async fn table_exists(&self, request: TableExistsRequest) -> Result<()> { + self.record_op("table_exists"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/exists", encoded_id); @@ -874,6 +946,7 @@ impl LanceNamespace for RestNamespace { } async fn drop_table(&self, request: DropTableRequest) -> Result { + self.record_op("drop_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/drop", encoded_id); @@ -886,6 +959,7 @@ impl LanceNamespace for RestNamespace { &self, request: DeregisterTableRequest, ) -> Result { + self.record_op("deregister_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/deregister", encoded_id); @@ -895,6 +969,7 @@ impl LanceNamespace for RestNamespace { } async fn count_table_rows(&self, request: CountTableRowsRequest) -> Result { + self.record_op("count_table_rows"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/count_rows", encoded_id); @@ -907,6 +982,7 @@ impl LanceNamespace for RestNamespace { request: CreateTableRequest, request_data: Bytes, ) -> Result { + self.record_op("create_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/create", encoded_id); @@ -921,6 +997,7 @@ impl LanceNamespace for RestNamespace { } async fn declare_table(&self, request: DeclareTableRequest) -> Result { + self.record_op("declare_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/declare", encoded_id); @@ -934,6 +1011,7 @@ impl LanceNamespace for RestNamespace { request: InsertIntoTableRequest, request_data: Bytes, ) -> Result { + self.record_op("insert_into_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/insert", encoded_id); @@ -958,6 +1036,7 @@ impl LanceNamespace for RestNamespace { request: MergeInsertIntoTableRequest, request_data: Bytes, ) -> Result { + self.record_op("merge_insert_into_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); @@ -1020,6 +1099,7 @@ impl LanceNamespace for RestNamespace { } async fn update_table(&self, request: UpdateTableRequest) -> Result { + self.record_op("update_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/update", encoded_id); @@ -1032,6 +1112,7 @@ impl LanceNamespace for RestNamespace { &self, request: DeleteFromTableRequest, ) -> Result { + self.record_op("delete_from_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/delete", encoded_id); @@ -1041,6 +1122,7 @@ impl LanceNamespace for RestNamespace { } async fn query_table(&self, request: QueryTableRequest) -> Result { + self.record_op("query_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/query", encoded_id); @@ -1086,6 +1168,7 @@ impl LanceNamespace for RestNamespace { &self, request: CreateTableIndexRequest, ) -> Result { + self.record_op("create_table_index"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/create_index", encoded_id); @@ -1098,6 +1181,7 @@ impl LanceNamespace for RestNamespace { &self, request: ListTableIndicesRequest, ) -> Result { + self.record_op("list_table_indices"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/index/list", encoded_id); @@ -1110,6 +1194,7 @@ impl LanceNamespace for RestNamespace { &self, request: DescribeTableIndexStatsRequest, ) -> Result { + self.record_op("describe_table_index_stats"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let index_name = request.index_name.as_deref().unwrap_or(""); @@ -1127,6 +1212,7 @@ impl LanceNamespace for RestNamespace { &self, request: DescribeTransactionRequest, ) -> Result { + self.record_op("describe_transaction"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/transaction/{}/describe", encoded_id); @@ -1139,6 +1225,7 @@ impl LanceNamespace for RestNamespace { &self, request: AlterTransactionRequest, ) -> Result { + self.record_op("alter_transaction"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/transaction/{}/alter", encoded_id); @@ -1151,6 +1238,7 @@ impl LanceNamespace for RestNamespace { &self, request: CreateTableIndexRequest, ) -> Result { + self.record_op("create_table_scalar_index"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/create_scalar_index", encoded_id); @@ -1163,6 +1251,7 @@ impl LanceNamespace for RestNamespace { &self, request: DropTableIndexRequest, ) -> Result { + self.record_op("drop_table_index"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let index_name = request.index_name.as_deref().unwrap_or(""); @@ -1177,6 +1266,7 @@ impl LanceNamespace for RestNamespace { } async fn list_all_tables(&self, request: ListTablesRequest) -> Result { + self.record_op("list_all_tables"); let path = "/v1/table"; let mut query = vec![("delimiter", self.delimiter.as_str())]; let page_token_str; @@ -1193,6 +1283,7 @@ impl LanceNamespace for RestNamespace { } async fn restore_table(&self, request: RestoreTableRequest) -> Result { + self.record_op("restore_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/restore", encoded_id); @@ -1202,6 +1293,7 @@ impl LanceNamespace for RestNamespace { } async fn rename_table(&self, request: RenameTableRequest) -> Result { + self.record_op("rename_table"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/rename", encoded_id); @@ -1214,6 +1306,7 @@ impl LanceNamespace for RestNamespace { &self, request: ListTableVersionsRequest, ) -> Result { + self.record_op("list_table_versions"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/version/list", encoded_id); @@ -1241,6 +1334,7 @@ impl LanceNamespace for RestNamespace { &self, request: CreateTableVersionRequest, ) -> Result { + self.record_op("create_table_version"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/version/create", encoded_id); @@ -1253,6 +1347,7 @@ impl LanceNamespace for RestNamespace { &self, request: DescribeTableVersionRequest, ) -> Result { + self.record_op("describe_table_version"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/version/describe", encoded_id); @@ -1265,6 +1360,7 @@ impl LanceNamespace for RestNamespace { &self, request: BatchDeleteTableVersionsRequest, ) -> Result { + self.record_op("batch_delete_table_versions"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/version/delete", encoded_id); @@ -1277,6 +1373,7 @@ impl LanceNamespace for RestNamespace { &self, request: UpdateTableSchemaMetadataRequest, ) -> Result { + self.record_op("update_table_schema_metadata"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/schema_metadata/update", encoded_id); @@ -1301,6 +1398,7 @@ impl LanceNamespace for RestNamespace { &self, request: GetTableStatsRequest, ) -> Result { + self.record_op("get_table_stats"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/stats", encoded_id); @@ -1313,6 +1411,7 @@ impl LanceNamespace for RestNamespace { &self, request: ExplainTableQueryPlanRequest, ) -> Result { + self.record_op("explain_table_query_plan"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/explain_plan", encoded_id); @@ -1325,6 +1424,7 @@ impl LanceNamespace for RestNamespace { &self, request: AnalyzeTableQueryPlanRequest, ) -> Result { + self.record_op("analyze_table_query_plan"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/analyze_plan", encoded_id); @@ -1337,6 +1437,7 @@ impl LanceNamespace for RestNamespace { &self, request: AlterTableAddColumnsRequest, ) -> Result { + self.record_op("alter_table_add_columns"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/add_columns", encoded_id); @@ -1349,6 +1450,7 @@ impl LanceNamespace for RestNamespace { &self, request: AlterTableAlterColumnsRequest, ) -> Result { + self.record_op("alter_table_alter_columns"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/alter_columns", encoded_id); @@ -1361,6 +1463,7 @@ impl LanceNamespace for RestNamespace { &self, request: AlterTableDropColumnsRequest, ) -> Result { + self.record_op("alter_table_drop_columns"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/drop_columns", encoded_id); @@ -1373,6 +1476,7 @@ impl LanceNamespace for RestNamespace { &self, request: ListTableTagsRequest, ) -> Result { + self.record_op("list_table_tags"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/tags/list", encoded_id); @@ -1394,6 +1498,7 @@ impl LanceNamespace for RestNamespace { &self, request: GetTableTagVersionRequest, ) -> Result { + self.record_op("get_table_tag_version"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/tags/version", encoded_id); @@ -1406,6 +1511,7 @@ impl LanceNamespace for RestNamespace { &self, request: CreateTableTagRequest, ) -> Result { + self.record_op("create_table_tag"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/tags/create", encoded_id); @@ -1418,6 +1524,7 @@ impl LanceNamespace for RestNamespace { &self, request: DeleteTableTagRequest, ) -> Result { + self.record_op("delete_table_tag"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/tags/delete", encoded_id); @@ -1430,6 +1537,7 @@ impl LanceNamespace for RestNamespace { &self, request: UpdateTableTagRequest, ) -> Result { + self.record_op("update_table_tag"); let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); let path = format!("/v1/table/{}/tags/update", encoded_id); diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 6c09ed002c6..8c978bcad65 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -745,20 +745,20 @@ impl Dataset { .await } - /// Write into a namespace-managed table with automatic credential vending. + /// Write into a namespace client-managed table with automatic credential vending. /// /// For CREATE mode, calls declare_table() to initialize the table. - /// For other modes, calls describe_table() and opens dataset with namespace credentials. + /// For other modes, calls describe_table() and opens dataset with namespace client credentials. /// /// # Arguments /// /// * `batches` - The record batches to write - /// * `namespace` - The namespace to use for table management + /// * `namespace_client` - The namespace client to use for table management /// * `table_id` - The table identifier /// * `params` - Write parameters pub async fn write_into_namespace( batches: impl RecordBatchReader + Send + 'static, - namespace: Arc, + namespace_client: Arc, table_id: Vec, mut params: Option, ) -> Result { @@ -770,7 +770,7 @@ impl Dataset { id: Some(table_id.clone()), ..Default::default() }; - let response = namespace + let response = namespace_client .declare_table(declare_request) .await .map_err(|e| Error::namespace_source(Box::new(e)))?; @@ -784,7 +784,7 @@ impl Dataset { // Set up commit handler when managed_versioning is enabled if response.managed_versioning == Some(true) { let external_store = LanceNamespaceExternalManifestStore::new( - namespace.clone(), + namespace_client.clone(), table_id.clone(), ); let commit_handler: Arc = @@ -794,13 +794,13 @@ impl Dataset { write_params.commit_handler = Some(commit_handler); } - // Set initial credentials and provider from namespace + // Set initial credentials and provider from namespace_client if let Some(namespace_storage_options) = response.storage_options { let provider: Arc = Arc::new( - LanceNamespaceStorageOptionsProvider::new(namespace, table_id), + LanceNamespaceStorageOptionsProvider::new(namespace_client, table_id), ); - // Merge namespace storage options with any existing options + // Merge namespace client storage options with any existing options let mut merged_options = write_params .store_params .as_ref() @@ -827,7 +827,7 @@ impl Dataset { id: Some(table_id.clone()), ..Default::default() }; - let response = namespace + let response = namespace_client .describe_table(request) .await .map_err(|e| Error::namespace_source(Box::new(e)))?; @@ -841,7 +841,7 @@ impl Dataset { // Set up commit handler when managed_versioning is enabled if response.managed_versioning == Some(true) { let external_store = LanceNamespaceExternalManifestStore::new( - namespace.clone(), + namespace_client.clone(), table_id.clone(), ); let commit_handler: Arc = @@ -851,15 +851,15 @@ impl Dataset { write_params.commit_handler = Some(commit_handler); } - // Set initial credentials and provider from namespace + // Set initial credentials and provider from namespace_client if let Some(namespace_storage_options) = response.storage_options { let provider: Arc = Arc::new(LanceNamespaceStorageOptionsProvider::new( - namespace.clone(), + namespace_client.clone(), table_id.clone(), )); - // Merge namespace storage options with any existing options + // Merge namespace client storage options with any existing options let mut merged_options = write_params .store_params .as_ref() diff --git a/rust/lance/src/dataset/builder.rs b/rust/lance/src/dataset/builder.rs index 3671d1ebd1a..2d995d3b78f 100644 --- a/rust/lance/src/dataset/builder.rs +++ b/rust/lance/src/dataset/builder.rs @@ -49,7 +49,7 @@ pub struct DatasetBuilder { version: Option, table_uri: String, file_reader_options: Option, - /// Storage options that override user-provided options (e.g., from namespace) + /// Storage options that override user-provided options (e.g., from namespace client) storage_options_override: Option>, } @@ -89,17 +89,17 @@ impl DatasetBuilder { } } - /// Create a DatasetBuilder from a LanceNamespace + /// Create a DatasetBuilder from a LanceNamespace client /// /// This will automatically fetch the table location and storage options from the namespace - /// via `describe_table()`. + /// client via `describe_table()`. /// - /// Storage options from the namespace will override any user-provided storage options - /// set via `.with_storage_options()`. This ensures the namespace is always the source + /// Storage options from the namespace client will override any user-provided storage options + /// set via `.with_storage_options()`. This ensures the namespace client is always the source /// of truth for storage options. /// /// # Arguments - /// * `namespace` - The namespace implementation to fetch table info from + /// * `namespace_client` - The namespace client implementation to fetch table info from /// * `table_id` - The table identifier (e.g., vec!["my_table"]) /// /// # Example @@ -108,14 +108,14 @@ impl DatasetBuilder { /// use lance::dataset::DatasetBuilder; /// /// // Connect to a REST namespace - /// let namespace = ConnectBuilder::new("rest") + /// let namespace_client = ConnectBuilder::new("rest") /// .property("uri", "http://localhost:8080") /// .connect() /// .await?; /// - /// // Load a dataset using storage options from namespace + /// // Load a dataset using storage options from namespace client /// let dataset = DatasetBuilder::from_namespace( - /// namespace, + /// namespace_client, /// vec!["my_table".to_string()], /// ) /// .await? @@ -124,7 +124,7 @@ impl DatasetBuilder { /// ``` #[allow(deprecated)] pub async fn from_namespace( - namespace: Arc, + namespace_client: Arc, table_id: Vec, ) -> Result { let request = DescribeTableRequest { @@ -132,7 +132,7 @@ impl DatasetBuilder { ..Default::default() }; - let response = namespace + let response = namespace_client .describe_table(request) .await .map_err(|e| Error::namespace_source(Box::new(e)))?; @@ -147,8 +147,10 @@ impl DatasetBuilder { // Check managed_versioning flag to determine if namespace-managed commits should be used if response.managed_versioning == Some(true) { - let external_store = - LanceNamespaceExternalManifestStore::new(namespace.clone(), table_id.clone()); + let external_store = LanceNamespaceExternalManifestStore::new( + namespace_client.clone(), + table_id.clone(), + ); let commit_handler: Arc = Arc::new(ExternalManifestCommitHandler { external_manifest_store: Arc::new(external_store), }); @@ -162,7 +164,7 @@ impl DatasetBuilder { if let Some(initial_opts) = namespace_storage_options { let provider: Arc = Arc::new( - LanceNamespaceStorageOptionsProvider::new(namespace, table_id), + LanceNamespaceStorageOptionsProvider::new(namespace_client, table_id), ); builder.options.storage_options_accessor = Some(Arc::new( StorageOptionsAccessor::with_initial_and_provider(initial_opts, provider), @@ -351,10 +353,10 @@ impl DatasetBuilder { self } - /// Enable credential vending from a LanceNamespace + /// Enable credential vending from a LanceNamespace client /// - /// Credentials will be automatically refreshed from the namespace - /// before they expire. The namespace should return `expires_at_millis` + /// Credentials will be automatically refreshed from the namespace client + /// before they expire. The namespace client should return `expires_at_millis` /// in the storage_options from `describe_table()`. /// /// Use `with_s3_credentials_refresh_offset()` to configure how early @@ -371,14 +373,14 @@ impl DatasetBuilder { /// use lance_io::object_store::{StorageOptionsProvider, LanceNamespaceStorageOptionsProvider}; /// /// // Connect to a REST namespace - /// let namespace = ConnectBuilder::new("rest") + /// let namespace_client = ConnectBuilder::new("rest") /// .property("uri", "http://localhost:8080") /// .connect() /// .await?; /// - /// // Create a storage options provider from namespace + /// // Create a storage options provider from namespace client /// let provider = Arc::new(LanceNamespaceStorageOptionsProvider::new( - /// namespace, + /// namespace_client, /// vec!["my_table".to_string()], /// )); /// @@ -565,11 +567,11 @@ impl DatasetBuilder { } async fn load_impl(mut self) -> Result { - // Apply storage_options_override to merge namespace options with any existing accessor + // Apply storage_options_override to merge namespace client options with any existing accessor if let Some(override_opts) = self.storage_options_override.take() { // Get existing options and merge let mut merged_opts = self.options.storage_options().cloned().unwrap_or_default(); - // Override with namespace storage options - they take precedence + // Override with namespace client storage options - they take precedence merged_opts.extend(override_opts); // Update accessor with merged options diff --git a/rust/lance/src/io/commit/namespace_manifest.rs b/rust/lance/src/io/commit/namespace_manifest.rs index 2593ad89dc6..5b3de301886 100644 --- a/rust/lance/src/io/commit/namespace_manifest.rs +++ b/rust/lance/src/io/commit/namespace_manifest.rs @@ -16,14 +16,14 @@ use object_store::path::Path; #[derive(Debug)] pub struct LanceNamespaceExternalManifestStore { - namespace: Arc, + namespace_client: Arc, table_id: Vec, } impl LanceNamespaceExternalManifestStore { - pub fn new(namespace: Arc, table_id: Vec) -> Self { + pub fn new(namespace_client: Arc, table_id: Vec) -> Self { Self { - namespace, + namespace_client, table_id, } } @@ -38,7 +38,10 @@ impl ExternalManifestStore for LanceNamespaceExternalManifestStore { ..Default::default() }; - let response = self.namespace.describe_table_version(request).await?; + let response = self + .namespace_client + .describe_table_version(request) + .await?; // Namespace returns full path (relative to object store root) Ok(response.version.manifest_path) @@ -52,7 +55,7 @@ impl ExternalManifestStore for LanceNamespaceExternalManifestStore { ..Default::default() }; - let response = self.namespace.list_table_versions(request).await?; + let response = self.namespace_client.list_table_versions(request).await?; if response.versions.is_empty() { return Ok(None); @@ -94,7 +97,7 @@ impl ExternalManifestStore for LanceNamespaceExternalManifestStore { ..Default::default() }; - let response = self.namespace.create_table_version(request).await?; + let response = self.namespace_client.create_table_version(request).await?; // Get version info from response let version_info = response.version.ok_or_else(|| { diff --git a/test_debug.py b/test_debug.py new file mode 100644 index 00000000000..e42798d5ff3 --- /dev/null +++ b/test_debug.py @@ -0,0 +1,50 @@ +import copy +import uuid +import lance +import pyarrow as pa +from lance.namespace import DirectoryNamespace + +CONFIG = { + 'allow_http': 'true', + 'aws_access_key_id': 'ACCESS_KEY', + 'aws_secret_access_key': 'SECRET_KEY', + 'aws_endpoint': 'http://localhost:4566', + 'aws_region': 'us-east-1', +} + +storage_options = copy.deepcopy(CONFIG) +storage_options_with_refresh = dict(storage_options) +storage_options_with_refresh['refresh_offset_millis'] = '1000' + +dir_props = {f'storage.{k}': v for k, v in storage_options_with_refresh.items()} +dir_props['root'] = 's3://lance-namespace-integtest/namespace_root' +dir_props['ops_metrics_enabled'] = 'true' +dir_props['vend_input_storage_options'] = 'true' +dir_props['vend_input_storage_options_refresh_interval_millis'] = '3600000' + +namespace = DirectoryNamespace(**dir_props) + +table1 = pa.Table.from_pylist([{'a': 1, 'b': 2}]) +table_name = 'debug_print3_' + uuid.uuid4().hex +table_id = ['test_ns', table_name] + +print('=== Creating table ===') +ds = lance.write_dataset( + table1, + namespace_client=namespace, + table_id=table_id, + mode='create', + storage_options=storage_options, +) +print('=== Table created ===') +print('Describe count after write:', namespace.retrieve_ops_metrics().get('describe_table', 0)) + +print('') +print('=== Opening dataset via lance.dataset() ===') +ds_from_namespace = lance.dataset( + namespace_client=namespace, + table_id=table_id, + storage_options=storage_options, +) +print('=== Dataset opened ===') +print('Describe count after lance.dataset():', namespace.retrieve_ops_metrics().get('describe_table', 0)) From 550499827d05c9f857f55d607b6c68c884920961 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Thu, 2 Apr 2026 08:54:38 +0000 Subject: [PATCH 125/206] chore: release beta version 5.0.0-beta.3 --- .bumpversion.toml | 2 +- Cargo.lock | 310 ++++++++++++++++++-------------------- Cargo.toml | 40 ++--- java/lance-jni/Cargo.lock | 166 ++++++++++---------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 156 +++++++++---------- python/Cargo.toml | 2 +- 8 files changed, 340 insertions(+), 340 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 1dbb046b1ef..62e0d7d9e92 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "5.0.0-beta.2" +current_version = "5.0.0-beta.3" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 766225a438e..da2c0b4c412 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,7 +37,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -114,21 +114,6 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse 0.2.7", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstream" version = "1.0.0" @@ -136,7 +121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", - "anstyle-parse 1.0.0", + "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -150,15 +135,6 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - [[package]] name = "anstyle-parse" version = "1.0.0" @@ -670,9 +646,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -1379,16 +1355,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -1519,9 +1495,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -1633,7 +1609,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream 1.0.0", + "anstream", "anstyle", "clap_lex", "strsim 0.11.1", @@ -1659,9 +1635,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -1812,6 +1788,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.3.0" @@ -3106,9 +3091,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -3116,11 +3101,11 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ - "anstream 0.6.21", + "anstream", "anstyle", "env_filter", "jiff", @@ -3338,9 +3323,12 @@ dependencies = [ [[package]] name = "fragile" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] [[package]] name = "fs4" @@ -3360,7 +3348,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "lance-datagen", @@ -3988,9 +3976,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -4003,7 +3991,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -4146,12 +4133,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -4159,9 +4147,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -4172,9 +4160,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -4186,15 +4174,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -4206,15 +4194,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -4384,9 +4372,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -4536,19 +4524,21 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "jsonb" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a901f06163d352fbe41c3c2ff5e08b75330a003cc941e988fb501022f5421e6" +checksum = "eb98fb29636087c40ad0d1274d9a30c0c1e83e03ae93f6e7e89247b37fcc6953" dependencies = [ "byteorder", "ethnum", @@ -4557,11 +4547,11 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float 5.1.0", + "ordered-float 5.3.0", "rand 0.9.2", - "ryu", "serde", "serde_json", + "zmij", ] [[package]] @@ -4590,7 +4580,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "all_asserts", "approx", @@ -4686,7 +4676,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -4735,7 +4725,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrayref", "paste", @@ -4744,7 +4734,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -4784,7 +4774,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4818,7 +4808,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4838,7 +4828,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4886,7 +4876,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "all_asserts", "arrow", @@ -4912,7 +4902,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4955,7 +4945,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "datafusion", "geo-traits", @@ -4969,7 +4959,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "approx", "arrow", @@ -5050,7 +5040,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -5101,7 +5091,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "approx", "arrow-array", @@ -5122,7 +5112,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "async-trait", @@ -5137,7 +5127,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -5155,7 +5145,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-ipc", @@ -5214,7 +5204,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -5260,7 +5250,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "proc-macro2", "quote", @@ -5269,7 +5259,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "arrow-schema", @@ -5280,7 +5270,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "clap", "lance-core", @@ -5392,9 +5382,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libflate" @@ -5428,9 +5418,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.11.0", "libc", @@ -5587,9 +5577,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -5806,9 +5796,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -6009,9 +5999,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-format" @@ -6319,9 +6309,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.1.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", ] @@ -6734,9 +6724,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -6867,9 +6857,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", @@ -7572,9 +7562,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -7979,7 +7969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -7990,7 +7980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -8031,9 +8021,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simdutf8" @@ -8130,11 +8120,11 @@ dependencies = [ [[package]] name = "spade" -version = "2.15.0" +version = "2.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", "num-traits", "robust", "smallvec", @@ -8718,9 +8708,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -8858,18 +8848,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.7+spec-1.1.0" +version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15b06e6c39068c203e7c1d0bc3944796d867449e7668ef7fa5ea43727cb846e" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ "indexmap", "toml_datetime", @@ -8879,9 +8869,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] @@ -9168,9 +9158,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -9267,9 +9257,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -9373,9 +9363,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -9386,23 +9376,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9410,9 +9396,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -9423,9 +9409,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -9479,9 +9465,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -9849,9 +9835,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -10043,9 +10029,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -10054,9 +10040,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -10066,18 +10052,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -10086,18 +10072,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -10113,9 +10099,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -10124,9 +10110,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -10135,9 +10121,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 5a69fdd24d9..be576c73227 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ resolver = "3" [workspace.package] -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -53,24 +53,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=5.0.0-beta.2", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=5.0.0-beta.2", path = "./rust/lance-arrow" } -lance-core = { version = "=5.0.0-beta.2", path = "./rust/lance-core" } -lance-datafusion = { version = "=5.0.0-beta.2", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=5.0.0-beta.2", path = "./rust/lance-datagen" } -lance-encoding = { version = "=5.0.0-beta.2", path = "./rust/lance-encoding" } -lance-file = { version = "=5.0.0-beta.2", path = "./rust/lance-file" } -lance-geo = { version = "=5.0.0-beta.2", path = "./rust/lance-geo" } -lance-index = { version = "=5.0.0-beta.2", path = "./rust/lance-index" } -lance-io = { version = "=5.0.0-beta.2", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=5.0.0-beta.2", path = "./rust/lance-linalg" } -lance-namespace = { version = "=5.0.0-beta.2", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=5.0.0-beta.2", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=5.0.0-beta.2", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=5.0.0-beta.3", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=5.0.0-beta.3", path = "./rust/lance-arrow" } +lance-core = { version = "=5.0.0-beta.3", path = "./rust/lance-core" } +lance-datafusion = { version = "=5.0.0-beta.3", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=5.0.0-beta.3", path = "./rust/lance-datagen" } +lance-encoding = { version = "=5.0.0-beta.3", path = "./rust/lance-encoding" } +lance-file = { version = "=5.0.0-beta.3", path = "./rust/lance-file" } +lance-geo = { version = "=5.0.0-beta.3", path = "./rust/lance-geo" } +lance-index = { version = "=5.0.0-beta.3", path = "./rust/lance-index" } +lance-io = { version = "=5.0.0-beta.3", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=5.0.0-beta.3", path = "./rust/lance-linalg" } +lance-namespace = { version = "=5.0.0-beta.3", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=5.0.0-beta.3", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=5.0.0-beta.3", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=5.0.0-beta.2", path = "./rust/lance-table" } -lance-test-macros = { version = "=5.0.0-beta.2", path = "./rust/lance-test-macros" } -lance-testing = { version = "=5.0.0-beta.2", path = "./rust/lance-testing" } +lance-table = { version = "=5.0.0-beta.3", path = "./rust/lance-table" } +lance-test-macros = { version = "=5.0.0-beta.3", path = "./rust/lance-test-macros" } +lance-testing = { version = "=5.0.0-beta.3", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -97,7 +97,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=5.0.0-beta.2", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=5.0.0-beta.3", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -138,7 +138,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=5.0.0-beta.2", path = "./rust/compression/fsst" } +fsst = { version = "=5.0.0-beta.3", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 2f98efa51e2..fd01d8d2d81 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -2398,7 +2398,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "rand 0.9.2", @@ -2907,9 +2907,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -2922,7 +2922,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -3047,12 +3046,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -3060,9 +3060,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -3073,9 +3073,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3087,15 +3087,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -3107,15 +3107,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -3187,6 +3187,17 @@ version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" +[[package]] +name = "io-uring" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd7bddefd0a8833b88a4b68f90dae22c7450d11b354198baee3874fd811b344" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3339,9 +3350,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "cfg-if", "futures-util", @@ -3386,7 +3397,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3452,7 +3463,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3472,7 +3483,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrayref", "paste", @@ -3481,7 +3492,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3518,7 +3529,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -3551,7 +3562,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -3569,7 +3580,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -3606,7 +3617,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -3638,7 +3649,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "datafusion", "geo-traits", @@ -3652,7 +3663,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3720,7 +3731,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3740,10 +3751,13 @@ dependencies = [ "deepsize", "futures", "http 1.4.0", + "io-uring", "lance-arrow", "lance-core", "lance-namespace", + "libc", "log", + "moka", "object_store", "object_store_opendal", "opendal", @@ -3761,7 +3775,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-schema", @@ -3795,7 +3809,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3811,7 +3825,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "async-trait", @@ -3824,7 +3838,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-ipc", @@ -3869,7 +3883,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -3984,9 +3998,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libm" @@ -4017,9 +4031,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -4829,9 +4843,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -6322,9 +6336,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -6409,18 +6423,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ "indexmap", "toml_datetime", @@ -6430,9 +6444,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] @@ -6788,9 +6802,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -6801,9 +6815,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.65" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ "js-sys", "wasm-bindgen", @@ -6811,9 +6825,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6821,9 +6835,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -6834,9 +6848,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -6890,9 +6904,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.92" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -7389,9 +7403,9 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7400,9 +7414,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -7432,18 +7446,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -7459,9 +7473,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -7470,9 +7484,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -7481,9 +7495,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 5a1e7f3f655..f4931f6e5fc 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 693beeefc69..e6f828cad96 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 5.0.0-beta.2 + 5.0.0-beta.3 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index a090f245765..b1d8cf44bcb 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2827,7 +2827,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3345,9 +3345,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -3360,7 +3360,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -3485,12 +3484,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -3498,9 +3498,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -3511,9 +3511,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3525,15 +3525,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -3545,15 +3545,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -3809,9 +3809,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "cfg-if", "futures-util", @@ -3865,7 +3865,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3932,7 +3932,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3952,7 +3952,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrayref", "paste", @@ -3961,7 +3961,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3998,7 +3998,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4031,7 +4031,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4118,7 +4118,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "datafusion", "geo-traits", @@ -4132,7 +4132,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -4203,7 +4203,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -4247,7 +4247,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "async-trait", @@ -4276,7 +4276,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-ipc", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4444,9 +4444,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libflate" @@ -4669,9 +4669,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -5550,9 +5550,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -5685,7 +5685,7 @@ dependencies = [ [[package]] name = "pylance" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -7294,9 +7294,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -7381,18 +7381,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ "indexmap", "toml_datetime", @@ -7402,9 +7402,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] @@ -7594,9 +7594,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "typewit" -version = "1.14.2" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" +checksum = "06fee3a8df48c50c55ad646a4e03b00a370da6fe1850ebf467a8d0165dfcafae" [[package]] name = "typify" @@ -7831,9 +7831,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -7844,9 +7844,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.65" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ "js-sys", "wasm-bindgen", @@ -7854,9 +7854,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7864,9 +7864,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -7877,9 +7877,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -7933,9 +7933,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.92" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -8382,9 +8382,9 @@ checksum = "aed111bd9e48a802518765906cbdadf0b45afb72b9c81ab049a3b86252adffdd" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -8393,9 +8393,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -8425,18 +8425,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -8452,9 +8452,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -8463,9 +8463,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -8474,9 +8474,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/python/Cargo.toml b/python/Cargo.toml index a3542f7360f..d5d7072ed7f 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "5.0.0-beta.2" +version = "5.0.0-beta.3" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 402f7b7587ff7aaeadaf336199ffc2e4380f8959 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 2 Apr 2026 18:10:03 +0800 Subject: [PATCH 126/206] feat: support vector query pruning by index segments (#6376) This change adds `with_index_segments()` for vector queries and makes ANN planning prune to the selected index segments instead of always searching the full logical index. It also makes `with_fragments()` participate in segment selection and flat fallback computation so fragment-filtered and segment-filtered searches stay correct when only part of the logical index is queried. This feature will make distributed search much faster to avoid loading not related index segments. --- FTS should also support this, will add after https://github.com/lance-format/lance/pull/6305 been merged. --- rust/lance/src/dataset/scanner.rs | 523 ++++++++++++++++++++++++++---- 1 file changed, 455 insertions(+), 68 deletions(-) diff --git a/rust/lance/src/dataset/scanner.rs b/rust/lance/src/dataset/scanner.rs index 48ac7dbd8ac..b7f5094ac35 100644 --- a/rust/lance/src/dataset/scanner.rs +++ b/rust/lance/src/dataset/scanner.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::collections::HashSet; use std::ops::Range; use std::pin::Pin; use std::sync::{Arc, LazyLock}; @@ -74,6 +75,7 @@ use lance_linalg::distance::MetricType; use lance_table::format::{Fragment, IndexMetadata}; use roaring::RoaringBitmap; use tracing::{Span, info_span, instrument}; +use uuid::Uuid; use super::Dataset; use crate::dataset::row_offsets_to_row_addresses; @@ -761,6 +763,9 @@ pub struct Scanner { /// If set, this scanner serves only these fragments. fragments: Option>, + /// If set, this scanner will only search the specified vector index segments. + index_segments: Option>, + /// Only search the data being indexed (weak consistency search). /// /// Default value is false. @@ -994,6 +999,7 @@ impl Scanner { use_stats: true, ordered: true, fragments: None, + index_segments: None, fast_search: false, use_scalar_index: true, include_deleted_rows: false, @@ -1040,6 +1046,23 @@ impl Scanner { self } + /// Restrict vector index search to the specified index segments. + /// + /// This setting is only supported for vector search. + /// + /// If [`Self::with_fragments`] is also set then rows from those fragments that are not covered + /// by the selected index segments will still be searched with flat KNN. Otherwise, unindexed + /// fragments outside the selected index segments are not searched. + pub fn with_index_segments(&mut self, segments: Vec) -> Result<&mut Self> { + if segments.is_empty() { + return Err(Error::invalid_input( + "with_index_segments does not accept an empty segment list".to_string(), + )); + } + self.index_segments = Some(segments); + Ok(self) + } + fn get_batch_size(&self) -> usize { // Default batch size to be large enough so that a i32 column can be // read in a single range request. For the object store default of @@ -2167,6 +2190,12 @@ impl Scanner { } } + if self.index_segments.is_some() && self.nearest.is_none() { + return Err(Error::not_supported( + "with_index_segments is only supported for vector search".to_string(), + )); + } + Ok(()) } @@ -3421,55 +3450,127 @@ impl Scanner { } else { Arc::new(vec![]) }; - // Find an index for the column and check if metric is compatible - let matching_index = if let Some(index) = - indices.iter().find(|i| i.fields.contains(&column_id)) - { - // TODO: Once we do https://github.com/lance-format/lance/issues/5231, we - // should be able to get the metric type directly from the index metadata, - // at least for newer indexes. - let idx = self - .dataset - .open_vector_index( - q.column.as_str(), - &index.uuid.to_string(), - &NoOpMetricsCollector, - ) - .await?; - let index_metric = idx.metric_type(); + let index_and_segments = if use_index { + if let Some(requested_segments) = self.index_segments.as_ref() { + let requested_segment_set = + requested_segments.iter().copied().collect::>(); + let requested_index_segments = indices + .iter() + .filter(|idx| requested_segment_set.contains(&idx.uuid)) + .cloned() + .collect::>(); + + if requested_index_segments.len() != requested_segment_set.len() { + let found_segment_set = requested_index_segments + .iter() + .map(|idx| idx.uuid) + .collect::>(); + let missing_segments = requested_segment_set + .difference(&found_segment_set) + .map(ToString::to_string) + .collect::>(); + return Err(Error::invalid_input(format!( + "with_index_segments referenced unknown index segments: {missing_segments:?}", + ))); + } - // Check if user's requested metric is compatible with index - let use_this_index = match q.metric_type { - Some(user_metric) => { - if user_metric == index_metric { - true + if requested_index_segments + .iter() + .any(|idx| !idx.fields.contains(&column_id)) + { + return Err(Error::invalid_input(format!( + "with_index_segments contained a segment that does not belong to vector column '{}'", + q.column + ))); + } + + let index_name = requested_index_segments[0].name.clone(); + if requested_index_segments + .iter() + .any(|idx| idx.name != index_name) + { + return Err(Error::invalid_input( + "with_index_segments must reference segments from a single logical index" + .to_string(), + )); + } + + let selected_index_segments = + self.retain_relevant_index_segments(requested_index_segments); + if selected_index_segments.is_empty() { + None + } else { + let idx = self + .dataset + .open_vector_index( + q.column.as_str(), + &selected_index_segments[0].uuid.to_string(), + &NoOpMetricsCollector, + ) + .await?; + let index_metric = idx.metric_type(); + let use_this_index = match q.metric_type { + Some(user_metric) => { + if user_metric == index_metric { + true + } else { + return Err(Error::invalid_input(format!( + "with_index_segments requested metric {:?} but the selected index segments use {:?}", + user_metric, index_metric + ))); + } + } + None => true, + }; + if use_this_index { + Some((index_name, selected_index_segments, index_metric)) } else { - log::warn!( - "Requested metric {:?} is incompatible with index metric {:?}, falling back to brute-force search", - user_metric, - index_metric - ); - false + None } } - None => true, // No preference, use index's metric - }; + } else if let Some(index) = indices.iter().find(|i| i.fields.contains(&column_id)) { + // TODO: Once we do https://github.com/lance-format/lance/issues/5231, we + // should be able to get the metric type directly from the index metadata, + // at least for newer indexes. + let idx = self + .dataset + .open_vector_index( + q.column.as_str(), + &index.uuid.to_string(), + &NoOpMetricsCollector, + ) + .await?; + let index_metric = idx.metric_type(); - if use_this_index { - Some((index, idx, index_metric)) - } else { - None - } - } else { - None - }; + let use_this_index = match q.metric_type { + Some(user_metric) => { + if user_metric == index_metric { + true + } else { + log::warn!( + "Requested metric {:?} is incompatible with index metric {:?}, falling back to brute-force search", + user_metric, + index_metric + ); + false + } + } + None => true, + }; - // Only return index and deltas if there is an index on the column and at least one of the target fragments are indexed - let index_and_deltas = if let Some((index, _idx, index_metric)) = matching_index { - let deltas = self.dataset.load_indices_by_name(&index.name).await?; - let index_frags = self.get_indexed_frags(&deltas); - if !index_frags.is_empty() { - Some((index, deltas, index_metric)) + if use_this_index { + let index_segments = self.retain_relevant_index_segments( + self.dataset.load_indices_by_name(&index.name).await?, + ); + let index_frags = self.get_indexed_frags(&index_segments); + if !index_segments.is_empty() && !index_frags.is_empty() { + Some((index.name.clone(), index_segments, index_metric)) + } else { + None + } + } else { + None + } } else { None } @@ -3477,7 +3578,7 @@ impl Scanner { None }; - if let Some((index, deltas, index_metric)) = index_and_deltas { + if let Some((index_name, index_segments, index_metric)) = index_and_segments { log::trace!("index found for vector search"); // Use the index's metric type q.metric_type = Some(index_metric); @@ -3489,8 +3590,8 @@ impl Scanner { )); } let ann_node = match vector_type { - DataType::FixedSizeList(_, _) => self.ann(&q, &deltas, filter_plan).await?, - DataType::List(_) => self.multivec_ann(&q, &deltas, filter_plan).await?, + DataType::FixedSizeList(_, _) => self.ann(&q, &index_segments, filter_plan).await?, + DataType::List(_) => self.multivec_ann(&q, &index_segments, filter_plan).await?, _ => unreachable!(), }; @@ -3507,7 +3608,9 @@ impl Scanner { }; // vector, _distance, _rowid if !self.fast_search { - knn_node = self.knn_combined(&q, index, knn_node, filter_plan).await?; + knn_node = self + .knn_combined(&q, &index_name, &index_segments, knn_node, filter_plan) + .await?; } Ok(knn_node) @@ -3557,27 +3660,27 @@ impl Scanner { async fn knn_combined( &self, q: &Query, - index: &IndexMetadata, + index_name: &str, + indexed_segments: &[IndexMetadata], mut knn_node: Arc, filter_plan: &ExprFilterPlan, ) -> Result> { - // Get unindexed fragments and filter to target fragments - let unindexed_fragments = - self.retain_target_fragments(self.dataset.unindexed_fragments(&index.name).await?); - - if !unindexed_fragments.is_empty() { - // need to set the metric type to be the same as the index - // to make sure the distance is comparable. - let idx = self - .dataset - .open_vector_index( - q.column.as_str(), - &index.uuid.to_string(), - &NoOpMetricsCollector, - ) - .await?; - let mut q = q.clone(); - q.metric_type = Some(idx.metric_type()); + let fallback_fragments = if let Some(target_fragments) = &self.fragments { + let indexed_fragments = self.get_indexed_frags(indexed_segments); + target_fragments + .iter() + .filter(|fragment| !indexed_fragments.contains(fragment.id as u32)) + .cloned() + .collect::>() + } else if self.index_segments.is_some() { + Vec::new() + } else { + self.dataset.unindexed_fragments(index_name).await? + }; + + if !fallback_fragments.is_empty() { + let q = q.clone(); + debug_assert!(q.metric_type.is_some()); // If the vector column is not present, we need to take the vector column, so // that the distance value is comparable with the flat search ones. @@ -3606,7 +3709,7 @@ impl Scanner { false, false, vector_scan_projection, - Arc::new(unindexed_fragments), + Arc::new(fallback_fragments), // Can't pushdown limit/offset in an ANN search None, // We are re-ordering anyways, so no need to get data in data @@ -4176,6 +4279,25 @@ impl Scanner { } } + fn retain_relevant_index_segments( + &self, + index_segments: Vec, + ) -> Vec { + if let Some(fragments) = &self.fragments { + let target_fragments = RoaringBitmap::from_iter(fragments.iter().map(|f| f.id as u32)); + index_segments + .into_iter() + .filter(|idx| { + idx.fragment_bitmap + .as_ref() + .is_some_and(|fragmap| !(fragmap & &target_fragments).is_empty()) + }) + .collect() + } else { + index_segments + } + } + /// Retain only fragments that are in the user-specified fragment list. /// If no fragment list is specified, returns the fragments unchanged. fn retain_target_fragments(&self, mut fragments: Vec) -> Vec { @@ -4543,6 +4665,7 @@ pub mod test_dataset { use arrow_array::{ ArrayRef, FixedSizeListArray, Int32Array, RecordBatch, RecordBatchIterator, StringArray, + types::Float32Type, }; use arrow_schema::{ArrowError, DataType}; use lance_arrow::FixedSizeListArrayExt; @@ -4551,7 +4674,13 @@ pub mod test_dataset { use lance_index::{ IndexType, scalar::{ScalarIndexParams, inverted::tokenizer::InvertedIndexParams}, + vector::{ + ivf::IvfBuildParams, + kmeans::{KMeansParams, train_kmeans}, + }, }; + use lance_linalg::distance::DistanceType; + use uuid::Uuid; use crate::dataset::WriteParams; use crate::index::vector::VectorIndexParams; @@ -4662,6 +4791,63 @@ pub mod test_dataset { Ok(()) } + pub async fn make_segmented_vector_index(&mut self) -> Result> { + let batch = self + .dataset + .scan() + .project(&["vec"]) + .unwrap() + .try_into_batch() + .await?; + let vectors = batch + .column_by_name("vec") + .expect("vector column should exist") + .as_fixed_size_list(); + let values = vectors.values().as_primitive::(); + let centroids = train_kmeans::( + values, + KMeansParams::new(None, 10, 1, DistanceType::L2), + self.dimension as usize, + 2, + 2, + ) + .unwrap() + .centroids + .as_primitive::() + .clone(); + let centroids = Arc::new( + FixedSizeListArray::try_new_from_values(centroids, self.dimension as i32).unwrap(), + ); + let params = VectorIndexParams::with_ivf_flat_params( + DistanceType::L2, + IvfBuildParams::try_with_centroids(2, centroids).unwrap(), + ); + let fragment_ids = self + .dataset + .get_fragments() + .iter() + .map(|fragment| fragment.id() as u32) + .collect::>(); + + let mut segments = Vec::with_capacity(fragment_ids.len()); + for fragment_id in fragment_ids { + let mut builder = + self.dataset + .create_index_builder(&["vec"], IndexType::Vector, ¶ms); + builder = builder.name("idx".to_string()).fragments(vec![fragment_id]); + segments.push(builder.execute_uncommitted().await?); + } + + let segment_ids = segments + .iter() + .map(|segment| segment.uuid) + .collect::>(); + self.dataset + .commit_existing_index_segments("idx", "vec", segments) + .await?; + Ok(segment_ids) + } + pub async fn make_scalar_index(&mut self) -> Result<()> { self.dataset .create_index( @@ -10258,8 +10444,8 @@ full_filter=name LIKE Utf8(\"test%2\"), refine_filter=name LIKE Utf8(\"test%2\") .await .unwrap(); - // Create index on first 2 fragments - test_ds.make_vector_index().await.unwrap(); + // Create one segment per indexed fragment so fragment filtering must prune ANN fan-out. + test_ds.make_segmented_vector_index().await.unwrap(); let query: Float32Array = (0..32).map(|v| v as f32).collect(); @@ -10280,6 +10466,207 @@ full_filter=name LIKE Utf8(\"test%2\"), refine_filter=name LIKE Utf8(\"test%2\") .await; } + #[tokio::test] + async fn test_vector_search_fragment_filter_prunes_segment_fanout() { + let mut test_ds = TestVectorDataset::new(LanceFileVersion::Stable, false) + .await + .unwrap(); + test_ds.make_segmented_vector_index().await.unwrap(); + + let query: Float32Array = (0..32).map(|v| v as f32).collect(); + test_ds.append_data_with_range(400, 410).await.unwrap(); + test_ds.append_data_with_range(410, 420).await.unwrap(); + let fragments = test_ds.dataset.fragments(); + + let mut scanner = test_ds.dataset.scan(); + scanner.nearest("vec", &query, 420).unwrap(); + let full_plan = scanner.explain_plan(true).await.unwrap(); + assert!( + full_plan.contains("ANNSubIndex: name=idx, k=420, deltas=2, metric=L2"), + "expected two ANN deltas without fragment filter, plan was:\n{full_plan}" + ); + + let mut scanner = test_ds.dataset.scan(); + scanner + .nearest("vec", &query, 420) + .unwrap() + .with_fragments(vec![fragments[0].clone()]); + let filtered_plan = scanner.explain_plan(true).await.unwrap(); + assert!( + filtered_plan.contains("ANNSubIndex: name=idx, k=420, deltas=1, metric=L2"), + "expected one ANN delta with fragment filter, plan was:\n{filtered_plan}" + ); + } + + #[tokio::test] + async fn test_vector_search_respects_index_segments() { + let mut test_ds = TestVectorDataset::new(LanceFileVersion::Stable, false) + .await + .unwrap(); + let segment_ids = test_ds.make_segmented_vector_index().await.unwrap(); + + let query: Float32Array = (0..32).map(|v| v as f32).collect(); + test_ds.append_data_with_range(400, 410).await.unwrap(); + test_ds.append_data_with_range(410, 420).await.unwrap(); + + let mut scanner = test_ds.dataset.scan(); + scanner + .nearest("vec", &query, 420) + .unwrap() + .with_index_segments(vec![segment_ids[0]]) + .unwrap(); + let batch = scanner.try_into_batch().await.unwrap(); + let i_array = batch + .column_by_name("i") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(batch.num_rows(), 200); + assert_values_in_range( + i_array, + 0..200, + "Should only get results from the selected index segment", + ); + } + + #[tokio::test] + async fn test_vector_search_intersects_fragments_and_index_segments() { + let mut test_ds = TestVectorDataset::new(LanceFileVersion::Stable, false) + .await + .unwrap(); + let segment_ids = test_ds.make_segmented_vector_index().await.unwrap(); + + let query: Float32Array = (0..32).map(|v| v as f32).collect(); + test_ds.append_data_with_range(400, 410).await.unwrap(); + test_ds.append_data_with_range(410, 420).await.unwrap(); + let fragments = test_ds.dataset.fragments(); + + let mut scanner = test_ds.dataset.scan(); + scanner + .nearest("vec", &query, 420) + .unwrap() + .with_fragments(vec![fragments[0].clone(), fragments[2].clone()]) + .with_index_segments(vec![segment_ids[0]]) + .unwrap(); + let batch = scanner.try_into_batch().await.unwrap(); + let i_array = batch + .column_by_name("i") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + assert!( + i_array + .iter() + .all(|v| v.is_some_and(|val| (0..200).contains(&val) || (400..410).contains(&val))) + && i_array + .iter() + .any(|v| v.is_some_and(|val| (0..200).contains(&val))) + && i_array + .iter() + .any(|v| v.is_some_and(|val| (400..410).contains(&val))), + "Should get selected segment rows plus flat fallback for target fragments outside the selected segments" + ); + } + + #[tokio::test] + async fn test_vector_search_rejects_unknown_index_segment() { + let mut test_ds = TestVectorDataset::new(LanceFileVersion::Stable, false) + .await + .unwrap(); + test_ds.make_segmented_vector_index().await.unwrap(); + + let query: Float32Array = (0..32).map(|v| v as f32).collect(); + let err = test_ds + .dataset + .scan() + .nearest("vec", &query, 10) + .unwrap() + .with_index_segments(vec![Uuid::new_v4()]) + .unwrap() + .try_into_batch() + .await + .unwrap_err(); + assert!( + err.to_string().contains("unknown index segments"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_vector_search_rejects_metric_mismatch_for_index_segments() { + let mut test_ds = TestVectorDataset::new(LanceFileVersion::Stable, false) + .await + .unwrap(); + let segment_ids = test_ds.make_segmented_vector_index().await.unwrap(); + + let query: Float32Array = (0..32).map(|v| v as f32).collect(); + let err = test_ds + .dataset + .scan() + .nearest("vec", &query, 10) + .unwrap() + .distance_metric(DistanceType::Dot) + .with_index_segments(vec![segment_ids[0]]) + .unwrap() + .try_into_batch() + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("with_index_segments requested metric"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_with_index_segments_rejects_empty_list() { + let test_ds = TestVectorDataset::new(LanceFileVersion::Stable, false) + .await + .unwrap(); + let query: Float32Array = (0..32).map(|v| v as f32).collect(); + + let Err(err) = test_ds + .dataset + .scan() + .nearest("vec", &query, 10) + .unwrap() + .with_index_segments(vec![]) + else { + panic!("expected empty index segments to be rejected"); + }; + assert!( + err.to_string() + .contains("with_index_segments does not accept an empty segment list"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_with_index_segments_rejected_for_non_vector_query() { + let mut test_ds = TestVectorDataset::new(LanceFileVersion::Stable, false) + .await + .unwrap(); + let segment_ids = test_ds.make_segmented_vector_index().await.unwrap(); + + let err = test_ds + .dataset + .scan() + .project(&["i"]) + .unwrap() + .with_index_segments(vec![segment_ids[0]]) + .unwrap() + .try_into_batch() + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("with_index_segments is only supported for vector search"), + "unexpected error: {err}" + ); + } + #[tokio::test] async fn test_fts_respects_fragment_list() { let mut test_ds = TestVectorDataset::new(LanceFileVersion::Stable, false) From 8b59eb645c3375ed5a296d6c1198a6314b7837c4 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 2 Apr 2026 19:07:07 +0800 Subject: [PATCH 127/206] feat: support ingest mode for external blob writes (#6356) This adds `external_blob_mode="ingest"` for blob v2 writes so datasets can import external blob bytes into Lance-managed storage instead of always persisting URI references. The write path now streams external sources through `lance-io` reader streams for packed and dedicated blobs while preserving inline materialization for small payloads. Closes #6321. --- python/python/lance/dataset.py | 10 + python/python/tests/test_blob.py | 84 ++++++ python/src/dataset.rs | 12 +- rust/lance-io/src/local.rs | 37 ++- rust/lance-io/src/object_reader.rs | 94 ++++++- rust/lance-io/src/traits.rs | 57 +++- rust/lance-io/src/utils.rs | 35 +++ rust/lance/src/dataset.rs | 4 +- rust/lance/src/dataset/blob.rs | 433 ++++++++++++++++++++++++++++- rust/lance/src/dataset/write.rs | 105 +++++++ 10 files changed, 848 insertions(+), 23 deletions(-) diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index d02a5a9c1a0..05ca528c855 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -5944,6 +5944,7 @@ def write_dataset( transaction_properties: Optional[Dict[str, str]] = None, initial_bases: Optional[List[DatasetBasePath]] = None, target_bases: Optional[List[str]] = None, + external_blob_mode: Literal["reference", "ingest"] = "reference", allow_external_blob_outside_bases: bool = False, namespace_client: Optional[LanceNamespace] = None, table_id: Optional[List[str]] = None, @@ -6039,9 +6040,17 @@ def write_dataset( **CREATE mode**: References must match bases in `initial_bases` **APPEND/OVERWRITE modes**: References must match bases in the existing manifest + external_blob_mode: {"reference", "ingest"}, default "reference" + How external blob URIs are handled on write. + + - ``"reference"`` stores the URI as an external blob reference. + - ``"ingest"`` reads the external bytes during write and stores them in + Lance-managed storage using the normal inline / packed / dedicated + thresholds. allow_external_blob_outside_bases: bool, default False If False, external blob URIs must map to the dataset root or a registered base path. If True, external blob URIs outside registered bases are allowed. + This option only applies when ``external_blob_mode="reference"``. namespace_client : optional, LanceNamespace A namespace client from which to fetch table location and storage options. Must be provided together with `table_id`. Cannot be used with `uri`. @@ -6168,6 +6177,7 @@ def write_dataset( "transaction_properties": merged_properties, "initial_bases": initial_bases, "target_bases": target_bases, + "external_blob_mode": external_blob_mode, "allow_external_blob_outside_bases": allow_external_blob_outside_bases, } diff --git a/python/python/tests/test_blob.py b/python/python/tests/test_blob.py index 782a83d814c..4e4633b222f 100644 --- a/python/python/tests/test_blob.py +++ b/python/python/tests/test_blob.py @@ -378,6 +378,47 @@ def test_blob_extension_write_external(tmp_path): assert f.read() == b"hello" +def test_blob_extension_write_external_ingest(tmp_path): + blob_path = tmp_path / "external_blob.bin" + blob_path.write_bytes(b"hello") + uri = blob_path.as_uri() + + table = pa.table({"blob": lance.blob_array([uri])}) + ds = lance.write_dataset( + table, + tmp_path / "test_ds_v2_external_ingest", + data_storage_version="2.2", + external_blob_mode="ingest", + ) + + blob_path.unlink() + + blob = ds.take_blobs("blob", indices=[0])[0] + assert blob.size() == 5 + with blob as f: + assert f.read() == b"hello" + + +def test_blob_extension_write_external_ingest_rejects_reference_only_options(tmp_path): + blob_path = tmp_path / "external_blob.bin" + blob_path.write_bytes(b"hello") + uri = blob_path.as_uri() + message = ( + "allow_external_blob_outside_bases only applies when " + 'external_blob_mode="reference"' + ) + + table = pa.table({"blob": lance.blob_array([uri])}) + with pytest.raises(OSError, match=message): + lance.write_dataset( + table, + tmp_path / "test_ds_v2_external_ingest_invalid", + data_storage_version="2.2", + external_blob_mode="ingest", + allow_external_blob_outside_bases=True, + ) + + def test_blob_extension_write_external_slice(tmp_path): tar_path = tmp_path / "container.tar" names = ["a.bin", "b.bin", "c.bin"] @@ -423,6 +464,49 @@ def test_blob_extension_write_external_slice(tmp_path): assert f.read() == expected +def test_blob_extension_write_external_slice_ingest(tmp_path): + tar_path = tmp_path / "container.tar" + names = ["a.bin", "b.bin", "c.bin"] + payloads = [b"alpha", b"bravo", b"charlie"] + + with tarfile.open(tar_path, "w") as tf: + for name, data in zip(names, payloads): + info = tarfile.TarInfo(name) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + + positions: list[int] = [] + sizes: list[int] = [] + with tarfile.open(tar_path, "r") as tf: + for name in names: + member = tf.getmember(name) + positions.append(member.offset_data) + sizes.append(member.size) + + uri = tar_path.as_uri() + blob_values = [ + Blob.from_uri(uri, position, size) for position, size in zip(positions, sizes) + ] + table = pa.table({"blob": lance.blob_array(blob_values)}) + + ds = lance.write_dataset( + table, + tmp_path / "ds_ingest", + data_storage_version="2.2", + external_blob_mode="ingest", + ) + + tar_path.unlink() + + blobs = ds.take_blobs("blob", indices=[0, 1, 2]) + assert len(blobs) == len(payloads) + + for expected, blob_file in zip(payloads, blobs): + assert blob_file.size() == len(expected) + with blob_file as f: + assert f.read() == expected + + @pytest.mark.parametrize( ("payload", "is_dataset_root"), [ diff --git a/python/src/dataset.rs b/python/src/dataset.rs index 567a1851768..70895880ed0 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -46,9 +46,10 @@ use lance::dataset::{ }; use lance::dataset::{ColumnAlteration, ProjectionRequest}; use lance::dataset::{ - Dataset as LanceDataset, DeleteBuilder, MergeInsertBuilder as LanceMergeInsertBuilder, - ReadParams, UncommittedMergeInsert, UpdateBuilder, Version, WhenMatched, WhenNotMatched, - WhenNotMatchedBySource, WriteMode, WriteParams, + Dataset as LanceDataset, DeleteBuilder, ExternalBlobMode, + MergeInsertBuilder as LanceMergeInsertBuilder, ReadParams, UncommittedMergeInsert, + UpdateBuilder, Version, WhenMatched, WhenNotMatched, WhenNotMatchedBySource, WriteMode, + WriteParams, fragment::FileFragment as LanceFileFragment, progress::WriteFragmentProgress, scanner::Scanner as LanceScanner, @@ -3229,6 +3230,11 @@ pub fn get_write_params(options: &Bound<'_, PyDict>) -> PyResult(options, "external_blob_mode")? { + p = p.with_external_blob_mode( + ExternalBlobMode::try_from(external_blob_mode.as_str()).infer_error()?, + ); + } // Handle properties if let Some(props) = diff --git a/rust/lance-io/src/local.rs b/rust/lance-io/src/local.rs index ab4a21a8874..12f846bcc52 100644 --- a/rust/lance-io/src/local.rs +++ b/rust/lance-io/src/local.rs @@ -24,9 +24,10 @@ use tokio::io::AsyncSeekExt; use tokio::sync::OnceCell; use tracing::instrument; +use crate::object_reader::stream_local_range; use crate::object_store::DEFAULT_LOCAL_IO_PARALLELISM; use crate::object_writer::WriteResult; -use crate::traits::{Reader, Writer}; +use crate::traits::{ByteStream, Reader, Writer}; use crate::utils::tracking_store::IOTracker; /// Convert an [`object_store::path::Path`] to a [`std::path::Path`]. @@ -240,10 +241,42 @@ impl Reader for LocalObjectReader { result }) } + + fn get_stream(&self) -> BoxFuture<'_, object_store::Result> { + Box::pin(async move { + let size = self.size().await?; + Ok(stream_local_range( + self.file.clone(), + self.path.clone(), + self.io_tracker.clone(), + 0..size, + self.block_size.max(8 * 1024), + )) + }) + } + + fn get_range_stream( + &self, + range: Range, + ) -> BoxFuture<'_, object_store::Result> { + let file = self.file.clone(); + let path = self.path.clone(); + let io_tracker = self.io_tracker.clone(); + let chunk_size = self.block_size.max(8 * 1024); + Box::pin(async move { + Ok(stream_local_range( + file, path, io_tracker, range, chunk_size, + )) + }) + } } #[cfg(windows)] -fn read_exact_at(file: Arc, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> { +pub(crate) fn read_exact_at( + file: Arc, + mut buf: &mut [u8], + mut offset: u64, +) -> std::io::Result<()> { let expected_len = buf.len(); while !buf.is_empty() { match file.seek_read(buf, offset) { diff --git a/rust/lance-io/src/object_reader.rs b/rust/lance-io/src/object_reader.rs index d6b34671aed..d7ad909079a 100644 --- a/rust/lance-io/src/object_reader.rs +++ b/rust/lance-io/src/object_reader.rs @@ -1,21 +1,31 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::fs::File; use std::ops::Range; use std::sync::Arc; +#[cfg(windows)] +use crate::local::read_exact_at; +#[cfg(unix)] +use std::os::unix::fs::FileExt; + use bytes::Bytes; use deepsize::DeepSizeOf; use futures::{ FutureExt, future::{BoxFuture, Shared}, + stream::{self, StreamExt}, }; use lance_core::{Error, Result, error::CloneableError}; use object_store::{GetOptions, GetResult, ObjectStore, Result as OSResult, path::Path}; use tokio::sync::OnceCell; use tracing::instrument; -use crate::{object_store::DEFAULT_CLOUD_IO_PARALLELISM, traits::Reader}; +use crate::{ + object_store::DEFAULT_CLOUD_IO_PARALLELISM, + traits::{ByteStream, Reader}, +}; trait StaticGetRange { fn path(&self) -> &Path; @@ -211,6 +221,41 @@ impl Reader for CloudObjectReader { .await }) } + + fn get_stream(&self) -> BoxFuture<'_, OSResult> { + let get_request = Arc::new(GetRequest { + object_store: self.object_store.clone(), + path: self.path.clone(), + options: GetOptions::default(), + }); + Box::pin(async move { + let get_request_clone = get_request.clone(); + let get_result = do_with_retry(move || get_request_clone.get_range()).await?; + Ok(get_result.into_stream()) + }) + } + + fn get_range_stream(&self, range: Range) -> BoxFuture<'_, OSResult> { + let get_request = Arc::new(GetRequest { + object_store: self.object_store.clone(), + path: self.path.clone(), + options: GetOptions { + range: Some( + Range { + start: range.start as u64, + end: range.end as u64, + } + .into(), + ), + ..Default::default() + }, + }); + Box::pin(async move { + let get_request_clone = get_request.clone(); + let get_result = do_with_retry(move || get_request_clone.get_range()).await?; + Ok(get_result.into_stream()) + }) + } } #[derive(Debug)] @@ -350,6 +395,53 @@ impl Reader for SmallReader { } } +pub(crate) fn stream_local_range( + file: Arc, + path: Path, + io_tracker: Arc, + range: Range, + chunk_size: usize, +) -> ByteStream { + stream::try_unfold( + (file, path, io_tracker, range.start, range.end), + move |state| async move { + let (file, path, io_tracker, start, end) = state; + if start >= end { + return Ok(None); + } + + let next = (start + chunk_size).min(end); + let file_clone = file.clone(); + let path_clone = path.clone(); + let bytes = tokio::task::spawn_blocking(move || { + let mut buf = bytes::BytesMut::with_capacity(next - start); + // Safety: buffer capacity matches the exact number of bytes we read below. + unsafe { buf.set_len(next - start) }; + #[cfg(unix)] + file_clone.read_exact_at(buf.as_mut(), start as u64)?; + #[cfg(windows)] + read_exact_at(file_clone, buf.as_mut(), start as u64)?; + Ok::<_, std::io::Error>(buf.freeze()) + }) + .await? + .map_err(|err: std::io::Error| object_store::Error::Generic { + store: "LocalFileSystem", + source: err.into(), + })?; + + io_tracker.record_read( + "get_range_stream", + path_clone, + (next - start) as u64, + Some(start as u64..next as u64), + ); + + Ok(Some((bytes, (file, path, io_tracker, next, end)))) + }, + ) + .boxed() +} + impl DeepSizeOf for SmallReader { fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize { let mut size = self.inner.path.as_ref().deep_size_of_children(context); diff --git a/rust/lance-io/src/traits.rs b/rust/lance-io/src/traits.rs index 9ad8d86c00c..eb83cf1a2ba 100644 --- a/rust/lance-io/src/traits.rs +++ b/rust/lance-io/src/traits.rs @@ -6,7 +6,7 @@ use std::ops::Range; use async_trait::async_trait; use bytes::Bytes; use deepsize::DeepSizeOf; -use futures::future::BoxFuture; +use futures::{StreamExt, future::BoxFuture, stream::BoxStream}; use object_store::path::Path; use prost::Message; use tokio::io::{AsyncWrite, AsyncWriteExt}; @@ -19,6 +19,8 @@ pub trait ProtoStruct { type Proto: Message; } +pub type ByteStream = BoxStream<'static, object_store::Result>; + /// A trait for writing to a file on local file system or object store. #[async_trait] pub trait Writer: AsyncWrite + Unpin + Send { @@ -67,6 +69,14 @@ pub trait WriteExt { minor_version: i16, magic: &[u8], ) -> Result<()>; + + async fn copy_from_reader(&mut self, reader: &dyn Reader) -> Result; + + async fn copy_range_from_reader( + &mut self, + reader: &dyn Reader, + range: Range, + ) -> Result; } #[async_trait] @@ -95,6 +105,32 @@ impl WriteExt for W { self.write_all(magic).await?; Ok(()) } + + async fn copy_from_reader(&mut self, reader: &dyn Reader) -> Result { + let mut stream = reader.get_stream().await?; + let mut copied = 0usize; + while let Some(chunk) = stream.next().await { + let bytes = chunk?; + copied += bytes.len(); + self.write_all(&bytes).await?; + } + Ok(copied) + } + + async fn copy_range_from_reader( + &mut self, + reader: &dyn Reader, + range: Range, + ) -> Result { + let mut stream = reader.get_range_stream(range).await?; + let mut copied = 0usize; + while let Some(chunk) = stream.next().await { + let bytes = chunk?; + copied += bytes.len(); + self.write_all(&bytes).await?; + } + Ok(copied) + } } pub trait Reader: std::fmt::Debug + Send + Sync + DeepSizeOf { @@ -119,4 +155,23 @@ pub trait Reader: std::fmt::Debug + Send + Sync + DeepSizeOf { /// By default this reads the size in a separate IOP but some implementations /// may not need the size beforehand. fn get_all(&self) -> BoxFuture<'_, object_store::Result>; + + /// Read the entire object as a byte stream. + fn get_stream(&self) -> BoxFuture<'_, object_store::Result> { + Box::pin(async move { + let bytes = self.get_all().await?; + Ok(futures::stream::once(async move { Ok(bytes) }).boxed()) + }) + } + + /// Read a byte range as a byte stream. + fn get_range_stream( + &self, + range: Range, + ) -> BoxFuture<'_, object_store::Result> { + Box::pin(async move { + let bytes = self.get_range(range).await?; + Ok(futures::stream::once(async move { Ok(bytes) }).boxed()) + }) + } } diff --git a/rust/lance-io/src/utils.rs b/rust/lance-io/src/utils.rs index 48909728e3a..83ea2177a0f 100644 --- a/rust/lance-io/src/utils.rs +++ b/rust/lance-io/src/utils.rs @@ -307,4 +307,39 @@ mod tests { let actual: BytesWrapper = read_struct(&object_reader, pos).await.unwrap(); assert_eq!(some_message, actual); } + + #[tokio::test] + async fn test_copy_reader_to_writer() { + let store = ObjectStore::memory(); + let src = Path::from("/src"); + let dst = Path::from("/dst"); + store.put(&src, b"abcdef").await.unwrap(); + + let reader = store.open(&src).await.unwrap(); + let mut writer = store.create(&dst).await.unwrap(); + let copied = writer.copy_from_reader(reader.as_ref()).await.unwrap(); + writer.shutdown().await.unwrap(); + + assert_eq!(copied, 6); + assert_eq!(store.read_one_all(&dst).await.unwrap().as_ref(), b"abcdef"); + } + + #[tokio::test] + async fn test_copy_reader_range_to_writer() { + let store = ObjectStore::memory(); + let src = Path::from("/src-range"); + let dst = Path::from("/dst-range"); + store.put(&src, b"abcdef").await.unwrap(); + + let reader = store.open(&src).await.unwrap(); + let mut writer = store.create(&dst).await.unwrap(); + let copied = writer + .copy_range_from_reader(reader.as_ref(), 2..5) + .await + .unwrap(); + writer.shutdown().await.unwrap(); + + assert_eq!(copied, 3); + assert_eq!(store.read_one_all(&dst).await.unwrap().as_ref(), b"cde"); + } } diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 8c978bcad65..fb784a38e6a 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -130,8 +130,8 @@ use crate::dataset::index::LanceIndexStoreExt; pub use write::update::{UpdateBuilder, UpdateJob}; #[allow(deprecated)] pub use write::{ - AutoCleanupParams, CommitBuilder, DeleteBuilder, DeleteResult, InsertBuilder, WriteDestination, - WriteMode, WriteParams, WriteProgressFn, WriteStats, write_fragments, + AutoCleanupParams, CommitBuilder, DeleteBuilder, DeleteResult, ExternalBlobMode, InsertBuilder, + WriteDestination, WriteMode, WriteParams, WriteProgressFn, WriteStats, write_fragments, }; pub(crate) const INDICES_DIR: &str = "_indices"; diff --git a/rust/lance/src/dataset/blob.rs b/rust/lance/src/dataset/blob.rs index b77a21dc302..33c365ee534 100644 --- a/rust/lance/src/dataset/blob.rs +++ b/rust/lance/src/dataset/blob.rs @@ -1,7 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::{collections::HashMap, future::Future, ops::DerefMut, sync::Arc}; +use std::{ + collections::HashMap, + future::Future, + ops::{DerefMut, Range}, + sync::Arc, +}; use arrow::array::AsArray; use arrow::datatypes::{UInt8Type, UInt32Type, UInt64Type}; @@ -17,12 +22,13 @@ use tokio::sync::Mutex; use url::Url; use super::take::TakeBuilder; +use super::write::ExternalBlobMode; use super::{Dataset, ProjectionRequest}; use arrow_array::StructArray; use lance_core::datatypes::{BlobKind, BlobVersion}; use lance_core::utils::blob::blob_path; use lance_core::{Error, Result, utils::address::RowAddress}; -use lance_io::traits::{Reader, Writer}; +use lance_io::traits::{Reader, WriteExt, Writer}; const INLINE_MAX: usize = 64 * 1024; // 64KB inline cutoff const DEDICATED_THRESHOLD: usize = 4 * 1024 * 1024; // 4MB dedicated cutoff @@ -151,12 +157,12 @@ impl PackWriter { async fn write_with_allocator( &mut self, alloc_blob_id: &mut F, - data: &[u8], + source: BlobWriteSource<'_>, ) -> Result<(u32, u64)> where F: FnMut() -> u32, { - let len = data.len(); + let len = source.size(); if self .current_blob_id .map(|_| self.current_size + len > self.max_pack_size) @@ -169,7 +175,7 @@ impl PackWriter { let writer = self.writer.as_mut().expect("pack writer is initialized"); let position = self.current_size as u64; - writer.write_all(data).await?; + source.write_to(writer.as_mut()).await?; self.current_size += len; Ok((self.current_blob_id.expect("pack blob id"), position)) } @@ -200,9 +206,98 @@ pub struct BlobPreprocessor { writer_metadata: Vec>, external_base_resolver: Option>, allow_external_blob_outside_bases: bool, + external_blob_mode: ExternalBlobMode, + source_store_registry: Arc, + source_store_params: ObjectStoreParams, +} + +/// A logical slice of an external blob that can be materialized or streamed into Lance-managed +/// storage. +struct ExternalBlobSource { + reader: Box, + start: u64, + size: u64, +} + +/// A blob payload source used by packed and dedicated writers. +/// +/// Inline blobs still need an in-memory byte slice because they are embedded into the descriptor +/// array, while external ingest can stream bytes from the source reader. +enum BlobWriteSource<'a> { + Bytes(&'a [u8]), + External(&'a ExternalBlobSource), +} + +impl ExternalBlobSource { + /// Return the logical payload size after applying any external slice. + fn size(&self) -> u64 { + self.size + } + + /// Convert the logical slice into the current reader API's usize-based range. + fn reader_range(&self) -> Result> { + let start = usize::try_from(self.start).map_err(|_| { + Error::invalid_input(format!( + "External blob position {} does not fit into usize", + self.start + )) + })?; + let size = usize::try_from(self.size).map_err(|_| { + Error::invalid_input(format!( + "External blob size {} does not fit into usize", + self.size + )) + })?; + let end = start.checked_add(size).ok_or_else(|| { + Error::invalid_input(format!( + "External blob range overflows usize: position={}, size={}", + self.start, self.size + )) + })?; + Ok(start..end) + } + + /// Materialize the slice into memory for the inline blob path. + async fn read_all(&self) -> Result { + let range = self.reader_range()?; + self.reader.get_range(range).await.map_err(Into::into) + } + + /// Stream the slice into a writer for packed or dedicated blob storage. + async fn copy_to_writer(&self, writer: &mut dyn Writer) -> Result<()> { + let range = self.reader_range()?; + writer + .copy_range_from_reader(self.reader.as_ref(), range) + .await?; + Ok(()) + } +} + +impl BlobWriteSource<'_> { + /// Return the payload size regardless of whether bytes come from memory or an external reader. + fn size(&self) -> usize { + match self { + Self::Bytes(data) => data.len(), + Self::External(source) => usize::try_from(source.size()) + .expect("packed and inline external blobs must fit into usize"), + } + } + + /// Write the payload into Lance-managed storage without forcing callers to branch on source + /// type. + async fn write_to(&self, writer: &mut dyn Writer) -> Result<()> { + match self { + Self::Bytes(data) => { + writer.write_all(data).await?; + Ok(()) + } + Self::External(source) => source.copy_to_writer(writer).await, + } + } } impl BlobPreprocessor { + #[allow(clippy::too_many_arguments)] pub(super) fn new( object_store: ObjectStore, data_dir: Path, @@ -210,6 +305,9 @@ impl BlobPreprocessor { schema: &lance_core::datatypes::Schema, external_base_resolver: Option>, allow_external_blob_outside_bases: bool, + external_blob_mode: ExternalBlobMode, + source_store_registry: Arc, + source_store_params: ObjectStoreParams, ) -> Self { let pack_writer = PackWriter::new( object_store.clone(), @@ -239,6 +337,9 @@ impl BlobPreprocessor { writer_metadata, external_base_resolver, allow_external_blob_outside_bases, + external_blob_mode, + source_store_registry, + source_store_params, } } @@ -248,15 +349,15 @@ impl BlobPreprocessor { id } - async fn write_dedicated(&mut self, blob_id: u32, data: &[u8]) -> Result { + async fn write_dedicated(&mut self, blob_id: u32, source: BlobWriteSource<'_>) -> Result { let path = blob_path(&self.data_dir, &self.data_file_key, blob_id); let mut writer = self.object_store.create(&path).await?; - writer.write_all(data).await?; + source.write_to(writer.as_mut()).await?; Writer::shutdown(&mut writer).await?; Ok(path) } - async fn write_packed(&mut self, data: &[u8]) -> Result<(u32, u64)> { + async fn write_packed(&mut self, source: BlobWriteSource<'_>) -> Result<(u32, u64)> { let (counter, pack_writer) = (&mut self.local_counter, &mut self.pack_writer); pack_writer .write_with_allocator( @@ -265,7 +366,7 @@ impl BlobPreprocessor { *counter += 1; id }, - data, + source, ) .await } @@ -291,6 +392,48 @@ impl BlobPreprocessor { ))) } + async fn open_external_source( + &mut self, + uri: &str, + position: Option, + size: Option, + ) -> Result { + let (object_store, path) = ObjectStore::from_uri_and_params( + self.source_store_registry.clone(), + uri, + &self.source_store_params, + ) + .await?; + let reader = object_store.open(&path).await?; + match (position, size) { + (Some(position), Some(size)) => { + position.checked_add(size).ok_or_else(|| { + Error::invalid_input(format!( + "External blob range overflows u64: position={}, size={}", + position, size + )) + })?; + Ok(ExternalBlobSource { + reader, + start: position, + size, + }) + } + (None, None) => { + let size = reader.size().await? as u64; + Ok(ExternalBlobSource { + reader, + start: 0, + size, + }) + } + _ => Err(Error::invalid_input(format!( + "External blob URI '{}' must set both position and size when slicing for ingest", + uri + ))), + } + } + pub(crate) async fn preprocess_batch(&mut self, batch: &RecordBatch) -> Result { let expected_columns = self.blob_v2_cols.len(); if batch.num_columns() != expected_columns { @@ -374,7 +517,8 @@ impl BlobPreprocessor { let dedicated_threshold = self.dedicated_thresholds[idx]; if has_data && data_len > dedicated_threshold { let blob_id = self.next_blob_id(); - self.write_dedicated(blob_id, data_col.value(i)).await?; + self.write_dedicated(blob_id, BlobWriteSource::Bytes(data_col.value(i))) + .await?; kind_builder.append_value(BlobKind::Dedicated as u8); data_builder.append_null(); @@ -386,7 +530,9 @@ impl BlobPreprocessor { } if has_data && data_len > INLINE_MAX { - let (pack_blob_id, position) = self.write_packed(data_col.value(i)).await?; + let (pack_blob_id, position) = self + .write_packed(BlobWriteSource::Bytes(data_col.value(i))) + .await?; kind_builder.append_value(BlobKind::Packed as u8); data_builder.append_null(); @@ -399,6 +545,64 @@ impl BlobPreprocessor { if has_uri { let uri_val = uri_col.value(i); + if self.external_blob_mode == ExternalBlobMode::Ingest { + let position = if has_position { + Some( + position_col + .as_ref() + .expect("position column must exist") + .value(i), + ) + } else { + None + }; + let size = if has_size { + Some(size_col.as_ref().expect("size column must exist").value(i)) + } else { + None + }; + let source = self.open_external_source(uri_val, position, size).await?; + let data_len = source.size(); + + if data_len > dedicated_threshold as u64 { + let blob_id = self.next_blob_id(); + self.write_dedicated(blob_id, BlobWriteSource::External(&source)) + .await?; + + kind_builder.append_value(BlobKind::Dedicated as u8); + data_builder.append_null(); + uri_builder.append_null(); + blob_id_builder.append_value(blob_id); + blob_size_builder.append_value(data_len); + position_builder.append_null(); + continue; + } + + if data_len > INLINE_MAX as u64 { + let (pack_blob_id, position) = self + .write_packed(BlobWriteSource::External(&source)) + .await?; + + kind_builder.append_value(BlobKind::Packed as u8); + data_builder.append_null(); + uri_builder.append_null(); + blob_id_builder.append_value(pack_blob_id); + blob_size_builder.append_value(data_len); + position_builder.append_value(position); + continue; + } + + let data = source.read_all().await?; + + kind_builder.append_value(BlobKind::Inline as u8); + data_builder.append_value(data.as_ref()); + uri_builder.append_null(); + blob_id_builder.append_null(); + blob_size_builder.append_null(); + position_builder.append_null(); + continue; + } + let (external_base_id, external_uri_or_path) = self.resolve_external_reference(uri_val).await?; kind_builder.append_value(BlobKind::External as u8); @@ -1124,11 +1328,15 @@ mod tests { datatypes::{UInt8Type, UInt32Type, UInt64Type}, }; use arrow_array::RecordBatch; - use arrow_array::{RecordBatchIterator, UInt32Array}; + use arrow_array::{ + ArrayRef, RecordBatchIterator, StringArray, StructArray, UInt32Array, UInt64Array, + }; use arrow_schema::{DataType, Field, Schema}; use async_trait::async_trait; use futures::TryStreamExt; - use lance_arrow::{BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY, DataTypeExt}; + use lance_arrow::{ + ARROW_EXT_NAME_KEY, BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY, BLOB_V2_EXT_NAME, DataTypeExt, + }; use lance_core::datatypes::BlobKind; use lance_io::object_store::{ObjectStore, ObjectStoreParams, ObjectStoreRegistry}; use lance_io::stream::RecordBatchStream; @@ -1150,7 +1358,7 @@ mod tests { use crate::{ Dataset, blob::{BlobArrayBuilder, blob_field}, - dataset::WriteParams, + dataset::{ExternalBlobMode, WriteParams}, utils::test::TestDatasetGenerator, }; @@ -1927,6 +2135,200 @@ mod tests { assert_eq!(blobs[0].read().await.unwrap().as_ref(), b"mapped"); } + #[tokio::test] + async fn test_blob_v2_external_ingest_inline_slice() { + let dataset_dir = TempDir::default(); + let external_dir = TempDir::default(); + let external_path = external_dir.std_path().join("external.bin"); + std::fs::write(&external_path, b"prefix-inline-suffix").unwrap(); + let external_uri = format!("file://{}", external_path.display()); + + let metadata = [(ARROW_EXT_NAME_KEY.to_string(), BLOB_V2_EXT_NAME.to_string())] + .into_iter() + .collect(); + let blob_field = Field::new( + "blob", + DataType::Struct( + vec![ + Field::new("data", DataType::LargeBinary, true), + Field::new("uri", DataType::Utf8, true), + Field::new("position", DataType::UInt64, true), + Field::new("size", DataType::UInt64, true), + ] + .into(), + ), + true, + ) + .with_metadata(metadata); + let blob_array: ArrayRef = Arc::new( + StructArray::try_new( + vec![ + Field::new("data", DataType::LargeBinary, true), + Field::new("uri", DataType::Utf8, true), + Field::new("position", DataType::UInt64, true), + Field::new("size", DataType::UInt64, true), + ] + .into(), + vec![ + Arc::new(arrow_array::LargeBinaryArray::from(vec![None::<&[u8]>])) as ArrayRef, + Arc::new(StringArray::from(vec![Some(external_uri.as_str())])) as ArrayRef, + Arc::new(UInt64Array::from(vec![Some(7)])) as ArrayRef, + Arc::new(UInt64Array::from(vec![Some(6)])) as ArrayRef, + ], + None, + ) + .unwrap(), + ); + let schema = Arc::new(Schema::new(vec![blob_field])); + let batch = RecordBatch::try_new(schema.clone(), vec![blob_array]).unwrap(); + let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema); + + let dataset = Arc::new( + Dataset::write( + reader, + &dataset_dir.path_str(), + Some(WriteParams { + data_storage_version: Some(LanceFileVersion::V2_2), + external_blob_mode: ExternalBlobMode::Ingest, + ..Default::default() + }), + ) + .await + .unwrap(), + ); + + let desc = dataset + .scan() + .project(&["blob"]) + .unwrap() + .try_into_batch() + .await + .unwrap() + .column(0) + .as_struct() + .to_owned(); + assert_eq!( + desc.column_by_name("kind") + .unwrap() + .as_primitive::() + .value(0), + BlobKind::Inline as u8 + ); + + let blobs = dataset.take_blobs_by_indices(&[0], "blob").await.unwrap(); + assert_eq!(blobs.len(), 1); + assert_eq!(blobs[0].kind(), BlobKind::Inline); + assert_eq!(blobs[0].read().await.unwrap().as_ref(), b"inline"); + } + + #[tokio::test] + async fn test_blob_v2_external_ingest_packed() { + let dataset_dir = TempDir::default(); + let external_dir = TempDir::default(); + let external_path = external_dir.std_path().join("external.bin"); + let payload = vec![0x5A; super::INLINE_MAX + 1024]; + std::fs::write(&external_path, &payload).unwrap(); + let external_uri = format!("file://{}", external_path.display()); + + let mut blob_builder = BlobArrayBuilder::new(1); + blob_builder.push_uri(external_uri).unwrap(); + let blob_array: arrow_array::ArrayRef = blob_builder.finish().unwrap(); + let schema = Arc::new(Schema::new(vec![blob_field("blob", true)])); + let batch = RecordBatch::try_new(schema.clone(), vec![blob_array]).unwrap(); + let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema); + + let dataset = Arc::new( + Dataset::write( + reader, + &dataset_dir.path_str(), + Some(WriteParams { + data_storage_version: Some(LanceFileVersion::V2_2), + external_blob_mode: ExternalBlobMode::Ingest, + ..Default::default() + }), + ) + .await + .unwrap(), + ); + + let desc = dataset + .scan() + .project(&["blob"]) + .unwrap() + .try_into_batch() + .await + .unwrap() + .column(0) + .as_struct() + .to_owned(); + assert_eq!( + desc.column_by_name("kind") + .unwrap() + .as_primitive::() + .value(0), + BlobKind::Packed as u8 + ); + + let blobs = dataset.take_blobs_by_indices(&[0], "blob").await.unwrap(); + assert_eq!(blobs.len(), 1); + assert_eq!(blobs[0].kind(), BlobKind::Packed); + assert_eq!(blobs[0].read().await.unwrap().as_ref(), payload.as_slice()); + } + + #[tokio::test] + async fn test_blob_v2_external_ingest_dedicated() { + let dataset_dir = TempDir::default(); + let external_dir = TempDir::default(); + let external_path = external_dir.std_path().join("external.bin"); + let payload = vec![0x7A; super::DEDICATED_THRESHOLD + 1024]; + std::fs::write(&external_path, &payload).unwrap(); + let external_uri = format!("file://{}", external_path.display()); + + let mut blob_builder = BlobArrayBuilder::new(1); + blob_builder.push_uri(external_uri).unwrap(); + let blob_array: arrow_array::ArrayRef = blob_builder.finish().unwrap(); + let schema = Arc::new(Schema::new(vec![blob_field("blob", true)])); + let batch = RecordBatch::try_new(schema.clone(), vec![blob_array]).unwrap(); + let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema); + + let dataset = Arc::new( + Dataset::write( + reader, + &dataset_dir.path_str(), + Some(WriteParams { + data_storage_version: Some(LanceFileVersion::V2_2), + external_blob_mode: ExternalBlobMode::Ingest, + ..Default::default() + }), + ) + .await + .unwrap(), + ); + + let desc = dataset + .scan() + .project(&["blob"]) + .unwrap() + .try_into_batch() + .await + .unwrap() + .column(0) + .as_struct() + .to_owned(); + assert_eq!( + desc.column_by_name("kind") + .unwrap() + .as_primitive::() + .value(0), + BlobKind::Dedicated as u8 + ); + + let blobs = dataset.take_blobs_by_indices(&[0], "blob").await.unwrap(); + assert_eq!(blobs.len(), 1); + assert_eq!(blobs[0].kind(), BlobKind::Dedicated); + assert_eq!(blobs[0].read().await.unwrap().as_ref(), payload.as_slice()); + } + #[tokio::test] async fn test_blob_v2_requires_v2_2() { let test_dir = TempStrDir::default(); @@ -1994,6 +2396,9 @@ mod tests { &writer_schema, None, false, + ExternalBlobMode::Reference, + Arc::new(ObjectStoreRegistry::default()), + ObjectStoreParams::default(), ); let mut blob_builder = BlobArrayBuilder::new(1); diff --git a/rust/lance/src/dataset/write.rs b/rust/lance/src/dataset/write.rs index 6b226969b08..46c4e47c81d 100644 --- a/rust/lance/src/dataset/write.rs +++ b/rust/lance/src/dataset/write.rs @@ -133,6 +133,43 @@ impl TryFrom<&str> for WriteMode { } } +/// The strategy for handling external blob URIs on write. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ExternalBlobMode { + /// Store the URI as an external blob reference. + #[default] + Reference, + /// Read the external bytes during write and store them in Lance-managed storage. + Ingest, +} + +impl TryFrom<&str> for ExternalBlobMode { + type Error = Error; + + fn try_from(value: &str) -> Result { + match value.to_lowercase().as_str() { + "reference" => Ok(Self::Reference), + "ingest" => Ok(Self::Ingest), + _ => Err(Error::invalid_input(format!( + "Invalid external blob mode: {}", + value + ))), + } + } +} + +fn validate_external_blob_write_params(params: &WriteParams) -> Result<()> { + if params.external_blob_mode == ExternalBlobMode::Ingest + && params.allow_external_blob_outside_bases + { + return Err(Error::invalid_input( + "allow_external_blob_outside_bases only applies when external_blob_mode=\"reference\"", + )); + } + + Ok(()) +} + /// Auto cleanup parameters #[derive(Debug, Clone)] pub struct AutoCleanupParams { @@ -258,6 +295,9 @@ pub struct WriteParams { /// Allow writing external blob URIs that cannot be mapped to any registered /// non-dataset-root base path. When disabled, such rows are rejected. pub allow_external_blob_outside_bases: bool, + + /// The strategy used when writing external blob URIs. + pub external_blob_mode: ExternalBlobMode, } impl Default for WriteParams { @@ -284,6 +324,7 @@ impl Default for WriteParams { target_bases: None, target_base_names_or_paths: None, allow_external_blob_outside_bases: false, + external_blob_mode: ExternalBlobMode::Reference, } } } @@ -370,6 +411,14 @@ impl WriteParams { ..self } } + + /// Configure how external blob URIs are handled during writes. + pub fn with_external_blob_mode(self, mode: ExternalBlobMode) -> Self { + Self { + external_blob_mode: mode, + ..self + } + } } /// Writes the given data to the dataset and returns fragments. @@ -426,6 +475,10 @@ pub async fn do_write_fragments( } else { None }; + let source_store_registry = dataset + .map(|ds| ds.session.store_registry()) + .unwrap_or_else(|| params.store_registry()); + let source_store_params = params.store_params.clone().unwrap_or_default(); let writer_generator = WriterGenerator::new( object_store, @@ -435,6 +488,9 @@ pub async fn do_write_fragments( target_bases_info, external_base_resolver, params.allow_external_blob_outside_bases, + params.external_blob_mode, + source_store_registry, + source_store_params, ); let mut writer: Option> = None; let mut num_rows_in_current_file = 0; @@ -730,6 +786,7 @@ pub async fn write_fragments_internal( // Make sure the max rows per group is not larger than the max rows per file params.max_rows_per_group = std::cmp::min(params.max_rows_per_group, params.max_rows_per_file); + validate_external_blob_write_params(¶ms)?; let (schema, storage_version) = if let Some(dataset) = dataset { match params.mode { @@ -939,6 +996,9 @@ struct WriterOptions { base_id: Option, external_base_resolver: Option>, allow_external_blob_outside_bases: bool, + external_blob_mode: ExternalBlobMode, + source_store_registry: Arc, + source_store_params: ObjectStoreParams, } async fn open_writer_with_options( @@ -953,6 +1013,9 @@ async fn open_writer_with_options( base_id, external_base_resolver, allow_external_blob_outside_bases, + external_blob_mode, + source_store_registry, + source_store_params, } = options; let data_file_key = generate_random_filename(); @@ -997,6 +1060,9 @@ async fn open_writer_with_options( schema, external_base_resolver, allow_external_blob_outside_bases, + external_blob_mode, + source_store_registry, + source_store_params, )) } else { None @@ -1036,11 +1102,15 @@ struct WriterGenerator { target_bases_info: Option>, external_base_resolver: Option>, allow_external_blob_outside_bases: bool, + external_blob_mode: ExternalBlobMode, + source_store_registry: Arc, + source_store_params: ObjectStoreParams, /// Counter for round-robin selection next_base_index: AtomicUsize, } impl WriterGenerator { + #[allow(clippy::too_many_arguments)] pub fn new( object_store: Arc, base_dir: &Path, @@ -1049,6 +1119,9 @@ impl WriterGenerator { target_bases_info: Option>, external_base_resolver: Option>, allow_external_blob_outside_bases: bool, + external_blob_mode: ExternalBlobMode, + source_store_registry: Arc, + source_store_params: ObjectStoreParams, ) -> Self { Self { object_store, @@ -1058,6 +1131,9 @@ impl WriterGenerator { target_bases_info, external_base_resolver, allow_external_blob_outside_bases, + external_blob_mode, + source_store_registry, + source_store_params, next_base_index: AtomicUsize::new(0), } } @@ -1088,6 +1164,9 @@ impl WriterGenerator { base_id: Some(base_info.base_id), external_base_resolver: self.external_base_resolver.clone(), allow_external_blob_outside_bases: self.allow_external_blob_outside_bases, + external_blob_mode: self.external_blob_mode, + source_store_registry: self.source_store_registry.clone(), + source_store_params: self.source_store_params.clone(), }, ) .await? @@ -1102,6 +1181,9 @@ impl WriterGenerator { base_id: None, external_base_resolver: self.external_base_resolver.clone(), allow_external_blob_outside_bases: self.allow_external_blob_outside_bases, + external_blob_mode: self.external_blob_mode, + source_store_registry: self.source_store_registry.clone(), + source_store_params: self.source_store_params.clone(), }, ) .await? @@ -1735,6 +1817,9 @@ mod tests { Some(target_bases), None, false, + ExternalBlobMode::Reference, + Arc::new(ObjectStoreRegistry::default()), + ObjectStoreParams::default(), ); // Create a writer @@ -1849,6 +1934,9 @@ mod tests { Some(target_bases), None, false, + ExternalBlobMode::Reference, + Arc::new(ObjectStoreRegistry::default()), + ObjectStoreParams::default(), ); // Create test batch @@ -1944,6 +2032,8 @@ mod tests { } fn validate_write_params(params: &WriteParams) -> Result<()> { + validate_external_blob_write_params(params)?; + // Replicate the validation logic from the main write function if matches!(params.mode, WriteMode::Create) && let Some(target_bases) = ¶ms.target_bases @@ -1971,6 +2061,21 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_external_blob_mode_validation() { + let params = WriteParams { + external_blob_mode: ExternalBlobMode::Ingest, + allow_external_blob_outside_bases: true, + ..Default::default() + }; + + let err = validate_write_params(¶ms).unwrap_err(); + assert!( + err.to_string() + .contains("allow_external_blob_outside_bases only applies") + ); + } + #[tokio::test] async fn test_multi_base_create() { use lance_testing::datagen::{BatchGenerator, IncrementingInt32}; From 8bd9b90607f96b4416b34b71882fceb361cf6eca Mon Sep 17 00:00:00 2001 From: Peng Wang Date: Thu, 2 Apr 2026 14:54:42 -0400 Subject: [PATCH 128/206] =?UTF-8?q?perf:=20fix=20O(N=C2=B2)=20version=20co?= =?UTF-8?q?lumn=20scan=20with=20deletion=20vectors=20(#6375)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Scanning `_row_created_at_version` or `_row_last_updated_at_version` is extremely slow on fragments with deletion vectors — **53 seconds for 1M rows** (vs 0.03s for a regular data column on the same table). This makes the `delta()` API (`get_inserted_rows()` / `get_updated_rows()`) unusable on any table that has had deletions. ## Root Cause In `apply_row_id_and_deletes()` (`lance-table/src/utils/stream.rs`), the version column is built by: ```rust sequence.versions() .skip(r.start as usize) .take((r.end - r.start) as usize) ``` `versions()` creates a fresh iterator from the start of the RLE sequence. For each range in the selection, `skip(r.start)` walks through all prior elements — O(rows) per range. With deletion vectors creating many small ranges, this becomes O(rows × ranges). ## Fix Replace with `version_values_for_selection()` that: - **Fast-paths single-run fragments** (common case: all rows same version) with `vec![version; count]` — O(1) - **Binary search over precomputed run offsets** for O(log(runs)) per position in the multi-run case ## Benchmark 1M rows, 33% deleted, `_row_created_at_version` scan: | | Before | After | |---|---|---| | Time | 53s | 0.02s | | Complexity | O(rows × ranges) | O(rows × log(runs)) | ## Minimal Reproduction ```python import lance, pyarrow as pa, numpy as np, time uri = "/tmp/test_version_col_perf.lance" lance.write_dataset( pa.table({"val": np.random.randint(0, 1000, 1_000_000)}), uri, mode="overwrite", enable_stable_row_ids=True, ) ds = lance.dataset(uri) ds.delete("val < 333") ds = lance.dataset(uri) t0 = time.time() ds.scanner(columns=["_row_created_at_version"]).to_table() print(f"_row_created_at_version with deletions: {time.time()-t0:.2f}s") t0 = time.time() ds.scanner(columns=["val"]).to_table() print(f"normal column with deletions: {time.time()-t0:.2f}s") ``` --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-table/src/utils/stream.rs | 228 ++++++++++++++++++++++----- 1 file changed, 192 insertions(+), 36 deletions(-) diff --git a/rust/lance-table/src/utils/stream.rs b/rust/lance-table/src/utils/stream.rs index 31f5fc87ced..f6fbbd45a61 100644 --- a/rust/lance-table/src/utils/stream.rs +++ b/rust/lance-table/src/utils/stream.rs @@ -161,6 +161,56 @@ fn apply_deletions_as_nulls(batch: RecordBatch, mask: &BooleanArray) -> Result Result> { + let selection = params + .slice(batch_offset as usize, num_rows as usize) + .unwrap() + .to_ranges() + .unwrap(); + + if sequence.runs.len() == 1 { + return Ok(vec![sequence.runs[0].version(); num_rows as usize]); + } + + let mut versions = Vec::with_capacity(num_rows as usize); + let run_offsets: Vec = sequence + .runs + .iter() + .scan(0usize, |acc, run| { + let start = *acc; + *acc += run.len(); + Some(start) + }) + .collect(); + let total_len: usize = sequence.runs.iter().map(|r| r.len()).sum(); + + for r in &selection { + for pos in r.start..r.end { + let pos = pos as usize; + if pos >= total_len { + return Err(lance_core::Error::internal(format!( + "version column position {} out of range (total_len={})", + pos, total_len + ))); + } + let run_idx = match run_offsets.binary_search(&pos) { + Ok(idx) => idx, + Err(idx) => idx - 1, + }; + versions.push(sequence.runs[run_idx].version()); + } + } + Ok(versions) +} + /// Configuration needed to apply row ids and deletions to a batch #[derive(Debug)] pub struct RowIdAndDeletesConfig { @@ -296,24 +346,12 @@ pub fn apply_row_id_and_deletes( if config.with_row_last_updated_at_version { let version_arr = if let Some(sequence) = &config.last_updated_at_sequence { - // Get the range of rows for this batch - let selection = config - .params - .slice(batch_offset as usize, num_rows as usize) - .unwrap() - .to_ranges() - .unwrap(); - // Extract version values for the selected ranges - let versions: Vec = selection - .iter() - .flat_map(|r| { - sequence - .versions() - .skip(r.start as usize) - .take((r.end - r.start) as usize) - }) - .collect(); - Arc::new(UInt64Array::from(versions)) + Arc::new(UInt64Array::from(version_values_for_selection( + sequence, + &config.params, + batch_offset, + num_rows, + )?)) } else { // Default to version 1 if sequence not provided Arc::new(UInt64Array::from(vec![1u64; num_rows as usize])) @@ -324,24 +362,12 @@ pub fn apply_row_id_and_deletes( if config.with_row_created_at_version { let version_arr = if let Some(sequence) = &config.created_at_sequence { - // Get the range of rows for this batch - let selection = config - .params - .slice(batch_offset as usize, num_rows as usize) - .unwrap() - .to_ranges() - .unwrap(); - // Extract version values for the selected ranges - let versions: Vec = selection - .iter() - .flat_map(|r| { - sequence - .versions() - .skip(r.start as usize) - .take((r.end - r.start) as usize) - }) - .collect(); - Arc::new(UInt64Array::from(versions)) + Arc::new(UInt64Array::from(version_values_for_selection( + sequence, + &config.params, + batch_offset, + num_rows, + )?)) } else { // Default to version 1 if sequence not provided Arc::new(UInt64Array::from(vec![1u64; num_rows as usize])) @@ -647,4 +673,134 @@ mod tests { } } } + + #[tokio::test] + async fn test_version_column_with_deletions() { + use crate::rowids::segment::U64Segment; + use crate::rowids::version::{RowDatasetVersionRun, RowDatasetVersionSequence}; + + let seq = Arc::new(RowDatasetVersionSequence { + runs: vec![RowDatasetVersionRun { + span: U64Segment::Range(0..100), + version: 42, + }], + }); + + let data = batch_task_stream( + lance_datagen::gen_batch() + .col("x", lance_datagen::array::rand::()) + .into_reader_stream(RowCount::from(10), BatchCount::from(10)) + .0, + ); + + let config = RowIdAndDeletesConfig { + params: ReadBatchParams::RangeFull, + with_row_id: true, + with_row_addr: false, + with_row_last_updated_at_version: false, + with_row_created_at_version: true, + deletion_vector: Some(Arc::new(DeletionVector::Bitmap(RoaringBitmap::from_iter( + 0..35, + )))), + row_id_sequence: None, + last_updated_at_sequence: None, + created_at_sequence: Some(seq), + make_deletions_null: false, + total_num_rows: 100, + }; + let stream = super::wrap_with_row_id_and_delete(data, 0, config); + let batches: Vec<_> = stream + .buffered(1) + .try_filter(|b| std::future::ready(b.num_rows() > 0)) + .try_collect() + .await + .unwrap(); + + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 65); + + for batch in &batches { + let versions = batch + .column_by_name("_row_created_at_version") + .unwrap() + .as_primitive::() + .values(); + assert!(versions.iter().all(|&v| v == 42)); + } + } + + #[tokio::test] + async fn test_version_column_multi_run() { + use crate::rowids::segment::U64Segment; + use crate::rowids::version::{RowDatasetVersionRun, RowDatasetVersionSequence}; + + // 3 runs: 0..40 v1, 40..70 v2, 70..100 v3 + let seq = Arc::new(RowDatasetVersionSequence { + runs: vec![ + RowDatasetVersionRun { + span: U64Segment::Range(0..40), + version: 1, + }, + RowDatasetVersionRun { + span: U64Segment::Range(40..70), + version: 2, + }, + RowDatasetVersionRun { + span: U64Segment::Range(70..100), + version: 3, + }, + ], + }); + + // Delete 0..20 and 60..80 (spans run boundary). + // Survivors: 20..40 (v1), 40..60 (v2), 80..100 (v3) = 60 rows + let mut deletions = RoaringBitmap::from_iter(0..20); + deletions.extend(60..80); + + let data = batch_task_stream( + lance_datagen::gen_batch() + .col("x", lance_datagen::array::rand::()) + .into_reader_stream(RowCount::from(10), BatchCount::from(10)) + .0, + ); + + let config = RowIdAndDeletesConfig { + params: ReadBatchParams::RangeFull, + with_row_id: true, + with_row_addr: false, + with_row_last_updated_at_version: false, + with_row_created_at_version: true, + deletion_vector: Some(Arc::new(DeletionVector::Bitmap(deletions))), + row_id_sequence: None, + last_updated_at_sequence: None, + created_at_sequence: Some(seq), + make_deletions_null: false, + total_num_rows: 100, + }; + let stream = super::wrap_with_row_id_and_delete(data, 0, config); + let batches: Vec<_> = stream + .buffered(1) + .try_filter(|b| std::future::ready(b.num_rows() > 0)) + .try_collect() + .await + .unwrap(); + + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 60); + + let all_versions: Vec = batches + .iter() + .flat_map(|b| { + b.column_by_name("_row_created_at_version") + .unwrap() + .as_primitive::() + .values() + .to_vec() + }) + .collect(); + + assert!(all_versions[..20].iter().all(|&v| v == 1)); + assert!(all_versions[20..40].iter().all(|&v| v == 2)); + assert!(all_versions[40..60].iter().all(|&v| v == 3)); + } } From 2b62004461a80ab4a0810205e4fc7fe7bb1b12a2 Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Thu, 2 Apr 2026 14:31:40 -0500 Subject: [PATCH 129/206] fix: ensure durable writes actually wait for WAL flush (#6368) track_batch_for_wal was returning a pre-resolved watcher instead of the actual BatchDurableWatcher, so durable_write=true never blocked waiting for the WAL flush to complete. Also include close_duration in benchmark timing for accurate end-to-end throughput reporting. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance/benches/mem_wal_write.rs | 2 +- rust/lance/src/dataset/mem_wal/wal.rs | 33 +++++++++++++++++++++++++ rust/lance/src/dataset/mem_wal/write.rs | 12 +++------ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/rust/lance/benches/mem_wal_write.rs b/rust/lance/benches/mem_wal_write.rs index 31f855fd0ad..5de7867288d 100644 --- a/rust/lance/benches/mem_wal_write.rs +++ b/rust/lance/benches/mem_wal_write.rs @@ -620,7 +620,7 @@ fn bench_lance_memwal_write(c: &mut Criterion) { // Get stats after close to include all WAL flushes let stats = stats_handle.snapshot(); - total_duration += put_duration; + total_duration += put_duration + close_duration; // Report stats once (first iteration of first sample only) if iter == 0 && !stats_printed.swap(true, Ordering::SeqCst) { diff --git a/rust/lance/src/dataset/mem_wal/wal.rs b/rust/lance/src/dataset/mem_wal/wal.rs index a0b7a4c912a..d212be661e7 100644 --- a/rust/lance/src/dataset/mem_wal/wal.rs +++ b/rust/lance/src/dataset/mem_wal/wal.rs @@ -587,6 +587,39 @@ mod tests { assert!(!watcher.is_durable()); } + // Regression test: track_batch must return a watcher wired to the real + // WAL watermark, NOT a pre-resolved watcher. A pre-resolved watcher would + // cause durable writes to return before the WAL is actually flushed. + #[tokio::test] + async fn test_track_batch_watcher_blocks_until_flush() { + let (store, base_path, _temp_dir) = create_local_store().await; + let region_id = Uuid::new_v4(); + let mut flusher = WalFlusher::new(&base_path, region_id, 1, 1); + flusher.set_object_store(store); + + let schema = create_test_schema(); + let batch_store = BatchStore::with_capacity(10); + batch_store.append(create_test_batch(&schema, 10)).unwrap(); + + let mut watcher = flusher.track_batch(0); + + // wait() must NOT resolve before the flush happens + let result = + tokio::time::timeout(std::time::Duration::from_millis(100), watcher.wait()).await; + assert!( + result.is_err(), + "watcher resolved before WAL flush — durability guarantee broken" + ); + + // Flush, then the watcher should resolve + flusher + .flush_to_with_index_update(&batch_store, batch_store.len(), None) + .await + .unwrap(); + watcher.wait().await.unwrap(); + assert!(watcher.is_durable()); + } + #[tokio::test] async fn test_wal_flusher_flush_to_with_index_update() { let (store, base_path, _temp_dir) = create_local_store().await; diff --git a/rust/lance/src/dataset/mem_wal/write.rs b/rust/lance/src/dataset/mem_wal/write.rs index dda06a7c9d9..6f108c87da4 100644 --- a/rust/lance/src/dataset/mem_wal/write.rs +++ b/rust/lance/src/dataset/mem_wal/write.rs @@ -799,12 +799,8 @@ impl SharedWriterState { } /// Track batch for WAL durability. - fn track_batch_for_wal(&self, batch_position: usize) -> DurabilityWatcher { - let _wal_watcher = self.wal_flusher.track_batch(batch_position); - // Return pre-resolved watcher for non-durable case - let cell: WatchableOnceCell = WatchableOnceCell::new(); - cell.write(DurabilityResult::ok()); - cell.reader() + fn track_batch_for_wal(&self, batch_position: usize) -> super::wal::BatchDurableWatcher { + self.wal_flusher.track_batch(batch_position) } /// Check if memtable flush is needed and trigger if so. @@ -1132,7 +1128,7 @@ impl RegionWriter { let start = std::time::Instant::now(); // Acquire write lock for entire operation (atomic approach) - let (batch_positions, durable_watcher, batch_store, indexes) = { + let (batch_positions, mut durable_watcher, batch_store, indexes) = { let mut state = self.state.write().await; // 1. Insert all batches into memtable atomically @@ -1170,7 +1166,7 @@ impl RegionWriter { // Must trigger a flush to ensure durability (flush up to and including all batches) self.wal_flusher .trigger_flush(batch_store, indexes, batch_positions.end, None)?; - durable_watcher.clone().await_value().await.into_result()?; + durable_watcher.wait().await?; } Ok(WriteResult { batch_positions }) From cc89488bcc995d89a9aeb4ad80a82458f9384576 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 2 Apr 2026 14:19:05 -0700 Subject: [PATCH 130/206] feat!: add progress monitoring via callbacks for distributed merge (#6210) --- java/lance-jni/src/blocking_dataset.rs | 3 +- python/src/dataset.rs | 8 +- rust/lance-index/src/progress.rs | 13 +- rust/lance-index/src/scalar/btree.rs | 90 ++++++- .../src/scalar/inverted/builder.rs | 186 +++++++++++--- .../src/vector/distributed/index_merger.rs | 131 +++++++++- rust/lance-testing/src/lib.rs | 1 + rust/lance-testing/src/progress.rs | 49 ++++ .../lance/benches/distributed_vector_build.rs | 2 + rust/lance/src/dataset.rs | 8 +- rust/lance/src/index/create.rs | 238 +++++++++++++++--- rust/lance/src/index/vector/ivf.rs | 38 ++- rust/lance/src/index/vector/ivf/v2.rs | 130 ++++++++++ 13 files changed, 798 insertions(+), 99 deletions(-) create mode 100644 rust/lance-testing/src/progress.rs diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index 91fe5d3e715..ee8de347ad7 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -47,6 +47,7 @@ use lance_core::datatypes::Schema as LanceSchema; use lance_file::version::LanceFileVersion; use lance_index::IndexCriteria as RustIndexCriteria; use lance_index::optimize::OptimizeOptions; +use lance_index::progress::noop_progress; use lance_index::scalar::btree::BTreeParameters; use lance_index::{IndexParams, IndexType}; use lance_io::object_store::ObjectStoreRegistry; @@ -1062,7 +1063,7 @@ fn inner_merge_index_metadata( RT.block_on(async { dataset_guard .inner - .merge_index_metadata(&index_uuid, index_type, batch_readhead) + .merge_index_metadata(&index_uuid, index_type, batch_readhead, noop_progress()) .await })?; Ok(()) diff --git a/python/src/dataset.rs b/python/src/dataset.rs index 70895880ed0..d1b547471bd 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -70,6 +70,7 @@ use lance_index::scalar::inverted::query::{ use lance_index::{ IndexParams, IndexType, optimize::OptimizeOptions, + progress::NoopIndexBuildProgress, scalar::{FullTextSearchQuery, InvertedIndexParams, ScalarIndexParams}, vector::{ Query as VectorQuery, hnsw::builder::HnswBuildParams, ivf::IvfBuildParams, @@ -2097,7 +2098,12 @@ impl Dataset { ) -> PyResult<()> { rt().block_on(None, async { self.ds - .merge_index_metadata(index_uuid, IndexType::try_from(index_type)?, batch_readhead) + .merge_index_metadata( + index_uuid, + IndexType::try_from(index_type)?, + batch_readhead, + Arc::new(NoopIndexBuildProgress), + ) .await })? .map_err(|err| PyValueError::new_err(err.to_string())) diff --git a/rust/lance-index/src/progress.rs b/rust/lance-index/src/progress.rs index b636cd1d765..b02ec4280ba 100644 --- a/rust/lance-index/src/progress.rs +++ b/rust/lance-index/src/progress.rs @@ -5,12 +5,15 @@ use async_trait::async_trait; use lance_core::Result; use std::sync::Arc; -/// Progress callback for index building. +/// Progress callback for index building and distributed index finalization. /// -/// Called at stage boundaries during index construction. Stages are sequential: `stage_complete` -/// is always called before the next `stage_start`, so only one stage is active at a time. Stage -/// names are index-type-specific (e.g. "train_ivf", "shuffle", "merge_partitions" for vector -/// indices; "load_data", "build_pages" for scalar indices). +/// Called at stage boundaries during index construction. For a single logical stream, stages are +/// sequential: `stage_complete` is always called before the next `stage_start`, so only one stage +/// is active at a time. Callers that orchestrate independent sub-builds in parallel may prefix +/// stage names (for example `segment_plan[0]/merge_partitions`) to represent separate logical +/// streams. Stage names are index-type-specific (e.g. "train_ivf", "shuffle", "merge_partitions" +/// for vector indices; "load_data", "build_pages" for scalar indices; merge/finalization stages +/// for distributed index construction). /// /// Methods take `&self` to allow concurrent calls from within a single stage. Implementations /// must be thread-safe. diff --git a/rust/lance-index/src/scalar/btree.rs b/rust/lance-index/src/scalar/btree.rs index 3fa05dfd131..1df740e815f 100644 --- a/rust/lance-index/src/scalar/btree.rs +++ b/rust/lance-index/src/scalar/btree.rs @@ -18,6 +18,7 @@ use super::{ use crate::{Index, IndexType}; use crate::{ frag_reuse::FragReuseIndex, + progress::{IndexBuildProgress, noop_progress}, scalar::{ CreatedIndex, UpdateCriteria, expression::{SargableQueryParser, ScalarQueryParser}, @@ -1659,7 +1660,14 @@ impl ScalarIndex for BTreeIndex { let lookup_files = (0..num_parts) .map(|part_id| part_lookup_file_path((part_id as u64) << 32)) .collect::>(); - merge_metadata_files(dest_store, &page_files, &lookup_files, None).await?; + merge_metadata_files( + dest_store, + &page_files, + &lookup_files, + None, + noop_progress(), + ) + .await?; } Ok(CreatedIndex { @@ -1925,6 +1933,7 @@ pub async fn merge_index_files( index_dir: &Path, store: Arc, batch_readhead: Option, + progress: Arc, ) -> Result<()> { // List all partition page / lookup files in the index directory let (part_page_files, part_lookup_files) = @@ -1934,6 +1943,7 @@ pub async fn merge_index_files( &part_page_files, &part_lookup_files, batch_readhead, + progress, ) .await } @@ -1989,6 +1999,7 @@ async fn merge_metadata_files( part_page_files: &[String], part_lookup_files: &[String], batch_readhead: Option, + progress: Arc, ) -> Result<()> { if part_lookup_files.is_empty() || part_page_files.is_empty() { return Err(Error::internal( @@ -2062,6 +2073,7 @@ async fn merge_metadata_files( metadata, batch_size, batch_readhead, + progress, ) .await } else { @@ -2074,6 +2086,7 @@ async fn merge_metadata_files( metadata, batch_size, batch_readhead, + progress, ) .await } @@ -2115,6 +2128,7 @@ async fn merge_range_partitioned_lookups( mut metadata: HashMap, batch_size: u64, batch_readhead: Option, + progress: Arc, ) -> Result<()> { let sorted_part_lookup_files = sort_files_by_partition_id(part_lookup_files)?; let mut lookup_file = store @@ -2125,7 +2139,15 @@ async fn merge_range_partitioned_lookups( let mut pages_per_file: Vec<(u64, u32)> = Vec::with_capacity(sorted_part_lookup_files.len()); let mut num_pages_written = 0u32; - for (part_id, part_lookup_file) in sorted_part_lookup_files { + progress + .stage_start( + "merge_lookups", + Some(sorted_part_lookup_files.len() as u64), + "files", + ) + .await?; + + for (idx, (part_id, part_lookup_file)) in sorted_part_lookup_files.into_iter().enumerate() { let lookup_reader = store.open_index_file(&part_lookup_file).await?; let reader_stream = IndexReaderStream::new(lookup_reader.clone(), batch_size).await; let mut stream = reader_stream.buffered(batch_readhead.unwrap_or(1)).boxed(); @@ -2136,6 +2158,9 @@ async fn merge_range_partitioned_lookups( } pages_per_file.push((part_id, lookup_reader.num_rows() as u32)); num_pages_written += lookup_reader.num_rows() as u32; + progress + .stage_progress("merge_lookups", idx as u64 + 1) + .await?; } metadata.insert(RANGE_PARTITIONED_META_KEY.to_string(), "true".to_string()); @@ -2145,6 +2170,7 @@ async fn merge_range_partitioned_lookups( ); lookup_file.finish_with_metadata(metadata).await?; + progress.stage_complete("merge_lookups").await?; // In this mode, we only clean up lookup files, and page files are untouched. cleanup_partition_files(store, part_lookup_files, &[]).await; @@ -2166,6 +2192,7 @@ async fn merge_pages_and_lookups( metadata: HashMap, batch_size: u64, batch_readhead: Option, + progress: Arc, ) -> Result<()> { // Create a new global page file let partition_id = extract_partition_id(part_lookup_files[0].as_str())?; @@ -2177,7 +2204,7 @@ async fn merge_pages_and_lookups( let mut page_file = store .new_index_file(BTREE_PAGES_NAME, arrow_schema.clone()) .await?; - + progress.stage_start("merge_pages", None, "pages").await?; let lookup_entries = merge_pages( part_lookup_files, page_files_map, @@ -2186,9 +2213,11 @@ async fn merge_pages_and_lookups( &mut page_file, arrow_schema.clone(), batch_readhead, + progress.clone(), ) .await?; page_file.finish().await?; + progress.stage_complete("merge_pages").await?; let lookup_batch = RecordBatch::try_new( lookup_schema.clone(), @@ -2208,8 +2237,13 @@ async fn merge_pages_and_lookups( let mut lookup_file = store .new_index_file(BTREE_LOOKUP_NAME, lookup_schema) .await?; + progress + .stage_start("write_lookup_file", Some(1), "files") + .await?; lookup_file.write_record_batch(lookup_batch).await?; lookup_file.finish_with_metadata(metadata).await?; + progress.stage_progress("write_lookup_file", 1).await?; + progress.stage_complete("write_lookup_file").await?; // After successfully writing the merged files, delete all partition files // Only perform deletion after files are successfully written, ensuring debug information is not lost in case of failure @@ -2240,6 +2274,7 @@ fn add_offset_to_page_idx(batch: &RecordBatch, offset: u32) -> Result, @@ -2248,6 +2283,7 @@ async fn merge_pages( page_file: &mut Box, arrow_schema: Arc, batch_readhead: Option, + progress: Arc, ) -> Result> { let mut lookup_entries = Vec::new(); let mut page_idx = 0u32; @@ -2331,6 +2367,9 @@ async fn merge_pages( lookup_entries.push((min_val, max_val, null_count, page_idx)); page_idx += 1; + progress + .stage_progress("merge_pages", page_idx as u64) + .await?; } Ok(lookup_entries) @@ -2666,6 +2705,7 @@ mod tests { use object_store::path::Path; use crate::metrics::LocalMetricsCollector; + use crate::progress::{IndexBuildProgress, noop_progress}; use crate::{ metrics::NoOpMetricsCollector, scalar::{ @@ -2679,6 +2719,12 @@ mod tests { DEFAULT_BTREE_BATCH_SIZE, OrderableScalarValue, part_lookup_file_path, part_page_data_file_path, train_btree_index, }; + + lance_testing::define_stage_event_progress!( + RecordingProgress, + IndexBuildProgress, + lance_core::Result<()> + ); #[test] fn test_scalar_value_size() { let size_of_i32 = OrderableScalarValue(ScalarValue::Int32(Some(0))).deep_size_of(); @@ -3144,15 +3190,50 @@ mod tests { part_lookup_file_path(2 << 32), ]; + let progress = Arc::new(RecordingProgress::default()); super::merge_metadata_files( fragment_store.as_ref(), &part_page_files, &part_lookup_files, Option::from(1usize), + progress.clone(), ) .await .unwrap(); + let tags = progress + .recorded_events() + .iter() + .map(|(kind, stage, _)| format!("{kind}:{stage}")) + .collect::>(); + let merge_start = tags + .iter() + .position(|e| e == "start:merge_pages") + .expect("missing merge_pages start"); + let merge_complete = tags + .iter() + .position(|e| e == "complete:merge_pages") + .expect("missing merge_pages complete"); + let lookup_start = tags + .iter() + .position(|e| e == "start:write_lookup_file") + .expect("missing write_lookup_file start"); + let lookup_complete = tags + .iter() + .position(|e| e == "complete:write_lookup_file") + .expect("missing write_lookup_file complete"); + assert!(merge_start < merge_complete); + assert!(merge_complete < lookup_start); + assert!(lookup_start < lookup_complete); + assert!( + tags.iter().any(|e| e == "progress:merge_pages"), + "expected merge_pages progress callbacks" + ); + assert!( + tags.iter().any(|e| e == "progress:write_lookup_file"), + "expected write_lookup_file progress callbacks" + ); + // Load both indexes let full_index = BTreeIndex::load(full_store.clone(), None, &LanceCache::no_cache()) .await @@ -3358,6 +3439,7 @@ mod tests { &part_page_files, &part_lookup_files, Option::from(1usize), + noop_progress(), ) .await .unwrap(); @@ -3925,6 +4007,7 @@ mod tests { &part_page_files, &part_lookup_files, Option::from(1usize), + noop_progress(), ) .await .unwrap(); @@ -4242,6 +4325,7 @@ mod tests { &part_page_files, &part_lookup_files, Option::from(1usize), + noop_progress(), ) .await .unwrap(); diff --git a/rust/lance-index/src/scalar/inverted/builder.rs b/rust/lance-index/src/scalar/inverted/builder.rs index f752ef0e68b..111c150d988 100644 --- a/rust/lance-index/src/scalar/inverted/builder.rs +++ b/rust/lance-index/src/scalar/inverted/builder.rs @@ -1515,16 +1515,23 @@ pub(crate) fn part_metadata_file_path(partition_id: u64) -> String { format!("part_{}_{}", partition_id, METADATA_FILE) } +const PARTITION_FILE_SUFFIXES: [&str; 3] = [TOKENS_FILE, INVERT_LIST_FILE, DOCS_FILE]; +// Each remapped file is renamed twice: first to a temp path (phase 1), then to +// its final path (phase 2). Keep in sync with the two rename loops below in +// `merge_metadata_files`. +const PARTITION_FILE_RENAME_PHASES: u64 = 2; + pub async fn merge_index_files( object_store: &ObjectStore, index_dir: &Path, store: Arc, + progress: Arc, ) -> Result<()> { // List all partition metadata files in the index directory let part_metadata_files = list_metadata_files(object_store, index_dir).await?; // Call merge_metadata_files function for inverted index - merge_metadata_files(store, &part_metadata_files).await + merge_metadata_files(store, &part_metadata_files, progress).await } /// List and filter metadata files from the index directory @@ -1564,6 +1571,7 @@ async fn list_metadata_files(object_store: &ObjectStore, index_dir: &Path) -> Re async fn merge_metadata_files( store: Arc, part_metadata_files: &[String], + progress: Arc, ) -> Result<()> { // Collect all partition IDs and params let mut all_partitions = Vec::new(); @@ -1571,10 +1579,16 @@ async fn merge_metadata_files( let mut token_set_format = None; let mut format_version = None; let mut posting_tail_codec = None; - let mut deleted_fragments = RoaringBitmap::new(); + progress + .stage_start( + "read_partition_metadata", + Some(part_metadata_files.len() as u64), + "files", + ) + .await?; - for file_name in part_metadata_files { + for (idx, file_name) in part_metadata_files.iter().enumerate() { let reader = store.open_index_file(file_name).await?; let metadata = &reader.schema().metadata; @@ -1623,7 +1637,11 @@ async fn merge_metadata_files( RoaringBitmap::deserialize_from(deleted_fragments_arr.value(0))?; deleted_fragments.extend(part_deleted_fragments); } + progress + .stage_progress("read_partition_metadata", idx as u64 + 1) + .await?; } + progress.stage_complete("read_partition_metadata").await?; // Create ID mapping: sorted original IDs -> 0,1,2... let mut sorted_ids = all_partitions.clone(); @@ -1642,12 +1660,24 @@ async fn merge_metadata_files( .unwrap() .as_secs(); + let changed_partition_count = id_mapping + .iter() + .filter(|(old_id, new_id)| old_id != new_id) + .count() as u64; + let total_renames = changed_partition_count + * PARTITION_FILE_SUFFIXES.len() as u64 + * PARTITION_FILE_RENAME_PHASES; + progress + .stage_start("remap_partition_files", Some(total_renames), "files") + .await?; + // Phase 1: Move files to temporary locations let mut temp_files: Vec<(String, String, String)> = Vec::new(); // (temp_path, old_path, final_path) + let mut renamed_files = 0u64; for (&old_id, &new_id) in &id_mapping { if old_id != new_id { - for suffix in [TOKENS_FILE, INVERT_LIST_FILE, DOCS_FILE] { + for suffix in PARTITION_FILE_SUFFIXES { let old_path = format!("part_{}_{}", old_id, suffix); let new_path = format!("part_{}_{}", new_id, suffix); let temp_path = format!("temp_{}_{}", timestamp, old_path); @@ -1664,6 +1694,10 @@ async fn merge_metadata_files( ))); } temp_files.push((temp_path, old_path, new_path)); + renamed_files += 1; + progress + .stage_progress("remap_partition_files", renamed_files) + .await?; } } } @@ -1689,7 +1723,12 @@ async fn merge_metadata_files( ))); } completed_renames.push((final_path.clone(), temp_path.clone())); + renamed_files += 1; + progress + .stage_progress("remap_partition_files", renamed_files) + .await?; } + progress.stage_complete("remap_partition_files").await?; // Write merged metadata with remapped IDs let remapped_partitions: Vec = (0..id_mapping.len() as u64).collect(); @@ -1705,9 +1744,14 @@ async fn merge_metadata_files( ) .with_format_version(format_version.unwrap_or(InvertedListFormatVersion::V1)) .with_posting_tail_codec(posting_tail_codec.unwrap_or(PostingTailCodec::Fixed32)); + progress + .stage_start("write_merged_metadata", Some(1), "files") + .await?; builder .write_metadata(&*store, &remapped_partitions) .await?; + progress.stage_progress("write_merged_metadata", 1).await?; + progress.stage_complete("write_merged_metadata").await?; // Cleanup partition metadata files for file_name in part_metadata_files { @@ -1774,7 +1818,6 @@ mod tests { use std::any::Any; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::time::Duration; - use tokio::sync::Mutex; fn make_doc_batch(doc: &str, row_id: u64) -> RecordBatch { let schema = Arc::new(Schema::new(vec![ @@ -2144,38 +2187,7 @@ mod tests { Ok(()) } - #[derive(Debug, Default)] - struct RecordingProgress { - events: Mutex>, - } - - #[async_trait] - impl IndexBuildProgress for RecordingProgress { - async fn stage_start(&self, stage: &str, total: Option, _unit: &str) -> Result<()> { - self.events.lock().await.push(( - "start".to_string(), - stage.to_string(), - total.unwrap_or(0), - )); - Ok(()) - } - - async fn stage_progress(&self, stage: &str, completed: u64) -> Result<()> { - self.events - .lock() - .await - .push(("progress".to_string(), stage.to_string(), completed)); - Ok(()) - } - - async fn stage_complete(&self, stage: &str) -> Result<()> { - self.events - .lock() - .await - .push(("complete".to_string(), stage.to_string(), 0)); - Ok(()) - } - } + lance_testing::define_stage_event_progress!(RecordingProgress, IndexBuildProgress, Result<()>); #[derive(Debug, Default)] struct FailingProgress; @@ -2218,7 +2230,7 @@ mod tests { .with_progress(progress.clone()); builder.update(stream, store.as_ref(), None).await?; - let events = progress.events.lock().await.clone(); + let events = progress.recorded_events(); let tags = events .iter() .map(|(kind, stage, _)| format!("{kind}:{stage}")) @@ -2313,9 +2325,7 @@ mod tests { builder.update(stream, store.as_ref(), None).await?; let tags = progress - .events - .lock() - .await + .recorded_events() .iter() .map(|(kind, stage, _)| format!("{kind}:{stage}")) .collect::>(); @@ -2331,6 +2341,100 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_merge_index_files_reports_progress_stages() -> Result<()> { + let index_dir = TempDir::default(); + let index_path = index_dir.obj_path(); + let object_store = ObjectStore::local(); + let store = Arc::new(LanceIndexStore::new( + object_store.clone().into(), + index_path.clone(), + Arc::new(LanceCache::no_cache()), + )); + + for (fragment_id, row_id, doc) in [ + (1_u64 << 32, 0_u64, "hello world"), + (2_u64 << 32, 1_u64, "goodbye world"), + ] { + let batch = make_doc_batch(doc, row_id); + let stream = + RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)])); + let stream = Box::pin(stream); + let mut builder = InvertedIndexBuilder::new_with_fragment_mask( + InvertedIndexParams::default(), + Some(fragment_id), + ) + .with_progress(noop_progress()); + builder.update(stream, store.as_ref(), None).await?; + } + + let progress = Arc::new(RecordingProgress::default()); + merge_index_files(&object_store, &index_path, store.clone(), progress.clone()).await?; + + let events = progress.recorded_events(); + let tags = events + .iter() + .map(|(kind, stage, _)| format!("{kind}:{stage}")) + .collect::>(); + let remap_progress = events + .iter() + .filter_map(|(kind, stage, completed)| { + if kind == "progress" && stage == "remap_partition_files" { + Some(*completed) + } else { + None + } + }) + .collect::>(); + + let read_start = tags + .iter() + .position(|e| e == "start:read_partition_metadata") + .expect("missing read_partition_metadata start"); + let read_complete = tags + .iter() + .position(|e| e == "complete:read_partition_metadata") + .expect("missing read_partition_metadata complete"); + let remap_start = tags + .iter() + .position(|e| e == "start:remap_partition_files") + .expect("missing remap_partition_files start"); + let remap_complete = tags + .iter() + .position(|e| e == "complete:remap_partition_files") + .expect("missing remap_partition_files complete"); + let metadata_start = tags + .iter() + .position(|e| e == "start:write_merged_metadata") + .expect("missing write_merged_metadata start"); + let metadata_complete = tags + .iter() + .position(|e| e == "complete:write_merged_metadata") + .expect("missing write_merged_metadata complete"); + + assert!(read_start < read_complete); + assert!(read_complete < remap_start); + assert!(remap_start < remap_complete); + assert!(remap_complete < metadata_start); + assert!(metadata_start < metadata_complete); + + assert!( + tags.iter().any(|e| e == "progress:read_partition_metadata"), + "expected progress callback for read_partition_metadata" + ); + assert_eq!( + remap_progress.last().copied().unwrap_or_default(), + 12, + "expected remap_partition_files progress to cover both rename phases" + ); + assert!( + tags.iter().any(|e| e == "progress:write_merged_metadata"), + "expected progress callback for write_merged_metadata" + ); + + Ok(()) + } + #[tokio::test] async fn test_worker_memory_limit_rejects_single_large_doc() { let index_dir = TempDir::default(); diff --git a/rust/lance-index/src/vector/distributed/index_merger.rs b/rust/lance-index/src/vector/distributed/index_merger.rs index c9c7ad4503c..74721f94c62 100755 --- a/rust/lance-index/src/vector/distributed/index_merger.rs +++ b/rust/lance-index/src/vector/distributed/index_merger.rs @@ -3,6 +3,7 @@ //! Index merging mechanisms for distributed vector index building +use crate::progress::IndexBuildProgress; use crate::vector::shared::partition_merger::{ SupportedIvfIndexType, write_unified_ivf_and_index_metadata, }; @@ -699,6 +700,7 @@ pub async fn merge_partial_vector_auxiliary_files( object_store: &lance_io::object_store::ObjectStore, aux_paths: &[object_store::path::Path], target_dir: &object_store::path::Path, + progress: Arc, ) -> Result<()> { if aux_paths.is_empty() { return Err(Error::index( @@ -737,8 +739,16 @@ pub async fn merge_partial_vector_auxiliary_files( // This avoids reopening each shard file for every partition during merge. let mut shard_infos: Vec = Vec::new(); + progress + .stage_start( + "read_shard_metadata", + Some(aux_paths.len() as u64), + "shards", + ) + .await?; + // Iterate over each shard auxiliary file and merge its metadata and collect lengths - for aux in aux_paths { + for (idx, aux) in aux_paths.iter().enumerate() { let fh = sched.open_file(aux, &CachedFileSize::unknown()).await?; let reader = V2Reader::try_open( fh, @@ -1363,7 +1373,11 @@ pub async fn merge_partial_vector_auxiliary_files( partition_offsets, total_rows: running_offset, }); + progress + .stage_progress("read_shard_metadata", idx as u64 + 1) + .await?; } + progress.stage_complete("read_shard_metadata").await?; // Write rows grouped by partition across all shards to ensure contiguous ranges per partition @@ -1376,6 +1390,15 @@ pub async fn merge_partial_vector_auxiliary_files( let idx_type_final = detected_index_type .ok_or_else(|| Error::index("Unable to detect index type".to_string()))?; + let total_rows = accumulated_lengths + .iter() + .map(|length| *length as u64) + .sum::(); + progress + .stage_start("merge_partitions", Some(total_rows), "rows") + .await?; + let mut merged_rows = 0u64; + match idx_type_final { SupportedIvfIndexType::IvfPq | SupportedIvfIndexType::IvfHnswPq => { // For PQ-backed indices, transpose PQ codes while merging partitions @@ -1405,6 +1428,10 @@ pub async fn merge_partial_vector_auxiliary_files( if let Some(w) = v2w_opt.as_mut() { write_partition_rows_pq_transposed(w, partition_batch).await?; } + merged_rows = merged_rows.saturating_add(accumulated_lengths[pid] as u64); + progress + .stage_progress("merge_partitions", merged_rows) + .await?; } } SupportedIvfIndexType::IvfRq => { @@ -1433,10 +1460,15 @@ pub async fn merge_partial_vector_auxiliary_files( if let Some(w) = v2w_opt.as_mut() { write_partition_rows_rq_packed(w, partition_batch).await?; } + merged_rows = merged_rows.saturating_add(accumulated_lengths[pid] as u64); + progress + .stage_progress("merge_partitions", merged_rows) + .await?; } } _ => { - for pid in 0..nlist { + for (pid, total_part_len) in accumulated_lengths.iter().copied().enumerate().take(nlist) + { for shard in shard_infos.iter() { let part_len = shard.lengths[pid] as usize; if part_len == 0 { @@ -1448,12 +1480,23 @@ pub async fn merge_partial_vector_auxiliary_files( .await?; } } + if total_part_len == 0 { + continue; + } + merged_rows = merged_rows.saturating_add(total_part_len as u64); + progress + .stage_progress("merge_partitions", merged_rows) + .await?; } } } + progress.stage_complete("merge_partitions").await?; // Write unified IVF metadata into global buffer & set schema metadata if let Some(w) = v2w_opt.as_mut() { + progress + .stage_start("write_auxiliary_index", Some(1), "files") + .await?; let mut ivf_model = if let Some(c) = first_centroids { IvfStorageModel::new(c, None) } else { @@ -1465,6 +1508,8 @@ pub async fn merge_partial_vector_auxiliary_files( let dt2 = distance_type.ok_or_else(|| Error::index("Distance type missing".to_string()))?; write_unified_ivf_and_index_metadata(w, &ivf_model, dt2, idx_type_final).await?; w.finish().await?; + progress.stage_progress("write_auxiliary_index", 1).await?; + progress.stage_complete("write_auxiliary_index").await?; } else { return Err(Error::index( "Failed to initialize unified writer".to_string(), @@ -1493,6 +1538,11 @@ mod tests { use prost::Message; use crate::vector::bq::RQRotationType; + lance_testing::define_stage_event_progress!( + RecordingProgress, + IndexBuildProgress, + lance_core::Result<()> + ); async fn write_flat_partial_aux( store: &ObjectStore, @@ -1585,14 +1635,86 @@ mod tests { .await .unwrap(); + let progress = Arc::new(RecordingProgress::default()); merge_partial_vector_auxiliary_files( &object_store, &[aux0.clone(), aux1.clone()], &index_dir, + progress.clone(), ) .await .unwrap(); + let events = progress.recorded_events(); + let tags = events + .iter() + .map(|(kind, stage, _)| format!("{kind}:{stage}")) + .collect::>(); + let merge_total = events + .iter() + .find_map(|(kind, stage, value)| { + if kind == "start" && stage == "merge_partitions" { + Some(*value) + } else { + None + } + }) + .expect("missing merge_partitions start total"); + let merged_rows = events + .iter() + .filter_map(|(kind, stage, value)| { + if kind == "progress" && stage == "merge_partitions" { + Some(*value) + } else { + None + } + }) + .next_back() + .unwrap_or_default(); + let read_start = tags + .iter() + .position(|e| e == "start:read_shard_metadata") + .expect("missing read_shard_metadata start"); + let read_complete = tags + .iter() + .position(|e| e == "complete:read_shard_metadata") + .expect("missing read_shard_metadata complete"); + let merge_start = tags + .iter() + .position(|e| e == "start:merge_partitions") + .expect("missing merge_partitions start"); + let merge_complete = tags + .iter() + .position(|e| e == "complete:merge_partitions") + .expect("missing merge_partitions complete"); + let write_start = tags + .iter() + .position(|e| e == "start:write_auxiliary_index") + .expect("missing write_auxiliary_index start"); + let write_complete = tags + .iter() + .position(|e| e == "complete:write_auxiliary_index") + .expect("missing write_auxiliary_index complete"); + assert!(read_start < read_complete); + assert!(read_complete < merge_start); + assert!(merge_start < merge_complete); + assert!(merge_complete < write_start); + assert!(write_start < write_complete); + assert!( + tags.iter().any(|e| e == "progress:read_shard_metadata"), + "expected read_shard_metadata progress callbacks" + ); + assert!( + tags.iter().any(|e| e == "progress:merge_partitions"), + "expected merge_partitions progress callbacks" + ); + assert_eq!(merge_total, 6, "expected merge_partitions total rows"); + assert_eq!(merged_rows, 6, "expected merge_partitions completed rows"); + assert!( + tags.iter().any(|e| e == "progress:write_auxiliary_index"), + "expected write_auxiliary_index progress callbacks" + ); + let aux_out = index_dir.child(INDEX_AUXILIARY_FILE_NAME); assert!(object_store.exists(&aux_out).await.unwrap()); @@ -1691,6 +1813,7 @@ mod tests { &object_store, &[aux0.clone(), aux1.clone()], &index_dir, + crate::progress::noop_progress(), ) .await; match res { @@ -1953,6 +2076,7 @@ mod tests { &object_store, &[aux0.clone(), aux1.clone()], &index_dir, + crate::progress::noop_progress(), ) .await .unwrap(); @@ -2071,6 +2195,7 @@ mod tests { &object_store, &[aux0.clone(), aux1.clone()], &index_dir, + crate::progress::noop_progress(), ) .await .unwrap(); @@ -2206,6 +2331,7 @@ mod tests { &object_store, &[aux0.clone(), aux1.clone()], &index_dir, + crate::progress::noop_progress(), ) .await; match res { @@ -2286,6 +2412,7 @@ mod tests { &object_store, &[aux_a.clone(), aux_b.clone()], &index_dir, + crate::progress::noop_progress(), ) .await .unwrap(); diff --git a/rust/lance-testing/src/lib.rs b/rust/lance-testing/src/lib.rs index 135530b317b..b7e6d968ac3 100644 --- a/rust/lance-testing/src/lib.rs +++ b/rust/lance-testing/src/lib.rs @@ -2,3 +2,4 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors pub mod datagen; +pub mod progress; diff --git a/rust/lance-testing/src/progress.rs b/rust/lance-testing/src/progress.rs new file mode 100644 index 00000000000..9b7f91281e8 --- /dev/null +++ b/rust/lance-testing/src/progress.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +/// Define a test-only progress recorder that captures stage events in memory. +#[macro_export] +macro_rules! define_stage_event_progress { + ($name:ident, $progress_trait:path, $result:ty) => { + #[derive(Debug, Default)] + struct $name { + events: ::std::sync::Mutex>, + } + + impl $name { + fn recorded_events(&self) -> Vec<(String, String, u64)> { + self.events + .lock() + .expect("recording progress mutex poisoned") + .clone() + } + } + + #[::async_trait::async_trait] + impl $progress_trait for $name { + async fn stage_start(&self, stage: &str, total: Option, _unit: &str) -> $result { + self.events + .lock() + .expect("recording progress mutex poisoned") + .push(("start".to_string(), stage.to_string(), total.unwrap_or(0))); + Ok(()) + } + + async fn stage_progress(&self, stage: &str, completed: u64) -> $result { + self.events + .lock() + .expect("recording progress mutex poisoned") + .push(("progress".to_string(), stage.to_string(), completed)); + Ok(()) + } + + async fn stage_complete(&self, stage: &str) -> $result { + self.events + .lock() + .expect("recording progress mutex poisoned") + .push(("complete".to_string(), stage.to_string(), 0)); + Ok(()) + } + } + }; +} diff --git a/rust/lance/benches/distributed_vector_build.rs b/rust/lance/benches/distributed_vector_build.rs index 5a9b2d70602..e32cf4e7e48 100644 --- a/rust/lance/benches/distributed_vector_build.rs +++ b/rust/lance/benches/distributed_vector_build.rs @@ -17,6 +17,7 @@ use uuid::Uuid; use lance::dataset::{Dataset, WriteMode, WriteParams}; use lance::index::{DatasetIndexExt, vector::VectorIndexParams}; use lance_arrow::FixedSizeListArrayExt; +use lance_index::progress::noop_progress; use lance_index::vector::kmeans::{KMeansParams, train_kmeans}; use lance_index::{ IndexType, @@ -417,6 +418,7 @@ fn bench_distributed_merge_only(c: &mut Criterion) { &target_uuid.to_string(), IndexType::IvfPq, None, + noop_progress(), )) .unwrap(); }, diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index fb784a38e6a..8ef2c79a67e 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -31,7 +31,7 @@ use lance_datafusion::projection::ProjectionPlan; use lance_file::datatypes::populate_schema_dictionary; use lance_file::reader::FileReaderOptions; use lance_file::version::LanceFileVersion; -use lance_index::IndexType; +use lance_index::{IndexType, progress::IndexBuildProgress}; use lance_io::object_store::{ LanceNamespaceStorageOptionsProvider, ObjectStore, ObjectStoreParams, StorageOptions, StorageOptionsAccessor, StorageOptionsProvider, @@ -2759,12 +2759,14 @@ impl Dataset { self.merge_impl(stream, left_on, right_on).await } - /// Merge a distributed scalar index into a single root artifact. + /// Merge a distributed scalar index into a single root artifact and report + /// progress via the supplied callback. pub async fn merge_index_metadata( &self, index_uuid: &str, index_type: IndexType, batch_readhead: Option, + progress: Arc, ) -> Result<()> { let store = LanceIndexStore::from_dataset_for_new(self, index_uuid)?; let index_dir = self.indices_dir().child(index_uuid); @@ -2775,6 +2777,7 @@ impl Dataset { self.object_store(), &index_dir, Arc::new(store), + progress, ) .await } @@ -2785,6 +2788,7 @@ impl Dataset { &index_dir, Arc::new(store), batch_readhead, + progress, ) .await } diff --git a/rust/lance/src/index/create.rs b/rust/lance/src/index/create.rs index 73407ef8d69..8aed939787a 100644 --- a/rust/lance/src/index/create.rs +++ b/rust/lance/src/index/create.rs @@ -31,7 +31,6 @@ use tracing::instrument; use uuid::Uuid; use arrow_array::RecordBatchReader; - /// Generate default index name from field path. /// /// Joins field names with `.` to create the base index name. @@ -529,6 +528,7 @@ mod tests { use lance_core::utils::tempfile::TempStrDir; use lance_datagen::{self, gen_batch}; use lance_index::optimize::OptimizeOptions; + use lance_index::progress::IndexBuildProgress; use lance_index::scalar::inverted::tokenizer::InvertedIndexParams; use lance_index::vector::hnsw::builder::HnswBuildParams; use lance_index::vector::ivf::IvfBuildParams; @@ -537,6 +537,8 @@ mod tests { use std::sync::Arc; use uuid::Uuid; + lance_testing::define_stage_event_progress!(RecordingProgress, IndexBuildProgress, Result<()>); + #[test] fn test_inverted_training_params_include_build_only_fields() { let params = InvertedIndexParams::default() @@ -894,18 +896,20 @@ mod tests { } #[tokio::test] - async fn test_merge_index_metadata() { - // Test the complete workflow for merge_index_metadata: - // 1. Create multiple fragment indexes using execute_uncommitted - // 2. Use merge_index_metadata to merge temporary metadata files - // 3. Commit the index using the standard commit process - // 4. Verify the final index is properly created and accessible - - // Create temporary directory for dataset + async fn test_merge_index_metadata_inverted_reports_progress() { + // This exercises the public distributed inverted-index workflow end to end: + // 1. build one uncommitted shard per fragment with CreateIndexBuilder.progress(...) + // 2. merge those shards with Dataset::merge_index_metadata(...) + // + // Expected outcomes: + // - the build callback should surface public build stages such as load_data, + // tokenize_docs, copy_partitions, and write_metadata + // - the merge callback should surface public merge stages such as + // read_partition_metadata, remap_partition_files, and write_merged_metadata + // - merge stages should be reported in execution order let tmpdir = TempStrDir::default(); let dataset_uri = format!("file://{}", tmpdir.as_str()); - // Create test data with multiple fragments let batch1 = create_text_batch(0, 15); let batch2 = create_text_batch(15, 30); let batch3 = create_text_batch(30, 45); @@ -928,58 +932,210 @@ mod tests { let params = InvertedIndexParams::default(); let fragments = dataset.get_fragments(); let fragment_ids: Vec = fragments.iter().map(|f| f.id() as u32).collect(); - - // Use a shared UUID for distributed indexing let shared_uuid = Uuid::new_v4().to_string(); + let build_progress = Arc::new(RecordingProgress::default()); - // Step 1: Create indexes for each fragment using execute_uncommitted - let mut index_metadatas = Vec::new(); for &fragment_id in &fragment_ids { let mut builder = CreateIndexBuilder::new(&mut dataset, &["text"], IndexType::Inverted, ¶ms) .name("distributed_index".to_string()) .fragments(vec![fragment_id]) - .index_uuid(shared_uuid.clone()); + .index_uuid(shared_uuid.clone()) + .progress(build_progress.clone()); let index_metadata = builder.execute_uncommitted().await.unwrap(); - - // Verify each fragment's index metadata assert_eq!(index_metadata.uuid.to_string(), shared_uuid); assert_eq!(index_metadata.name, "distributed_index"); let fragment_bitmap = index_metadata.fragment_bitmap.as_ref().unwrap(); let indexed_fragments: Vec = fragment_bitmap.iter().collect(); assert_eq!(indexed_fragments, vec![fragment_id]); - - index_metadatas.push(index_metadata); } - // Step 2: Merge inverted index metadata - // Note: This step would typically be done by calling dataset.merge_index_metadata() - // but for this test, we verify that the execute_uncommitted workflow produces correct metadata + let merge_progress = Arc::new(RecordingProgress::default()); + dataset + .merge_index_metadata( + &shared_uuid, + IndexType::Inverted, + None, + merge_progress.clone(), + ) + .await + .unwrap(); - // Step 3: Verify the metadata from execute_uncommitted contains all necessary information - assert_eq!(index_metadatas.len(), fragment_ids.len()); + let build_tags = build_progress + .recorded_events() + .iter() + .map(|(kind, stage, _)| format!("{kind}:{stage}")) + .collect::>(); + assert!( + build_tags.iter().any(|e| e == "start:load_data"), + "expected load_data progress during public distributed build" + ); + assert!( + build_tags.iter().any(|e| e == "start:tokenize_docs"), + "expected tokenize_docs progress during public distributed build" + ); + assert!( + build_tags.iter().any(|e| e == "start:copy_partitions"), + "expected copy_partitions progress during public distributed build" + ); + assert!( + build_tags.iter().any(|e| e == "start:write_metadata"), + "expected write_metadata progress during public distributed build" + ); - // Verify all metadata have the same UUID (shared UUID for distributed indexing) - for metadata in &index_metadatas { - assert_eq!(metadata.uuid.to_string(), shared_uuid); - assert_eq!(metadata.name, "distributed_index"); - assert!(metadata.fragment_bitmap.is_some()); - assert!(metadata.created_at.is_some()); - } + let merge_events = merge_progress.recorded_events(); + let merge_tags = merge_events + .iter() + .map(|(kind, stage, _)| format!("{kind}:{stage}")) + .collect::>(); + let read_start = merge_tags + .iter() + .position(|e| e == "start:read_partition_metadata") + .expect("missing read_partition_metadata start"); + let read_complete = merge_tags + .iter() + .position(|e| e == "complete:read_partition_metadata") + .expect("missing read_partition_metadata complete"); + let remap_start = merge_tags + .iter() + .position(|e| e == "start:remap_partition_files") + .expect("missing remap_partition_files start"); + let remap_complete = merge_tags + .iter() + .position(|e| e == "complete:remap_partition_files") + .expect("missing remap_partition_files complete"); + let metadata_start = merge_tags + .iter() + .position(|e| e == "start:write_merged_metadata") + .expect("missing write_merged_metadata start"); + let metadata_complete = merge_tags + .iter() + .position(|e| e == "complete:write_merged_metadata") + .expect("missing write_merged_metadata complete"); + assert!(read_start < read_complete); + assert!(read_complete < remap_start); + assert!(remap_start < remap_complete); + assert!(remap_complete < metadata_start); + assert!(metadata_start < metadata_complete); + assert!( + merge_tags + .iter() + .any(|e| e == "progress:read_partition_metadata"), + "expected read_partition_metadata progress during public merge" + ); + assert!( + merge_tags + .iter() + .any(|e| e == "progress:remap_partition_files"), + "expected remap_partition_files progress during public merge" + ); + assert!( + merge_tags + .iter() + .any(|e| e == "progress:write_merged_metadata"), + "expected write_merged_metadata progress during public merge" + ); + } + + #[tokio::test] + async fn test_merge_index_metadata_btree_reports_progress() { + let tmpdir = TempStrDir::default(); + let dataset_uri = format!("file://{}", tmpdir.as_str()); + + let reader = gen_batch() + .col("id", lance_datagen::array::step::()) + .into_reader_rows( + lance_datagen::RowCount::from(256), + lance_datagen::BatchCount::from(4), + ); + let mut dataset = Dataset::write( + reader, + &dataset_uri, + Some(WriteParams { + max_rows_per_file: 64, + mode: WriteMode::Overwrite, + ..Default::default() + }), + ) + .await + .unwrap(); + + let params = ScalarIndexParams::for_builtin(lance_index::scalar::BuiltinIndexType::BTree); + let fragments = dataset.get_fragments(); + let fragment_ids: Vec = fragments.iter().map(|f| f.id() as u32).collect(); + let shared_uuid = Uuid::new_v4().to_string(); + let build_progress = Arc::new(RecordingProgress::default()); - // Verify that each fragment is covered by exactly one metadata - let mut all_covered_fragments = Vec::new(); - for metadata in &index_metadatas { - let fragment_bitmap = metadata.fragment_bitmap.as_ref().unwrap(); - let covered_fragments: Vec = fragment_bitmap.iter().collect(); - all_covered_fragments.extend(covered_fragments); + for &fragment_id in &fragment_ids { + CreateIndexBuilder::new(&mut dataset, &["id"], IndexType::BTree, ¶ms) + .name("distributed_btree".to_string()) + .fragments(vec![fragment_id]) + .index_uuid(shared_uuid.clone()) + .progress(build_progress.clone()) + .execute_uncommitted() + .await + .unwrap(); } - all_covered_fragments.sort(); - let mut expected_fragments = fragment_ids.clone(); - expected_fragments.sort(); - assert_eq!(all_covered_fragments, expected_fragments); + + let merge_progress = Arc::new(RecordingProgress::default()); + dataset + .merge_index_metadata( + &shared_uuid, + IndexType::BTree, + Some(1), + merge_progress.clone(), + ) + .await + .unwrap(); + + let build_tags = build_progress + .recorded_events() + .iter() + .map(|(kind, stage, _)| format!("{kind}:{stage}")) + .collect::>(); + assert!( + build_tags.iter().any(|e| e == "start:load_data"), + "expected load_data progress during public distributed build" + ); + + let merge_tags = merge_progress + .recorded_events() + .iter() + .map(|(kind, stage, _)| format!("{kind}:{stage}")) + .collect::>(); + let pages_start = merge_tags + .iter() + .position(|e| e == "start:merge_pages") + .expect("missing merge_pages start"); + let pages_complete = merge_tags + .iter() + .position(|e| e == "complete:merge_pages") + .expect("missing merge_pages complete"); + let write_start = merge_tags + .iter() + .position(|e| e == "start:write_lookup_file") + .expect("missing write_lookup_file start"); + let write_complete = merge_tags + .iter() + .position(|e| e == "complete:write_lookup_file") + .expect("missing write_lookup_file complete"); + assert!(pages_start < pages_complete); + assert!(pages_complete < write_start); + assert!(write_start < write_complete); + assert!( + merge_tags.iter().any(|e| e == "progress:merge_pages"), + "expected merge_pages progress during public merge" + ); + assert!( + merge_tags.iter().any(|e| e == "progress:write_lookup_file"), + "expected write_lookup_file progress during public merge" + ); + assert!( + !merge_tags.iter().any(|e| e == "start:merge_lookups"), + "fragment-based distributed BTREE merge should not use merge_lookups" + ); } #[tokio::test] diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index ee298df6316..4d75837e212 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -1908,6 +1908,23 @@ pub(crate) async fn merge_segments( object_store: &ObjectStore, indices_dir: &Path, segments: Vec, +) -> Result { + merge_segments_with_progress( + object_store, + indices_dir, + segments, + lance_index::progress::noop_progress(), + ) + .await +} + +/// Merge one caller-defined group of source segments into a single segment and +/// report progress through the provided callback. +pub(crate) async fn merge_segments_with_progress( + object_store: &ObjectStore, + indices_dir: &Path, + segments: Vec, + progress: Arc, ) -> Result { if segments.is_empty() { return Err(Error::index("No segment metadata was provided".to_string())); @@ -1931,7 +1948,7 @@ pub(crate) async fn merge_segments( let index_version = infer_source_index_version(&segments)?; let segment_uuid = Uuid::new_v4(); let final_dir = indices_dir.child(segment_uuid.to_string()); - merge_segments_to_dir(object_store, indices_dir, &final_dir, &segments).await?; + merge_segments_to_dir(object_store, indices_dir, &final_dir, &segments, progress).await?; let files = list_index_files_with_sizes(object_store, &final_dir).await?; merged_segment = TableIndexMetadata { @@ -1957,6 +1974,7 @@ async fn merge_segments_to_dir( indices_dir: &Path, final_dir: &Path, segments: &[TableIndexMetadata], + progress: Arc, ) -> Result<()> { reset_final_segment_dir(object_store, final_dir).await?; @@ -1986,10 +2004,17 @@ async fn merge_segments_to_dir( object_store, &aux_paths, final_dir, + progress.clone(), + ) + .await?; + write_root_vector_index_from_auxiliary( + object_store, + final_dir, + None, + &source_index_paths, + progress.clone(), ) .await?; - write_root_vector_index_from_auxiliary(object_store, final_dir, None, &source_index_paths) - .await?; Ok(()) } @@ -2020,6 +2045,7 @@ async fn write_root_vector_index_from_auxiliary( index_dir: &Path, requested_index_type: Option, centroid_source_index_paths: &[Path], + progress: Arc, ) -> Result<()> { let aux_path = index_dir.child(INDEX_AUXILIARY_FILE_NAME); let scheduler = ScanScheduler::new( @@ -2114,6 +2140,9 @@ async fn write_root_vector_index_from_auxiliary( // Write root index.idx via V2 writer so downstream opens through v2 path. let index_path = index_dir.child(INDEX_FILE_NAME); let obj_writer = object_store.create(&index_path).await?; + progress + .stage_start("write_root_index", Some(1), "files") + .await?; // Schema for HNSW sub-index: include neighbors/dist fields; empty batch is fine. let arrow_schema = HNSW::schema(); @@ -2159,6 +2188,9 @@ async fn write_root_vector_index_from_auxiliary( let empty_batch = RecordBatch::new_empty(arrow_schema); v2_writer.write_batch(&empty_batch).await?; v2_writer.finish().await?; + progress.stage_progress("write_root_index", 1).await?; + progress.stage_complete("write_root_index").await?; + Ok(()) } diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 9143a5b12fc..566e42887e8 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -651,6 +651,7 @@ mod tests { use lance_file::reader::{FileReader, FileReaderOptions}; use lance_file::writer::FileWriter; use lance_index::IndexType; + use lance_index::progress::IndexBuildProgress; use lance_index::vector::DIST_COL; use lance_index::vector::ivf::IvfBuildParams; use lance_index::vector::kmeans::{KMeansParams, train_kmeans}; @@ -682,6 +683,8 @@ mod tests { const NUM_ROWS: usize = 512; const DIM: usize = 32; + lance_testing::define_stage_event_progress!(RecordingProgress, IndexBuildProgress, Result<()>); + async fn generate_test_dataset( test_uri: &str, range: Range, @@ -2237,6 +2240,133 @@ mod tests { assert!(result.num_rows() > 0); } + #[tokio::test] + async fn test_merge_index_metadata_reports_progress() { + const INDEX_NAME: &str = "vector_idx"; + + let test_dir = TempStrDir::default(); + let dataset_uri = format!("{}/progress", test_dir.as_str()); + let (schema, batches) = make_two_fragment_batches(); + let mut dataset = write_dataset_from_batches(&dataset_uri, schema, batches).await; + + let fragments = dataset.get_fragments(); + assert!( + fragments.len() >= 2, + "expected at least 2 fragments, got {}", + fragments.len() + ); + let expected_rows = fragments[0].physical_rows().await.unwrap() as u64 + + fragments[1].physical_rows().await.unwrap() as u64; + + let (ivf_params, pq_params) = prepare_global_ivf_pq(&dataset, "vector").await; + let params = VectorIndexParams::with_ivf_pq_params(DistanceType::L2, ivf_params, pq_params); + let mut segments = Vec::new(); + for fragment in fragments.iter().take(2) { + segments.push( + dataset + .create_index_builder(&["vector"], IndexType::Vector, ¶ms) + .name(INDEX_NAME.to_string()) + .fragments(vec![fragment.id() as u32]) + .execute_uncommitted() + .await + .unwrap(), + ); + } + + let progress = Arc::new(RecordingProgress::default()); + let merged_segment = crate::index::vector::ivf::merge_segments_with_progress( + dataset.object_store(), + &dataset.indices_dir(), + segments, + progress.clone(), + ) + .await + .unwrap(); + dataset + .commit_existing_index_segments(INDEX_NAME, "vector", vec![merged_segment]) + .await + .unwrap(); + + let events = progress.recorded_events(); + let tags = events + .iter() + .map(|(kind, stage, _)| format!("{kind}:{stage}")) + .collect::>(); + let merge_total = events + .iter() + .find_map(|(kind, stage, value)| { + if kind == "start" && stage == "merge_partitions" { + Some(*value) + } else { + None + } + }) + .expect("missing merge_partitions start total"); + let merged_rows = events + .iter() + .filter_map(|(kind, stage, value)| { + if kind == "progress" && stage == "merge_partitions" { + Some(*value) + } else { + None + } + }) + .next_back() + .unwrap_or_default(); + let read_start = tags + .iter() + .position(|e| e == "start:read_shard_metadata") + .expect("missing read_shard_metadata start"); + let read_complete = tags + .iter() + .position(|e| e == "complete:read_shard_metadata") + .expect("missing read_shard_metadata complete"); + let merge_start = tags + .iter() + .position(|e| e == "start:merge_partitions") + .expect("missing merge_partitions start"); + let merge_complete = tags + .iter() + .position(|e| e == "complete:merge_partitions") + .expect("missing merge_partitions complete"); + let aux_start = tags + .iter() + .position(|e| e == "start:write_auxiliary_index") + .expect("missing write_auxiliary_index start"); + let aux_complete = tags + .iter() + .position(|e| e == "complete:write_auxiliary_index") + .expect("missing write_auxiliary_index complete"); + let root_start = tags + .iter() + .position(|e| e == "start:write_root_index") + .expect("missing write_root_index start"); + let root_complete = tags + .iter() + .position(|e| e == "complete:write_root_index") + .expect("missing write_root_index complete"); + + assert!(read_start < read_complete); + assert!(read_complete < merge_start); + assert!(merge_start < merge_complete); + assert!(merge_complete < aux_start); + assert!(aux_start < aux_complete); + assert!(aux_complete < root_start); + assert!(root_start < root_complete); + assert_eq!( + merge_total, expected_rows, + "expected merge_partitions total rows to match dataset rows" + ); + assert_eq!( + merged_rows, expected_rows, + "expected merge_partitions completed rows to match dataset rows" + ); + assert!( + tags.iter().any(|e| e == "progress:write_root_index"), + "expected write_root_index progress callbacks" + ); + } + #[tokio::test] async fn test_distributed_ivf_sq_worker_training_respects_fragment_filter() { const ROWS_PER_FRAGMENT: usize = 64; From a97c647f8c90db6ba86a8e1c00281e306b16c4be Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Thu, 2 Apr 2026 14:36:21 -0700 Subject: [PATCH 131/206] fix: preserve multipart part ordering in throttle wrapper (#6393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Call `target.put_part` synchronously in `ThrottledMultipartUpload::put_part` to lock in part ordering at creation time, rather than deferring the call into the async future body where await order would determine part order. - Remove the unnecessary `Arc>` wrapper around the inner upload target since `&mut self` already prevents concurrent `put_part` calls. - Add test `test_throttled_multipart_reorders_parts` that verifies parts are ordered by creation, not by await order. ## Test plan - [x] `cargo test -p lance-io throttle` — all 21 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- rust/lance-io/src/object_store/throttle.rs | 72 +++++++++++++--------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/rust/lance-io/src/object_store/throttle.rs b/rust/lance-io/src/object_store/throttle.rs index a632ddda7e0..046ab4b0ff4 100644 --- a/rust/lance-io/src/object_store/throttle.rs +++ b/rust/lance-io/src/object_store/throttle.rs @@ -461,14 +461,8 @@ impl Debug for OperationThrottle { /// A [`MultipartUpload`] wrapper that throttles and retries `put_part`, /// `complete`, and `abort`, feeding outcomes back to the write AIMD /// controller. -/// -/// Uses a `std::sync::Mutex` (not `tokio::sync::Mutex`) so that aborted -/// futures cannot cause deadlocks — the guard is always dropped -/// deterministically. The lock is held only briefly for the sync -/// `put_part` dispatch; `complete`/`abort` hold it across their await but -/// are never called concurrently with part uploads. struct ThrottledMultipartUpload { - target: Arc>>, + target: Box, write: Arc, } @@ -482,27 +476,19 @@ impl Debug for ThrottledMultipartUpload { impl MultipartUpload for ThrottledMultipartUpload { fn put_part(&mut self, data: PutPayload) -> UploadPart { let write = Arc::clone(&self.write); - let target = Arc::clone(&self.target); + // Call put_part synchronously to preserve part ordering regardless + // of which futures are awaited first. + let fut = self.target.put_part(data); Box::pin(async move { - write - .throttled(|| { - // The let binding is intentional: it ensures the - // MutexGuard is dropped before the future is awaited. - #[allow(clippy::let_and_return)] - let fut = target.lock().unwrap().put_part(data.clone()); - fut - }) - .await + write.acquire_token().await; + let result = fut.await; + write.observe_outcome(&result); + result }) } async fn complete(&mut self) -> OSResult { - // &mut self guarantees no concurrent put_part futures are alive, - // so get_mut always succeeds (Arc refcount == 1). - let target = Arc::get_mut(&mut self.target) - .expect("complete called while put_part futures are still alive") - .get_mut() - .unwrap(); + let target = &mut self.target; for attempt in 0..=self.write.max_retries { self.write.acquire_token().await; let result = target.complete().await; @@ -522,10 +508,7 @@ impl MultipartUpload for ThrottledMultipartUpload { } async fn abort(&mut self) -> OSResult<()> { - let target = Arc::get_mut(&mut self.target) - .expect("abort called while put_part futures are still alive") - .get_mut() - .unwrap(); + let target = &mut self.target; for attempt in 0..=self.write.max_retries { self.write.acquire_token().await; let result = target.abort().await; @@ -655,7 +638,7 @@ impl ObjectStore for AimdThrottledStore { .throttled(|| self.target.put_multipart(location)) .await?; Ok(Box::new(ThrottledMultipartUpload { - target: Arc::new(std::sync::Mutex::new(target)), + target, write: Arc::clone(&self.write), })) } @@ -670,7 +653,7 @@ impl ObjectStore for AimdThrottledStore { .throttled(|| self.target.put_multipart_opts(location, opts.clone())) .await?; Ok(Box::new(ThrottledMultipartUpload { - target: Arc::new(std::sync::Mutex::new(target)), + target, write: Arc::clone(&self.write), })) } @@ -1611,4 +1594,35 @@ mod tests { // Should have called get 4 times: initial attempt + 3 retries assert_eq!(mock.get_call_count.load(Ordering::Relaxed), 4); } + + #[tokio::test] + async fn test_throttled_multipart_reorders_parts() { + let store = Arc::new(InMemory::new()) as Arc; + let config = AimdThrottleConfig::default(); + let throttled = AimdThrottledStore::new(store.clone(), config).unwrap(); + + let path = Path::from("test/multipart_ordering.bin"); + let mut upload = throttled.put_multipart(&path).await.unwrap(); + + // Create futures for two parts in order: A then B. + let fut_a = upload.put_part(PutPayload::from_static(b"AAAA")); + let fut_b = upload.put_part(PutPayload::from_static(b"BBBB")); + + // Await in REVERSE order. Part ordering should be determined by + // creation order (put_part call order), not by await order. + fut_b.await.unwrap(); + fut_a.await.unwrap(); + + upload.complete().await.unwrap(); + + let result = store.get(&path).await.unwrap(); + let bytes = result.bytes().await.unwrap(); + + assert_eq!( + bytes.as_ref(), + b"AAAABBBB", + "Parts were reordered! Got {:?} instead of AAAABBBB.", + std::str::from_utf8(&bytes).unwrap_or(""), + ); + } } From d9068e76a301df9e21d7282419f24f61a11375ac Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Thu, 2 Apr 2026 21:37:28 +0000 Subject: [PATCH 132/206] chore: release beta version 5.0.0-beta.4 --- .bumpversion.toml | 2 +- Cargo.lock | 46 +++++++++++++++++++-------------------- Cargo.toml | 40 +++++++++++++++++----------------- java/lance-jni/Cargo.lock | 38 ++++++++++++++++---------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 38 ++++++++++++++++---------------- python/Cargo.toml | 2 +- 8 files changed, 85 insertions(+), 85 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 62e0d7d9e92..2ca3c888102 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "5.0.0-beta.3" +current_version = "5.0.0-beta.4" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index da2c0b4c412..8c641036c03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3348,7 +3348,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "lance-datagen", @@ -4580,7 +4580,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "all_asserts", "approx", @@ -4676,7 +4676,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "arrow-buffer", @@ -4725,7 +4725,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrayref", "paste", @@ -4734,7 +4734,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "arrow-buffer", @@ -4774,7 +4774,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -4808,7 +4808,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -4828,7 +4828,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-arith", "arrow-array", @@ -4876,7 +4876,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "all_asserts", "arrow", @@ -4902,7 +4902,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-arith", "arrow-array", @@ -4945,7 +4945,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "datafusion", "geo-traits", @@ -4959,7 +4959,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "approx", "arrow", @@ -5040,7 +5040,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-arith", @@ -5091,7 +5091,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "approx", "arrow-array", @@ -5112,7 +5112,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "async-trait", @@ -5127,7 +5127,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -5145,7 +5145,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-ipc", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -5250,7 +5250,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "proc-macro2", "quote", @@ -5259,7 +5259,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "arrow-schema", @@ -5270,7 +5270,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "clap", "lance-core", @@ -9980,9 +9980,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" diff --git a/Cargo.toml b/Cargo.toml index be576c73227..b057ed60205 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ resolver = "3" [workspace.package] -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -53,24 +53,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=5.0.0-beta.3", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=5.0.0-beta.3", path = "./rust/lance-arrow" } -lance-core = { version = "=5.0.0-beta.3", path = "./rust/lance-core" } -lance-datafusion = { version = "=5.0.0-beta.3", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=5.0.0-beta.3", path = "./rust/lance-datagen" } -lance-encoding = { version = "=5.0.0-beta.3", path = "./rust/lance-encoding" } -lance-file = { version = "=5.0.0-beta.3", path = "./rust/lance-file" } -lance-geo = { version = "=5.0.0-beta.3", path = "./rust/lance-geo" } -lance-index = { version = "=5.0.0-beta.3", path = "./rust/lance-index" } -lance-io = { version = "=5.0.0-beta.3", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=5.0.0-beta.3", path = "./rust/lance-linalg" } -lance-namespace = { version = "=5.0.0-beta.3", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=5.0.0-beta.3", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=5.0.0-beta.3", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=5.0.0-beta.4", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=5.0.0-beta.4", path = "./rust/lance-arrow" } +lance-core = { version = "=5.0.0-beta.4", path = "./rust/lance-core" } +lance-datafusion = { version = "=5.0.0-beta.4", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=5.0.0-beta.4", path = "./rust/lance-datagen" } +lance-encoding = { version = "=5.0.0-beta.4", path = "./rust/lance-encoding" } +lance-file = { version = "=5.0.0-beta.4", path = "./rust/lance-file" } +lance-geo = { version = "=5.0.0-beta.4", path = "./rust/lance-geo" } +lance-index = { version = "=5.0.0-beta.4", path = "./rust/lance-index" } +lance-io = { version = "=5.0.0-beta.4", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=5.0.0-beta.4", path = "./rust/lance-linalg" } +lance-namespace = { version = "=5.0.0-beta.4", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=5.0.0-beta.4", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=5.0.0-beta.4", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=5.0.0-beta.3", path = "./rust/lance-table" } -lance-test-macros = { version = "=5.0.0-beta.3", path = "./rust/lance-test-macros" } -lance-testing = { version = "=5.0.0-beta.3", path = "./rust/lance-testing" } +lance-table = { version = "=5.0.0-beta.4", path = "./rust/lance-table" } +lance-test-macros = { version = "=5.0.0-beta.4", path = "./rust/lance-test-macros" } +lance-testing = { version = "=5.0.0-beta.4", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -97,7 +97,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=5.0.0-beta.3", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=5.0.0-beta.4", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -138,7 +138,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=5.0.0-beta.3", path = "./rust/compression/fsst" } +fsst = { version = "=5.0.0-beta.4", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index fd01d8d2d81..04ae7e95373 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -2398,7 +2398,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3397,7 +3397,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-arith", @@ -3463,7 +3463,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "arrow-buffer", @@ -3483,7 +3483,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrayref", "paste", @@ -3492,7 +3492,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "arrow-buffer", @@ -3529,7 +3529,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -3562,7 +3562,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -3580,7 +3580,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-arith", "arrow-array", @@ -3617,7 +3617,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-arith", "arrow-array", @@ -3649,7 +3649,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "datafusion", "geo-traits", @@ -3663,7 +3663,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-arith", @@ -3731,7 +3731,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-arith", @@ -3775,7 +3775,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-schema", @@ -3809,7 +3809,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "arrow-buffer", @@ -3825,7 +3825,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "async-trait", @@ -3838,7 +3838,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-ipc", @@ -3883,7 +3883,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -7376,9 +7376,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index f4931f6e5fc..1520e59c3e3 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index e6f828cad96..08e3ca90a64 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 5.0.0-beta.3 + 5.0.0-beta.4 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index b1d8cf44bcb..40eecaaae8f 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2827,7 +2827,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3865,7 +3865,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-arith", @@ -3932,7 +3932,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "arrow-buffer", @@ -3952,7 +3952,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrayref", "paste", @@ -3961,7 +3961,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "arrow-buffer", @@ -3998,7 +3998,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -4031,7 +4031,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-arith", "arrow-array", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-arith", "arrow-array", @@ -4118,7 +4118,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "datafusion", "geo-traits", @@ -4132,7 +4132,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-arith", @@ -4203,7 +4203,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-arith", @@ -4247,7 +4247,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow-array", "arrow-buffer", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "async-trait", @@ -4276,7 +4276,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-ipc", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -5685,7 +5685,7 @@ dependencies = [ [[package]] name = "pylance" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" dependencies = [ "arrow", "arrow-array", @@ -8339,9 +8339,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" diff --git a/python/Cargo.toml b/python/Cargo.toml index d5d7072ed7f..843cd68acab 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "5.0.0-beta.3" +version = "5.0.0-beta.4" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From b36df7da96191051b813a78e1f7e1c95d90884ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:57:19 -0700 Subject: [PATCH 133/206] chore(deps): bump requests from 2.32.5 to 2.33.0 in /python (#6299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.

    Release notes

    Sourced from requests's releases.

    v2.33.0

    2.33.0 (2026-03-25)

    Announcements

    • 📣 Requests is adding inline types. If you have a typed code base that uses Requests, please take a look at #7271. Give it a try, and report any gaps or feedback you may have in the issue. 📣

    Security

    • CVE-2026-25645 requests.utils.extract_zipped_paths now extracts contents to a non-deterministic location to prevent malicious file replacement. This does not affect default usage of Requests, only applications calling the utility function directly.

    Improvements

    • Migrated to a PEP 517 build system using setuptools. (#7012)

    Bugfixes

    • Fixed an issue where an empty netrc entry could cause malformed authentication to be applied to Requests on Python 3.11+. (#7205)

    Deprecations

    • Dropped support for Python 3.9 following its end of support. (#7196)

    Documentation

    • Various typo fixes and doc improvements.

    New Contributors

    Full Changelog: https://github.com/psf/requests/blob/main/HISTORY.md#2330-2026-03-25

    Changelog

    Sourced from requests's changelog.

    2.33.0 (2026-03-25)

    Announcements

    • 📣 Requests is adding inline types. If you have a typed code base that uses Requests, please take a look at #7271. Give it a try, and report any gaps or feedback you may have in the issue. 📣

    Security

    • CVE-2026-25645 requests.utils.extract_zipped_paths now extracts contents to a non-deterministic location to prevent malicious file replacement. This does not affect default usage of Requests, only applications calling the utility function directly.

    Improvements

    • Migrated to a PEP 517 build system using setuptools. (#7012)

    Bugfixes

    • Fixed an issue where an empty netrc entry could cause malformed authentication to be applied to Requests on Python 3.11+. (#7205)

    Deprecations

    • Dropped support for Python 3.9 following its end of support. (#7196)

    Documentation

    • Various typo fixes and doc improvements.
    Commits
    • bc04dfd v2.33.0
    • 66d21cb Merge commit from fork
    • 8b9bc8f Move badges to top of README (#7293)
    • e331a28 Remove unused extraction call (#7292)
    • 753fd08 docs: fix FAQ grammar in httplib2 example
    • 774a0b8 docs(socks): same block as other sections
    • 9c72a41 Bump github/codeql-action from 4.33.0 to 4.34.1
    • ebf7190 Bump github/codeql-action from 4.32.0 to 4.33.0
    • 0e4ae38 docs: exclude Response.is_permanent_redirect from API docs (#7244)
    • d568f47 docs: clarify Quickstart POST example (#6960)
    • Additional commits viewable in compare view

    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/uv.lock | 333 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 226 insertions(+), 107 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index d4158924cab..62cf84316d0 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -33,14 +33,14 @@ name = "aiohttp" version = "3.12.15" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, + { name = "aiohappyeyeballs", marker = "python_full_version >= '3.10'" }, + { name = "aiosignal", marker = "python_full_version >= '3.10'" }, + { name = "async-timeout", marker = "python_full_version == '3.10.*'" }, + { name = "attrs", marker = "python_full_version >= '3.10'" }, + { name = "frozenlist", marker = "python_full_version >= '3.10'" }, + { name = "multidict", marker = "python_full_version >= '3.10'" }, + { name = "propcache", marker = "python_full_version >= '3.10'" }, + { name = "yarl", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } wheels = [ @@ -136,8 +136,8 @@ name = "aiosignal" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "frozenlist", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -399,27 +399,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/ae/3fdea50fa88f304db96728a67deb6e07bb0d9a02f665ca09db4237a9a199/datafusion-52.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:67e252ef20b918537c8fdb47e6c825c0bd639795e19715a85fedde331a83d2e1", size = 33717685, upload-time = "2026-02-23T12:22:44.463Z" }, ] +[[package]] +name = "datasets" +version = "0.0.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/fe/4d2874473a753d59c83335691bd9532704f2605418a0d288a1d70fa003fc/datasets-0.0.9.zip", hash = "sha256:86d54441bab87aebb2aa3bf0853aa7fb7abed8c708f9bb08a88e86a498972010", size = 4013, upload-time = "2015-08-18T00:07:40.556Z" } + [[package]] name = "datasets" version = "4.1.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] dependencies = [ - { name = "dill" }, - { name = "filelock" }, - { name = "fsspec", extra = ["http"] }, - { name = "huggingface-hub" }, - { name = "multiprocess" }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "dill", marker = "python_full_version >= '3.10'" }, + { name = "filelock", marker = "python_full_version >= '3.10'" }, + { name = "fsspec", extra = ["http"], marker = "python_full_version >= '3.10'" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.10'" }, + { name = "multiprocess", marker = "python_full_version >= '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pandas", marker = "python_full_version >= '3.10'" }, + { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "tqdm" }, - { name = "xxhash" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, + { name = "requests", marker = "python_full_version >= '3.10'" }, + { name = "tqdm", marker = "python_full_version >= '3.10'" }, + { name = "xxhash", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/a4/73f8e6ef52c535e1d20d5b2ca83bfe6de399d8b8b8a61ccc8d63d60735aa/datasets-4.1.1.tar.gz", hash = "sha256:7d8d5ba8b12861d2c44bfff9c83484ebfafff1ff553371e5901a8d3aab5450e2", size = 579324, upload-time = "2025-09-18T13:14:27.108Z" } wheels = [ @@ -494,10 +509,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] +[[package]] +name = "flatbuffers" +version = "2.0.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/90/0532e737a11e1dc50e9e352c3ccc97338cb75991f83279c2edbc9234e022/flatbuffers-2.0.7.tar.gz", hash = "sha256:0ae7d69c5b82bf41962ca5fde9cc43033bc9501311d975fd5a25e8a7d29c1245", size = 22686, upload-time = "2022-08-23T22:50:07.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/0d/b5bfb553a6ac66d6ec2b6d7f1e814a908fba7188356ac94bb36ae3d905c3/flatbuffers-2.0.7-py2.py3-none-any.whl", hash = "sha256:71e135d533be527192819aaab757c5e3d109cb10fbb01e687f6bdb7a61ad39d1", size = 26562, upload-time = "2022-08-23T22:50:56.342Z" }, +] + [[package]] name = "flatbuffers" version = "25.9.23" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] sdist = { url = "https://files.pythonhosted.org/packages/9d/1f/3ee70b0a55137442038f2a33469cc5fddd7e0ad2abf83d7497c18a2b6923/flatbuffers-25.9.23.tar.gz", hash = "sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12", size = 22067, upload-time = "2025-09-24T05:25:30.106Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ee/1b/00a78aa2e8fbd63f9af08c9c19e6deb3d5d66b4dda677a0f61654680ee89/flatbuffers-25.9.23-py2.py3-none-any.whl", hash = "sha256:255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2", size = 30869, upload-time = "2025-09-24T05:25:28.912Z" }, @@ -625,13 +659,32 @@ wheels = [ [package.optional-dependencies] http = [ - { name = "aiohttp" }, + { name = "aiohttp", marker = "python_full_version >= '3.10'" }, +] + +[[package]] +name = "gast" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/83/4a/07c7e59cef23fb147454663c3271c21da68ba2ab141427c20548ae5a8a4d/gast-0.4.0.tar.gz", hash = "sha256:40feb7b8b8434785585ab224d1568b857edb18297e5a3047f1ba012bc83b42c1", size = 13804, upload-time = "2020-08-07T21:45:23.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/48/583c032b79ae5b3daa02225a675aeb673e58d2cb698e78510feceb11958c/gast-0.4.0-py3-none-any.whl", hash = "sha256:b7adcdd5adbebf1adf17378da5ba3f543684dbec47b1cda1f3997e573cd542c4", size = 9824, upload-time = "2020-08-07T21:45:21.32Z" }, ] [[package]] name = "gast" version = "0.6.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] sdist = { url = "https://files.pythonhosted.org/packages/3c/14/c566f5ca00c115db7725263408ff952b8ae6d6a4e792ef9c84e77d9af7a1/gast-0.6.0.tar.gz", hash = "sha256:88fc5300d32c7ac6ca7b515310862f71e6fdf2c029bbec7c66c0f5dd47b6b1fb", size = 27708, upload-time = "2024-06-27T20:31:49.527Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a3/61/8001b38461d751cd1a0c3a6ae84346796a5758123f3ed97a1b121dfbf4f3/gast-0.6.0-py3-none-any.whl", hash = "sha256:52b182313f7330389f72b069ba00f174cfe2a06411099547288839c6cbafbd54", size = 21173, upload-time = "2024-07-09T13:15:15.615Z" }, @@ -878,14 +931,14 @@ name = "huggingface-hub" version = "0.35.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "tqdm" }, - { name = "typing-extensions" }, + { name = "filelock", marker = "python_full_version >= '3.10'" }, + { name = "fsspec", marker = "python_full_version >= '3.10'" }, + { name = "hf-xet", marker = "(python_full_version >= '3.10' and platform_machine == 'aarch64') or (python_full_version >= '3.10' and platform_machine == 'amd64') or (python_full_version >= '3.10' and platform_machine == 'arm64') or (python_full_version >= '3.10' and platform_machine == 'x86_64')" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, + { name = "requests", marker = "python_full_version >= '3.10'" }, + { name = "tqdm", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/7e/a0a97de7c73671863ca6b3f61fa12518caf35db37825e43d63a70956738c/huggingface_hub-0.35.3.tar.gz", hash = "sha256:350932eaa5cc6a4747efae85126ee220e4ef1b54e29d31c3b45c5612ddf0b32a", size = 461798, upload-time = "2025-09-29T14:29:58.625Z" } wheels = [ @@ -945,24 +998,13 @@ wheels = [ [[package]] name = "keras" -version = "3.10.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] -dependencies = [ - { name = "absl-py", marker = "python_full_version < '3.10'" }, - { name = "h5py", marker = "python_full_version < '3.10'" }, - { name = "ml-dtypes", marker = "python_full_version < '3.10'" }, - { name = "namex", marker = "python_full_version < '3.10'" }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "optree", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "rich", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/fe/2946daf8477ae38a4b480c8889c72ede4f36eb28f9e1a27fc355cd633c3d/keras-3.10.0.tar.gz", hash = "sha256:6e9100bf66eaf6de4b7f288d34ef9bb8b5dcdd62f42c64cfd910226bb34ad2d2", size = 1040781, upload-time = "2025-05-19T22:58:30.833Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/e6/4179c461a5fc43e3736880f64dbdc9b1a5349649f0ae32ded927c0e3a227/keras-3.10.0-py3-none-any.whl", hash = "sha256:c095a6bf90cd50defadf73d4859ff794fad76b775357ef7bd1dbf96388dae7d3", size = 1380082, upload-time = "2025-05-19T22:58:28.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8b/065f94ba03282fa41b2d76942b87a180a9913312c4611ea7d6508fbbc114/keras-2.7.0-py2.py3-none-any.whl", hash = "sha256:0c33ae1f728064ca0d35dfba999e9c316f03623bf5688c82fb83cc74a80ea248", size = 1332171, upload-time = "2021-11-03T16:16:34.318Z" }, ] [[package]] @@ -992,6 +1034,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/5b/4c778cc921ce4b864b238f63f8e3ff6e954ab19b80c9fa680593ad8093d4/keras-3.11.3-py3-none-any.whl", hash = "sha256:f484f050e05ee400455b05ec8c36ed35edc34de94256b6073f56cfe68f65491f", size = 1408438, upload-time = "2025-08-21T22:08:55.858Z" }, ] +[[package]] +name = "keras-preprocessing" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "six", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/b44337faca48874333769a29398fe4666686733c8880aa160b9fd5dfe600/Keras_Preprocessing-1.1.2.tar.gz", hash = "sha256:add82567c50c8bc648c14195bf544a5ce7c1f76761536956c3d2978970179ef3", size = 163598, upload-time = "2020-05-14T03:53:48.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/4c/7c3275a01e12ef9368a892926ab932b33bb13d55794881e3573482b378a7/Keras_Preprocessing-1.1.2-py2.py3-none-any.whl", hash = "sha256:7b82029b130ff61cc99b55f3bd27427df4838576838c5b2f65940e4fcec99a7b", size = 42581, upload-time = "2020-05-14T03:53:47.192Z" }, +] + [[package]] name = "lance-namespace" version = "0.5.2" @@ -1044,32 +1099,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, ] -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "mdurl", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ { name = "mdurl", marker = "python_full_version >= '3.10'" }, ] @@ -1244,7 +1277,7 @@ name = "multidict" version = "6.6.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } wheels = [ @@ -1364,7 +1397,7 @@ name = "multiprocess" version = "0.70.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dill" }, + { name = "dill", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/ae/04f39c5d0d0def03247c2893d6f2b83c136bf3320a2154d7b8858f2ba72d/multiprocess-0.70.16.tar.gz", hash = "sha256:161af703d4652a0e1410be6abccecde4a7ddffd19341be0a7011b94aeb171ac1", size = 1772603, upload-time = "2024-01-28T18:52:34.85Z" } wheels = [ @@ -1783,7 +1816,7 @@ name = "optree" version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/c7/0853e0c59b135dff770615d2713b547b6b3b5cde7c10995b4a5825244612/optree-0.17.0.tar.gz", hash = "sha256:5335a5ec44479920620d72324c66563bd705ab2a698605dd4b6ee67dbcad7ecd", size = 163111, upload-time = "2025-07-25T11:26:11.586Z" } wheels = [ @@ -2178,10 +2211,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "protobuf" +version = "3.19.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/d1/79bfd1f481469b661a2eddab551255536401892722189433282bfb13cfb1/protobuf-3.19.6.tar.gz", hash = "sha256:5f5540d57a43042389e87661c6eaa50f47c19c6176e8cf1c4f287aeefeccb5c4", size = 218071, upload-time = "2022-09-29T22:07:23.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/3b/90f805b9e5ecacf8a216f2e5acabc2d3ad965b62803510be41804e6bfbfe/protobuf-3.19.6-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:010be24d5a44be7b0613750ab40bc8b8cedc796db468eae6c779b395f50d1fa1", size = 913631, upload-time = "2022-09-29T21:17:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/26/ef/bd6ba3b4ff9a35944bdd325e2c9ee56f71e855757f7d43938232499f0278/protobuf-3.19.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11478547958c2dfea921920617eb457bc26867b0d1aa065ab05f35080c5d9eb6", size = 1055327, upload-time = "2022-09-29T21:17:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/bc/db/8b33c9558f1f27dd74e7f9ad730c6b32efab431419af556b1659e125b041/protobuf-3.19.6-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:30a15015d86b9c3b8d6bf78d5b8c7749f2512c29f168ca259c9d7727604d0e39", size = 913657, upload-time = "2022-09-29T21:18:18.359Z" }, + { url = "https://files.pythonhosted.org/packages/51/61/e80b7a04f4e1b4eecc86582335205fd876abca0abafee4a6c001f70a375e/protobuf-3.19.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:878b4cd080a21ddda6ac6d1e163403ec6eea2e206cf225982ae04567d39be7b0", size = 1055457, upload-time = "2022-09-29T21:18:20.212Z" }, + { url = "https://files.pythonhosted.org/packages/32/27/1141a8232723dcb10a595cc0ce4321dcbbd5215300bf4acfc142343205bf/protobuf-3.19.6-py2.py3-none-any.whl", hash = "sha256:14082457dc02be946f60b15aad35e9f5c69e738f80ebbc0900a19bc83734a5a4", size = 162648, upload-time = "2022-09-29T22:07:20.303Z" }, +] + [[package]] name = "protobuf" version = "6.32.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] sdist = { url = "https://files.pythonhosted.org/packages/fa/a4/cc17347aa2897568beece2e674674359f911d6fe21b0b8d6268cd42727ac/protobuf-6.32.1.tar.gz", hash = "sha256:ee2469e4a021474ab9baafea6cd070e5bf27c7d29433504ddea1a4ee5850f68d", size = 440635, upload-time = "2025-09-11T21:38:42.935Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3f/be/8dd0a927c559b37d7a6c8ab79034fd167dcc1f851595f2e641ad62be8643/protobuf-6.32.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:2f5b80a49e1eb7b86d85fcd23fe92df154b9730a725c3b38c4e43b9d77018bf4", size = 322874, upload-time = "2025-09-11T21:38:35.509Z" }, @@ -2513,7 +2569,8 @@ geo = [ tests = [ { name = "boto3" }, { name = "datafusion", marker = "python_full_version >= '3.10'" }, - { name = "datasets" }, + { name = "datasets", version = "0.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "datasets", version = "4.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "duckdb" }, { name = "ml-dtypes" }, { name = "pandas" }, @@ -2521,7 +2578,8 @@ tests = [ { name = "polars", extra = ["pandas", "pyarrow"] }, { name = "psutil" }, { name = "pytest" }, - { name = "tensorflow", marker = "sys_platform == 'linux'" }, + { name = "tensorflow", version = "2.7.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'linux'" }, + { name = "tensorflow", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'linux'" }, { name = "tqdm" }, ] torch = [ @@ -2851,18 +2909,17 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]] @@ -2870,9 +2927,8 @@ name = "rich" version = "14.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pygments" }, + { name = "markdown-it-py", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } wheels = [ @@ -2959,7 +3015,8 @@ dependencies = [ { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, { name = "pillow" }, - { name = "protobuf" }, + { name = "protobuf", version = "3.19.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "protobuf", version = "6.32.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "setuptools" }, { name = "tensorboard-data-server" }, { name = "werkzeug" }, @@ -2977,35 +3034,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" }, ] +[[package]] +name = "tensorflow" +version = "2.7.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "absl-py", marker = "python_full_version < '3.10'" }, + { name = "astunparse", marker = "python_full_version < '3.10'" }, + { name = "flatbuffers", version = "2.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "gast", version = "0.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "google-pasta", marker = "python_full_version < '3.10'" }, + { name = "grpcio", marker = "python_full_version < '3.10'" }, + { name = "h5py", marker = "python_full_version < '3.10'" }, + { name = "keras", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "keras-preprocessing", marker = "python_full_version < '3.10'" }, + { name = "libclang", marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opt-einsum", marker = "python_full_version < '3.10'" }, + { name = "protobuf", version = "3.19.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "six", marker = "python_full_version < '3.10'" }, + { name = "tensorboard", marker = "python_full_version < '3.10'" }, + { name = "tensorflow-estimator", marker = "python_full_version < '3.10'" }, + { name = "tensorflow-io-gcs-filesystem", marker = "python_full_version < '3.10'" }, + { name = "termcolor", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "wheel", marker = "python_full_version < '3.10'" }, + { name = "wrapt", marker = "python_full_version < '3.10'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/31/d49a3dff9c4ca6e6c09c2c5fea95f58cf59cc3cd4f0d557069c7dccd6f57/tensorflow-2.7.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c4597635dd71fc6809b7fffcb462524d73e2ade09da61844059e6a2fead71140", size = 496066688, upload-time = "2022-09-02T19:11:01.631Z" }, +] + [[package]] name = "tensorflow" version = "2.20.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] dependencies = [ - { name = "absl-py" }, - { name = "astunparse" }, - { name = "flatbuffers" }, - { name = "gast" }, - { name = "google-pasta" }, - { name = "grpcio" }, - { name = "h5py" }, - { name = "keras", version = "3.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "absl-py", marker = "python_full_version >= '3.10'" }, + { name = "astunparse", marker = "python_full_version >= '3.10'" }, + { name = "flatbuffers", version = "25.9.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "gast", version = "0.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "google-pasta", marker = "python_full_version >= '3.10'" }, + { name = "grpcio", marker = "python_full_version >= '3.10'" }, + { name = "h5py", marker = "python_full_version >= '3.10'" }, { name = "keras", version = "3.11.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "libclang" }, - { name = "ml-dtypes" }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "libclang", marker = "python_full_version >= '3.10'" }, + { name = "ml-dtypes", marker = "python_full_version >= '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "opt-einsum" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "requests" }, - { name = "setuptools" }, - { name = "six" }, - { name = "tensorboard" }, - { name = "termcolor" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "opt-einsum", marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "protobuf", version = "6.32.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests", marker = "python_full_version >= '3.10'" }, + { name = "setuptools", marker = "python_full_version >= '3.10'" }, + { name = "six", marker = "python_full_version >= '3.10'" }, + { name = "tensorboard", marker = "python_full_version >= '3.10'" }, + { name = "termcolor", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, + { name = "wrapt", marker = "python_full_version >= '3.10'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ff/07/ea91ac67a9fd36d3372099f5a3e69860ded544f877f5f2117802388f4212/tensorflow-2.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02a0293d94f5c8b7125b66abf622cc4854a33ae9d618a0d41309f95e091bbaea", size = 259307122, upload-time = "2025-08-13T16:50:47.909Z" }, @@ -3020,6 +3116,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/fe/f3d738dc7c93ed5f67f9ace8dd3ed66971dab7c5a47f2d1c504ef0d0cf1d/tensorflow-2.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0deb5c583dfc53b54fd158a194ce0087b406bb6518af400ca3809735e4548ec3", size = 620427169, upload-time = "2025-08-13T16:54:33.431Z" }, ] +[[package]] +name = "tensorflow-estimator" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/de/3a71ad41b87f9dd424e3aec3b0794a60f169fa7e9a9a1e3dd44290b86dd6/tensorflow_estimator-2.7.0-py2.py3-none-any.whl", hash = "sha256:325b5a224864379242b7b76c6987ca544239be82579d33e68ec7c2bda57abc9d", size = 463110, upload-time = "2021-10-29T23:02:47.14Z" }, +] + +[[package]] +name = "tensorflow-io-gcs-filesystem" +version = "0.37.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/19/9095c69e22c879cb3896321e676c69273a549a3148c4f62aa4bc5ebdb20f/tensorflow_io_gcs_filesystem-0.37.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8febbfcc67c61e542a5ac1a98c7c20a91a5e1afc2e14b1ef0cb7c28bc3b6aa70", size = 4842078, upload-time = "2024-07-01T23:44:18.977Z" }, + { url = "https://files.pythonhosted.org/packages/f3/48/47b7d25572961a48b1de3729b7a11e835b888e41e0203cca82df95d23b91/tensorflow_io_gcs_filesystem-0.37.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9679b36e3a80921876f31685ab6f7270f3411a4cc51bc2847e80d0e4b5291e27", size = 5085736, upload-time = "2024-07-01T23:44:21.034Z" }, + { url = "https://files.pythonhosted.org/packages/de/bf/ba597d3884c77d05a78050f3c178933d69e3f80200a261df6eaa920656cd/tensorflow_io_gcs_filesystem-0.37.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e1f2796b57e799a8ca1b75bf47c2aaa437c968408cc1a402a9862929e104cda", size = 4842079, upload-time = "2024-07-01T23:44:26.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/e36ae148c2f03d61ca1bff24bc13a0fef6d6825c966abef73fc6f880a23b/tensorflow_io_gcs_filesystem-0.37.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee7c8ee5fe2fd8cb6392669ef16e71841133041fee8a330eff519ad9b36e4556", size = 5085736, upload-time = "2024-07-01T23:44:28.618Z" }, + { url = "https://files.pythonhosted.org/packages/d3/46/962f47af08bd39fc9feb280d3192825431a91a078c856d17a78ae4884eb1/tensorflow_io_gcs_filesystem-0.37.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fbb33f1745f218464a59cecd9a18e32ca927b0f4d77abd8f8671b645cc1a182f", size = 4842077, upload-time = "2024-07-01T23:44:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9b/790d290c232bce9b691391cf16e95a96e469669c56abfb1d9d0f35fa437c/tensorflow_io_gcs_filesystem-0.37.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:286389a203a5aee1a4fa2e53718c661091aa5fea797ff4fa6715ab8436b02e6c", size = 5085733, upload-time = "2024-07-01T23:44:36.663Z" }, + { url = "https://files.pythonhosted.org/packages/66/5f/334a011caa1eb97689274d1141df8e6b7a25e389f0390bdcd90235de9783/tensorflow_io_gcs_filesystem-0.37.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:426de1173cb81fbd62becec2012fc00322a295326d90eb6c737fab636f182aed", size = 4842075, upload-time = "2024-07-01T23:44:42.094Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cb/7dcee55fc5a7d7d8a862e12519322851cd5fe5b086f946fd71e4ae1ef281/tensorflow_io_gcs_filesystem-0.37.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df00891669390078a003cedbdd3b8e645c718b111917535fa1d7725e95cdb95", size = 5087496, upload-time = "2024-07-01T23:44:43.797Z" }, +] + [[package]] name = "termcolor" version = "3.1.0" @@ -3408,9 +3527,9 @@ name = "yarl" version = "1.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "multidict", marker = "python_full_version >= '3.10'" }, + { name = "propcache", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } wheels = [ From 36b344fb4f6396c4c119fa50f55737cfbfc64a87 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Thu, 2 Apr 2026 15:00:26 -0700 Subject: [PATCH 134/206] feat: thread data_size through decode pipeline (#6391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Threads accurate `data_size` (in bytes) from `DataBlock::data_size()` at the encoding layer through the full decode pipeline to the final `RecordBatch` - Implements `DataBlock::data_size()` for `Struct` and `Dictionary` variants (were `todo!()`) - Uses the accurate data size for the "batch is too large" warning instead of Arrow's `get_array_memory_size()`, which over-reports due to shared page buffers - Changes `DecodeArrayTask::decode()` to return `(ArrayRef, u64)` so data size flows through naturally ## Test plan - [x] All 364 existing `lance-encoding` tests pass - [x] `cargo clippy -p lance-encoding --tests -- -D warnings` clean - [x] `cargo clippy -p lance-file --tests -- -D warnings` clean - [x] `cargo fmt --all -- --check` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 --- rust/lance-encoding/src/data.rs | 8 +-- rust/lance-encoding/src/decoder.rs | 60 ++++++++++--------- .../src/encodings/logical/fixed_size_list.rs | 7 ++- .../src/encodings/logical/list.rs | 8 ++- .../src/encodings/logical/map.rs | 7 ++- .../src/encodings/logical/primitive.rs | 8 ++- .../src/encodings/logical/struct.rs | 5 ++ .../src/previous/encodings/logical/binary.rs | 21 +++---- .../src/previous/encodings/logical/blob.rs | 15 ++--- .../src/previous/encodings/logical/list.rs | 21 ++++--- .../previous/encodings/logical/primitive.rs | 8 ++- .../src/previous/encodings/logical/struct.rs | 27 +++++---- .../src/previous/encodings/physical/binary.rs | 2 +- .../previous/encodings/physical/dictionary.rs | 2 +- 14 files changed, 117 insertions(+), 82 deletions(-) diff --git a/rust/lance-encoding/src/data.rs b/rust/lance-encoding/src/data.rs index f2cf9c5b825..fa4ffe3021e 100644 --- a/rust/lance-encoding/src/data.rs +++ b/rust/lance-encoding/src/data.rs @@ -970,12 +970,8 @@ impl DataBlock { Self::FixedWidth(inner) => inner.data_size(), Self::FixedSizeList(inner) => inner.data_size(), Self::VariableWidth(inner) => inner.data_size(), - Self::Struct(_) => { - todo!("the data_size method for StructDataBlock is not implemented yet") - } - Self::Dictionary(_) => { - todo!("the data_size method for DictionaryDataBlock is not implemented yet") - } + Self::Struct(inner) => inner.children.iter().map(|child| child.data_size()).sum(), + Self::Dictionary(inner) => inner.indices.data_size() + inner.dictionary.data_size(), Self::Opaque(inner) => inner.data_size(), } } diff --git a/rust/lance-encoding/src/decoder.rs b/rust/lance-encoding/src/decoder.rs index 4f6e8b06083..aa1bdaed777 100644 --- a/rust/lance-encoding/src/decoder.rs +++ b/rust/lance-encoding/src/decoder.rs @@ -1472,9 +1472,13 @@ impl BatchDecodeStream { // Real decode work happens inside into_batch, which can block the current // thread for a long time. By spawning it as a new task, we allow Tokio's // worker threads to keep making progress. - tokio::spawn(async move { next_task.into_batch(emitted_batch_size_warning) }) + let (batch, _data_size) = + tokio::spawn( + async move { next_task.into_batch(emitted_batch_size_warning) }, + ) .await - .map_err(|err| Error::wrapped(err.into()))? + .map_err(|err| Error::wrapped(err.into()))??; + Ok(batch) }; (task, num_rows) }); @@ -1660,7 +1664,7 @@ impl BatchDecodeIterator { self.rows_drained += to_take; - let batch = next_task.into_batch(self.emitted_batch_size_warning.clone())?; + let (batch, _data_size) = next_task.into_batch(self.emitted_batch_size_warning.clone())?; Ok(Some(batch)) } @@ -1812,15 +1816,16 @@ impl StructuralBatchDecodeStream { let spawn_batch_decode_tasks = slf.spawn_batch_decode_tasks; let task = async move { let next_task = next_task?; - if spawn_batch_decode_tasks { + let (batch, _data_size) = if spawn_batch_decode_tasks { tokio::spawn( async move { next_task.into_batch(emitted_batch_size_warning) }, ) .await - .map_err(|err| Error::wrapped(err.into()))? + .map_err(|err| Error::wrapped(err.into()))?? } else { - next_task.into_batch(emitted_batch_size_warning) - } + next_task.into_batch(emitted_batch_size_warning)? + }; + Ok(batch) }; (task, num_rows) }); @@ -2516,13 +2521,14 @@ pub trait StructuralFieldScheduler: Send + std::fmt::Debug { /// A trait for tasks that decode data into an Arrow array pub trait DecodeArrayTask: Send { - /// Decodes the data into an Arrow array - fn decode(self: Box) -> Result; + /// Decodes the data into an Arrow array and its data size in bytes + fn decode(self: Box) -> Result<(ArrayRef, u64)>; } impl DecodeArrayTask for Box { - fn decode(self: Box) -> Result { - StructuralDecodeArrayTask::decode(*self).map(|decoded_array| decoded_array.array) + fn decode(self: Box) -> Result<(ArrayRef, u64)> { + StructuralDecodeArrayTask::decode(*self) + .map(|decoded_array| (decoded_array.array, decoded_array.data_size)) } } @@ -2543,25 +2549,19 @@ impl NextDecodeTask { // If the batch is very large this function will log a warning message // suggesting the user try a smaller batch size. #[instrument(name = "task_to_batch", level = "debug", skip_all)] - fn into_batch(self, emitted_batch_size_warning: Arc) -> Result { - let struct_arr = self.task.decode(); - match struct_arr { - Ok(struct_arr) => { - let batch = RecordBatch::from(struct_arr.as_struct()); - let size_bytes = batch.get_array_memory_size() as u64; - if size_bytes > BATCH_SIZE_BYTES_WARNING { - emitted_batch_size_warning.call_once(|| { - let size_mb = size_bytes / 1024 / 1024; - debug!("Lance read in a single batch that contained more than {}MiB of data. You may want to consider reducing the batch size.", size_mb); - }); - } - Ok(batch) - } - Err(e) => { - let e = Error::internal(format!("Error decoding batch: {}", e)); - Err(e) - } + fn into_batch(self, emitted_batch_size_warning: Arc) -> Result<(RecordBatch, u64)> { + let (struct_arr, data_size) = self + .task + .decode() + .map_err(|e| Error::internal(format!("Error decoding batch: {}", e)))?; + let batch = RecordBatch::from(struct_arr.as_struct()); + if data_size > BATCH_SIZE_BYTES_WARNING { + emitted_batch_size_warning.call_once(|| { + let size_mb = data_size / 1024 / 1024; + debug!("Lance read in a single batch that contained more than {}MiB of data. You may want to consider reducing the batch size.", size_mb); + }); } + Ok((batch, data_size)) } } @@ -2659,6 +2659,8 @@ pub struct LoadedPageShard { pub struct DecodedArray { pub array: ArrayRef, pub repdef: CompositeRepDefUnraveler, + /// The number of bytes of data in this array (excluding Arrow overhead). + pub data_size: u64, } pub trait StructuralDecodeArrayTask: std::fmt::Debug + Send { diff --git a/rust/lance-encoding/src/encodings/logical/fixed_size_list.rs b/rust/lance-encoding/src/encodings/logical/fixed_size_list.rs index 4de11cd18fb..9e8e3e109ea 100644 --- a/rust/lance-encoding/src/encodings/logical/fixed_size_list.rs +++ b/rust/lance-encoding/src/encodings/logical/fixed_size_list.rs @@ -247,7 +247,11 @@ impl StructuralFixedSizeListDecodeTask { impl StructuralDecodeArrayTask for StructuralFixedSizeListDecodeTask { fn decode(self: Box) -> Result { - let DecodedArray { array, mut repdef } = self.child_task.decode()?; + let DecodedArray { + array, + mut repdef, + data_size, + } = self.child_task.decode()?; match &self.data_type { DataType::FixedSizeList(child_field, dimension) => { let num_rows = self.num_rows as usize; @@ -261,6 +265,7 @@ impl StructuralDecodeArrayTask for StructuralFixedSizeListDecodeTask { Ok(DecodedArray { array: Arc::new(fsl_array), repdef, + data_size, }) } _ => Err(Error::internal( diff --git a/rust/lance-encoding/src/encodings/logical/list.rs b/rust/lance-encoding/src/encodings/logical/list.rs index 674e9d8ae4b..01422ae5dc0 100644 --- a/rust/lance-encoding/src/encodings/logical/list.rs +++ b/rust/lance-encoding/src/encodings/logical/list.rs @@ -195,7 +195,11 @@ impl StructuralListDecodeTask { impl StructuralDecodeArrayTask for StructuralListDecodeTask { fn decode(self: Box) -> Result { - let DecodedArray { array, mut repdef } = self.child_task.decode()?; + let DecodedArray { + array, + mut repdef, + data_size, + } = self.child_task.decode()?; match &self.data_type { DataType::List(child_field) => { let (offsets, validity) = repdef.unravel_offsets::()?; @@ -209,6 +213,7 @@ impl StructuralDecodeArrayTask for StructuralListDecodeTask { Ok(DecodedArray { array: Arc::new(list_array), repdef, + data_size, }) } DataType::LargeList(child_field) => { @@ -218,6 +223,7 @@ impl StructuralDecodeArrayTask for StructuralListDecodeTask { Ok(DecodedArray { array: Arc::new(list_array), repdef, + data_size, }) } _ => panic!("List decoder did not have a list field"), diff --git a/rust/lance-encoding/src/encodings/logical/map.rs b/rust/lance-encoding/src/encodings/logical/map.rs index 7ac53946063..b5172d8c189 100644 --- a/rust/lance-encoding/src/encodings/logical/map.rs +++ b/rust/lance-encoding/src/encodings/logical/map.rs @@ -187,7 +187,11 @@ impl StructuralMapDecodeTask { impl StructuralDecodeArrayTask for StructuralMapDecodeTask { fn decode(self: Box) -> Result { - let DecodedArray { array, mut repdef } = self.child_task.decode()?; + let DecodedArray { + array, + mut repdef, + data_size, + } = self.child_task.decode()?; // Decode the offsets from RepDef let (offsets, validity) = repdef.unravel_offsets::()?; @@ -224,6 +228,7 @@ impl StructuralDecodeArrayTask for StructuralMapDecodeTask { Ok(DecodedArray { array: Arc::new(map_array), repdef, + data_size, }) } } diff --git a/rust/lance-encoding/src/encodings/logical/primitive.rs b/rust/lance-encoding/src/encodings/logical/primitive.rs index f3ad7ca7e87..6bcdbc5e56b 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive.rs @@ -3509,8 +3509,10 @@ impl StructuralDecodeArrayTask for StructuralCompositeDecodeArrayTask { fn decode(self: Box) -> Result { let mut arrays = Vec::with_capacity(self.tasks.len()); let mut unravelers = Vec::with_capacity(self.tasks.len()); + let mut data_size = 0u64; for task in self.tasks { let decoded = task.decode()?; + data_size += decoded.data.data_size(); unravelers.push(decoded.repdef); let array = make_array( @@ -3527,7 +3529,11 @@ impl StructuralDecodeArrayTask for StructuralCompositeDecodeArrayTask { let array = Self::restore_validity(array, &mut repdef); - Ok(DecodedArray { array, repdef }) + Ok(DecodedArray { + array, + repdef, + data_size, + }) } } diff --git a/rust/lance-encoding/src/encodings/logical/struct.rs b/rust/lance-encoding/src/encodings/logical/struct.rs index 9199d1dcb19..eee3404abdf 100644 --- a/rust/lance-encoding/src/encodings/logical/struct.rs +++ b/rust/lance-encoding/src/encodings/logical/struct.rs @@ -358,6 +358,7 @@ impl StructuralDecodeArrayTask for RepDefStructDecodeTask { return Ok(DecodedArray { array: Arc::new(StructArray::new_empty_fields(self.num_rows as usize, None)), repdef: CompositeRepDefUnraveler::new(vec![]), + data_size: 0, }); } @@ -367,16 +368,19 @@ impl StructuralDecodeArrayTask for RepDefStructDecodeTask { .map(|task| task.decode()) .collect::>>()?; let mut children = Vec::with_capacity(arrays.len()); + let mut data_size = 0u64; let mut arrays_iter = arrays.into_iter(); let first_array = arrays_iter.next().unwrap(); let length = first_array.array.len(); // The repdef should be identical across all children at this point let mut repdef = first_array.repdef; + data_size += first_array.data_size; children.push(first_array.array); for array in arrays_iter { debug_assert_eq!(length, array.array.len()); + data_size += array.data_size; children.push(array.array); } @@ -391,6 +395,7 @@ impl StructuralDecodeArrayTask for RepDefStructDecodeTask { Ok(DecodedArray { array: Arc::new(array), repdef, + data_size, }) } } diff --git a/rust/lance-encoding/src/previous/encodings/logical/binary.rs b/rust/lance-encoding/src/previous/encodings/logical/binary.rs index 29b04c68a25..00715f64511 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/binary.rs +++ b/rust/lance-encoding/src/previous/encodings/logical/binary.rs @@ -169,17 +169,18 @@ impl BinaryArrayDecoder { } impl DecodeArrayTask for BinaryArrayDecoder { - fn decode(self: Box) -> Result { + fn decode(self: Box) -> Result<(ArrayRef, u64)> { let data_type = self.data_type; - let arr = self.inner.decode()?; - match data_type { - DataType::Binary => Ok(Self::from_list_array::(arr.as_list::())), - DataType::LargeBinary => Ok(Self::from_list_array::( - arr.as_list::(), - )), - DataType::Utf8 => Ok(Self::from_list_array::(arr.as_list::())), - DataType::LargeUtf8 => Ok(Self::from_list_array::(arr.as_list::())), + let (arr, _) = self.inner.decode()?; + let result = match data_type { + DataType::Binary => Self::from_list_array::(arr.as_list::()), + DataType::LargeBinary => Self::from_list_array::(arr.as_list::()), + DataType::Utf8 => Self::from_list_array::(arr.as_list::()), + DataType::LargeUtf8 => Self::from_list_array::(arr.as_list::()), _ => panic!("Binary decoder does not support this data type"), - } + }; + // data_size is only tracked in the v2.1 structural decode path; the legacy + // v2.0 path does not need it so we return 0. + Ok((result, 0)) } } diff --git a/rust/lance-encoding/src/previous/encodings/logical/blob.rs b/rust/lance-encoding/src/previous/encodings/logical/blob.rs index ce4bdb5ae21..b54c7f89e5b 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/blob.rs +++ b/rust/lance-encoding/src/previous/encodings/logical/blob.rs @@ -77,7 +77,7 @@ impl SchedulingJob for BlobFieldSchedulingJob<'_> { .await .unwrap(); let descriptions_task = decoder.drain(decoder.num_rows()).unwrap(); - descriptions_task.task.decode() + descriptions_task.task.decode().map(|(arr, _)| arr) } .boxed(); let decoder = Box::new(BlobFieldDecoder { @@ -254,7 +254,7 @@ impl BlobArrayDecodeTask { } impl DecodeArrayTask for BlobArrayDecodeTask { - fn decode(self: Box) -> Result { + fn decode(self: Box) -> Result<(ArrayRef, u64)> { let num_bytes = self.bytes.iter().map(|b| b.len()).sum::(); let offsets = self .bytes @@ -272,11 +272,12 @@ impl DecodeArrayTask for BlobArrayDecodeTask { buffer.extend_from_slice(&bytes); } let data_buf = Buffer::from_vec(buffer); - Ok(Arc::new(LargeBinaryArray::new( - offsets, - data_buf, - self.validity, - ))) + // data_size is only tracked in the v2.1 structural decode path; the legacy + // v2.0 path does not need it so we return 0. + Ok(( + Arc::new(LargeBinaryArray::new(offsets, data_buf, self.validity)), + 0, + )) } } diff --git a/rust/lance-encoding/src/previous/encodings/logical/list.rs b/rust/lance-encoding/src/previous/encodings/logical/list.rs index 785a5e2632d..83f93cb22e6 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/list.rs +++ b/rust/lance-encoding/src/previous/encodings/logical/list.rs @@ -330,7 +330,7 @@ async fn indirect_schedule_task( // pages. We can use a dummy receiver to match the decoder API offsets_decoder.wait_for_loaded(num_offsets - 1).await?; let decode_task = offsets_decoder.drain(num_offsets)?; - let offsets = decode_task.task.decode()?; + let (offsets, _) = decode_task.task.decode()?; let (item_ranges, offsets, validity) = decode_offsets(offsets.as_ref(), &list_requests, null_offset_adjustment); @@ -615,13 +615,13 @@ struct ListDecodeTask { } impl DecodeArrayTask for ListDecodeTask { - fn decode(self: Box) -> Result { + fn decode(self: Box) -> Result<(ArrayRef, u64)> { let items = self .items .map(|items| { // When we run the indirect I/O we wrap things in a struct array with a single field // named "item". We can unwrap that now. - let wrapped_items = items.decode()?; + let (wrapped_items, _) = items.decode()?; Result::Ok(wrapped_items.as_struct().column(0).clone()) }) .unwrap_or_else(|| Ok(new_empty_array(self.items_field.data_type())))?; @@ -640,33 +640,36 @@ impl DecodeArrayTask for ListDecodeTask { }; let min_offset = UInt64Array::new_scalar(offsets.value(0)); let offsets = arrow_arith::numeric::sub(&offsets, &min_offset)?; - match &self.offset_type { + let array: ArrayRef = match &self.offset_type { DataType::Int32 => { let offsets = arrow_cast::cast(&offsets, &DataType::Int32)?; let offsets_i32 = offsets.as_primitive::(); let offsets = OffsetBuffer::new(offsets_i32.values().clone()); - Ok(Arc::new(ListArray::try_new( + Arc::new(ListArray::try_new( self.items_field.clone(), offsets, items, validity, - )?)) + )?) } DataType::Int64 => { let offsets = arrow_cast::cast(&offsets, &DataType::Int64)?; let offsets_i64 = offsets.as_primitive::(); let offsets = OffsetBuffer::new(offsets_i64.values().clone()); - Ok(Arc::new(LargeListArray::try_new( + Arc::new(LargeListArray::try_new( self.items_field.clone(), offsets, items, validity, - )?)) + )?) } _ => panic!("ListDecodeTask with data type that is not i32 or i64"), - } + }; + // data_size is only tracked in the v2.1 structural decode path; the legacy + // v2.0 path does not need it so we return 0. + Ok((array, 0)) } } diff --git a/rust/lance-encoding/src/previous/encodings/logical/primitive.rs b/rust/lance-encoding/src/previous/encodings/logical/primitive.rs index 794a45bf783..d1debf3ef33 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/primitive.rs +++ b/rust/lance-encoding/src/previous/encodings/logical/primitive.rs @@ -288,7 +288,7 @@ struct PrimitiveFieldDecodeTask { } impl DecodeArrayTask for PrimitiveFieldDecodeTask { - fn decode(self: Box) -> Result { + fn decode(self: Box) -> Result<(ArrayRef, u64)> { let block = self .physical_decoder .decode(self.rows_to_skip, self.rows_to_take)?; @@ -313,10 +313,12 @@ impl DecodeArrayTask for PrimitiveFieldDecodeTask { .data_type(dict.data_type().clone()) .build()?, ); - return Ok(new_array); + return Ok((new_array, 0)); } } - Ok(array) + // data_size is only tracked in the v2.1 structural decode path; the legacy + // v2.0 path does not need it so we return 0. + Ok((array, 0)) } } diff --git a/rust/lance-encoding/src/previous/encodings/logical/struct.rs b/rust/lance-encoding/src/previous/encodings/logical/struct.rs index f26bf118dd9..b117f74ce2b 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/struct.rs +++ b/rust/lance-encoding/src/previous/encodings/logical/struct.rs @@ -56,11 +56,13 @@ struct EmptyStructDecodeTask { } impl DecodeArrayTask for EmptyStructDecodeTask { - fn decode(self: Box) -> Result { - Ok(Arc::new(StructArray::new_empty_fields( - self.num_rows as usize, - None, - ))) + fn decode(self: Box) -> Result<(ArrayRef, u64)> { + // data_size is only tracked in the v2.1 structural decode path; the legacy + // v2.0 path does not need it so we return 0. + Ok(( + Arc::new(StructArray::new_empty_fields(self.num_rows as usize, None)), + 0, + )) } } @@ -580,7 +582,7 @@ impl CompositeDecodeTask { let arrays = self .tasks .into_iter() - .map(|task| task.decode()) + .map(|task| task.decode().map(|(arr, _)| arr)) .collect::>>()?; let array_refs = arrays.iter().map(|arr| arr.as_ref()).collect::>(); // TODO: If this is a primitive column we should be able to avoid this @@ -599,16 +601,17 @@ struct SimpleStructDecodeTask { } impl DecodeArrayTask for SimpleStructDecodeTask { - fn decode(self: Box) -> Result { + fn decode(self: Box) -> Result<(ArrayRef, u64)> { let child_arrays = self .children .into_iter() .map(|child| child.decode()) .collect::>>()?; - Ok(Arc::new(StructArray::try_new( - self.child_fields, - child_arrays, - None, - )?)) + // data_size is only tracked in the v2.1 structural decode path; the legacy + // v2.0 path does not need it so we return 0. + Ok(( + Arc::new(StructArray::try_new(self.child_fields, child_arrays, None)?), + 0, + )) } } diff --git a/rust/lance-encoding/src/previous/encodings/physical/binary.rs b/rust/lance-encoding/src/previous/encodings/physical/binary.rs index fe1e3a9bd3a..960f7583828 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/binary.rs +++ b/rust/lance-encoding/src/previous/encodings/physical/binary.rs @@ -109,7 +109,7 @@ impl BinaryPageScheduler { PrimitiveFieldDecoder::new_from_data(decoder, DataType::UInt64, num_rows, false); let drained_task = primitive_wrapper.drain(num_rows)?; let indices_decode_task = drained_task.task; - indices_decode_task.decode() + indices_decode_task.decode().map(|(arr, _)| arr) } } diff --git a/rust/lance-encoding/src/previous/encodings/physical/dictionary.rs b/rust/lance-encoding/src/previous/encodings/physical/dictionary.rs index 77ba0300237..a4c01ff9ef5 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/dictionary.rs +++ b/rust/lance-encoding/src/previous/encodings/physical/dictionary.rs @@ -101,7 +101,7 @@ impl PageScheduler for DictionaryPageScheduler { // Decode all items let drained_task = primitive_wrapper.drain(copy_size)?; let items_decode_task = drained_task.task; - let decoded_dict = items_decode_task.decode()?; + let (decoded_dict, _) = items_decode_task.decode()?; let indices_decoder: Box = indices_page_decoder.await?; From f810c7b96450b419d405402f84b8c096a49d5f58 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 3 Apr 2026 19:07:52 +0800 Subject: [PATCH 135/206] chore(python): sync uv lock (#6401) This updates `python/uv.lock` to match the current result of `uv sync --extra tests --extra dev` in the Python project. The lock file was lagging behind the resolved `lance-namespace` packages, which caused recurring diffs after local syncs. I validated the change by rerunning `uv sync --extra tests --extra dev` successfully in `python/`. --- python/uv.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 62cf84316d0..98153628542 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1049,19 +1049,19 @@ wheels = [ [[package]] name = "lance-namespace" -version = "0.5.2" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lance-namespace-urllib3-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/c6/aec0d7752e15536564b50cf9a8926f0e5d7780aa3ab8ce8bca46daa55659/lance_namespace-0.5.2.tar.gz", hash = "sha256:566cc33091b5631793ab411f095d46c66391db0a62343cd6b4470265bb04d577", size = 10274, upload-time = "2026-02-20T03:14:31.777Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/9f/7906ba4117df8d965510285eaf07264a77de2fd283b9d44ec7fc63a4a57a/lance_namespace-0.6.1.tar.gz", hash = "sha256:f0deea442bd3f1056a8e2fed056ae2778e3356517ec2e680db049058b824d131", size = 10666, upload-time = "2026-03-17T17:55:44.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/3d/737c008d8fb2861e7ce260e2ffab0d5058eae41556181f80f1a1c3b52ef5/lance_namespace-0.5.2-py3-none-any.whl", hash = "sha256:6ccaf5649bf6ee6aa92eed9c535a114b7b4eb08e89f40426f58bc1466cbcffa3", size = 12087, upload-time = "2026-02-20T03:14:35.261Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/aee1c0a04d17f2810173bd304bd444eb78332045df1b0c1b07cebd01f530/lance_namespace-0.6.1-py3-none-any.whl", hash = "sha256:9699c9e3f12236e5e08ea979cc4e036a8e3c67ed2f37ae6f25c5353ab908e1be", size = 12498, upload-time = "2026-03-17T17:55:44.062Z" }, ] [[package]] name = "lance-namespace-urllib3-client" -version = "0.5.2" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -1070,9 +1070,9 @@ dependencies = [ { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/64/51622c93ec8c164483c83b68764e5e76e52286c0137a8247bc6a7fac25f4/lance_namespace_urllib3_client-0.5.2.tar.gz", hash = "sha256:8a3a238006e6eabc01fc9d385ac3de22ba933aef0ae8987558f3c3199c9b3799", size = 172578, upload-time = "2026-02-20T03:14:33.031Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/a1/8706a2be25bd184acccc411e48f1a42a4cbf3b6556cba15b9fcf4c15cfcc/lance_namespace_urllib3_client-0.6.1.tar.gz", hash = "sha256:31fbd058ce1ea0bf49045cdeaa756360ece0bc61e9e10276f41af6d217debe87", size = 182567, upload-time = "2026-03-17T17:55:46.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/10/f86d994498b37f7f35d0b8c2f7626a16fe4cb1949b518c1e5d5052ecf95f/lance_namespace_urllib3_client-0.5.2-py3-none-any.whl", hash = "sha256:83cefb6fd6e5df0b99b5e866ee3d46300d375b75e8af32c27bc16fbf7c1a5978", size = 300351, upload-time = "2026-02-20T03:14:34.236Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/cb9580602dec25f0fdd6005c1c9ba1d4c8c0c3dc8d543107e5a9f248bba8/lance_namespace_urllib3_client-0.6.1-py3-none-any.whl", hash = "sha256:b9c103e1377ad46d2bd70eec894bfec0b1e2133dae0964d7e4de543c6e16293b", size = 317111, upload-time = "2026-03-17T17:55:45.546Z" }, ] [[package]] @@ -2594,7 +2594,7 @@ requires-dist = [ { name = "duckdb", marker = "extra == 'tests'" }, { name = "geoarrow-rust-core", marker = "extra == 'geo'" }, { name = "geoarrow-rust-io", marker = "extra == 'geo'" }, - { name = "lance-namespace", specifier = ">=0.5.2" }, + { name = "lance-namespace", specifier = ">=0.6.1" }, { name = "ml-dtypes", marker = "extra == 'tests'" }, { name = "numpy", specifier = ">=1.22" }, { name = "pandas", marker = "extra == 'tests'" }, From b98670b3a846bfe7116c5d771a66369579173471 Mon Sep 17 00:00:00 2001 From: hushengquan <45221305+hushengquan@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:38:26 +0800 Subject: [PATCH 136/206] fix: remove legacy tempfile save of IVF centroids in GPU training path (#6396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description Removes the leftover `tempfile.NamedTemporaryFile` save in `train_ivf_centroids_on_accelerator`. This was a debugging/checkpoint artifact that is no longer needed — the `IvfModel.save()` API now provides explicit persistence to any URI (local or cloud). The temp file was created with `delete=False` and never cleaned up, leaking disk space over repeated runs. The CPU training path (Rust `indices.train_ivf_model`) does not have this behavior, so this change also makes the two paths consistent. ### Changes - **`python/python/lance/vector.py`**: Removed the `tempfile.NamedTemporaryFile` + `np.save` + log line from `train_ivf_centroids_on_accelerator` (3 lines deleted). Closes #6395 --- python/python/lance/vector.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python/python/lance/vector.py b/python/python/lance/vector.py index 73f7981fe28..34a6154a321 100644 --- a/python/python/lance/vector.py +++ b/python/python/lance/vector.py @@ -270,10 +270,6 @@ def train_ivf_centroids_on_accelerator( kmeans.centroids.cpu().numpy().astype(vector_value_type.to_pandas_dtype()) ) - with tempfile.NamedTemporaryFile(delete=False) as f: - np.save(f, centroids) - LOGGER.info("Saved centroids to %s", f.name) - return centroids, kmeans From effca10f966a821771d825e365a5b6c6bfd69718 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Fri, 3 Apr 2026 22:02:14 +0800 Subject: [PATCH 137/206] feat: refine logical vector index into an IVF view (#6400) This PR introduces `LogicalVectorIndex` as a logical aggregate and moves IVF-specific partition inspection into `LogicalIvfView`, so the API boundary matches the actual semantics. --- rust/lance/src/index.rs | 332 ++++++++++++++++++--- rust/lance/src/index/append.rs | 456 +++++++++++++++++++---------- rust/lance/src/index/vector.rs | 153 +++++++++- rust/lance/src/index/vector/ivf.rs | 24 +- 4 files changed, 761 insertions(+), 204 deletions(-) diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index de318750fe3..c604e260b92 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -7,11 +7,10 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, OnceLock}; -use arrow_schema::{DataType, Schema}; +use arrow_schema::DataType; use async_trait::async_trait; use datafusion::execution::SendableRecordBatchStream; -use datafusion::physical_plan::stream::RecordBatchStreamAdapter; -use futures::{FutureExt, stream}; +use futures::FutureExt; use itertools::Itertools; use lance_core::cache::{CacheKey, UnsizedCacheKey}; use lance_core::datatypes::Field; @@ -85,6 +84,7 @@ use crate::index::frag_reuse::{load_frag_reuse_index_details, open_frag_reuse_in use crate::index::mem_wal::open_mem_wal_index; pub use crate::index::prefilter::{FilterLoader, PreFilter}; use crate::index::scalar::{IndexDetails, fetch_index_details, load_training_data}; +pub use crate::index::vector::{LogicalIvfView, LogicalVectorIndex}; use crate::session::index_caches::{FragReuseIndexKey, IndexMetadataKey}; use crate::{Error, Result, dataset::Dataset}; pub use create::CreateIndexBuilder; @@ -1076,34 +1076,13 @@ impl DatasetIndexExt for Dataset { return Err(Error::index_not_found(format!("name={}", index_name))); } let column = self.schema().field_by_id(indices[0].fields[0]).unwrap(); - - let mut schema: Option> = None; - let mut partition_streams = Vec::with_capacity(indices.len()); - for index in indices { - let index = self - .open_vector_index(&column.name, &index.uuid.to_string(), &NoOpMetricsCollector) - .await?; - - let stream = index - .partition_reader(partition_id, with_vector, &NoOpMetricsCollector) - .await?; - if schema.is_none() { - schema = Some(stream.schema()); - } - partition_streams.push(stream); - } - - match schema { - Some(schema) => { - let merged = stream::select_all(partition_streams); - let stream = RecordBatchStreamAdapter::new(schema, merged); - Ok(Box::pin(stream)) - } - None => Ok(Box::pin(RecordBatchStreamAdapter::new( - Arc::new(Schema::empty()), - stream::empty(), - ))), - } + let logical_index = self + .open_logical_vector_index(&column.name, index_name) + .await?; + logical_index + .as_ivf()? + .read_partition(partition_id, with_vector) + .await } } @@ -1363,6 +1342,12 @@ pub trait DatasetIndexInternalExt: DatasetIndexExt { uuid: &str, metrics: &dyn MetricsCollector, ) -> Result>; + /// Opens all segments for one logical vector index and returns a materialized snapshot. + async fn open_logical_vector_index( + &self, + column: &str, + name: &str, + ) -> Result; /// Opens the fragment reuse index async fn open_frag_reuse_index( @@ -1746,6 +1731,38 @@ impl DatasetIndexInternalExt for Dataset { Ok(index) } + async fn open_logical_vector_index( + &self, + column: &str, + name: &str, + ) -> Result { + let metadatas = self.load_indices_by_name(name).await?; + if metadatas.is_empty() { + return Err(Error::index_not_found(format!("name={name}"))); + } + + let field_id = self.schema().field_id(column)?; + if let Some(invalid_metadata) = metadatas + .iter() + .find(|metadata| !metadata.fields.contains(&field_id)) + { + return Err(Error::invalid_input(format!( + "Logical vector index '{}' contains segment {} that does not belong to column '{}'", + name, invalid_metadata.uuid, column + ))); + } + + let mut segments = Vec::with_capacity(metadatas.len()); + for metadata in metadatas { + let index = self + .open_vector_index(column, &metadata.uuid.to_string(), &NoOpMetricsCollector) + .await?; + segments.push((metadata, index)); + } + + LogicalVectorIndex::try_new(name.to_string(), column.to_string(), segments) + } + async fn open_frag_reuse_index( &self, metrics: &dyn MetricsCollector, @@ -2127,13 +2144,16 @@ mod tests { BuiltinIndexType, FullTextSearchQuery, InvertedIndexParams, ScalarIndexParams, }; use lance_index::vector::{ - hnsw::builder::HnswBuildParams, ivf::IvfBuildParams, sq::builder::SQBuildParams, + hnsw::builder::HnswBuildParams, + ivf::IvfBuildParams, + kmeans::{KMeansParams, train_kmeans}, + sq::builder::SQBuildParams, }; use lance_io::{assert_io_eq, assert_io_lt}; use lance_linalg::distance::{DistanceType, MetricType}; use lance_testing::datagen::generate_random_array; use rstest::rstest; - use std::collections::HashSet; + use std::collections::{HashMap, HashSet}; async fn write_vector_segment_metadata( dataset: &Dataset, @@ -2169,6 +2189,252 @@ mod tests { } } + async fn write_fragmented_vector_dataset(uri: &str, dimension: i32) -> Dataset { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + dimension, + ), + false, + ), + ])); + let batches = (0..5) + .map(|i| { + let vector_values: Float32Array = (0..dimension * 80) + .map(|value| value as f32 + (i * 1000) as f32) + .collect(); + let vectors = + FixedSizeListArray::try_new_from_values(vector_values, dimension).unwrap(); + RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(i * 80..(i + 1) * 80)), + Arc::new(vectors), + ], + ) + }) + .collect::, arrow_schema::ArrowError>>() + .unwrap(); + let reader = RecordBatchIterator::new(batches.into_iter().map(Ok), schema); + Dataset::write( + reader, + uri, + Some(WriteParams { + max_rows_per_group: 10, + max_rows_per_file: 80, + ..Default::default() + }), + ) + .await + .unwrap() + } + + async fn create_segmented_vector_index( + dataset: &mut Dataset, + index_name: &str, + column: &str, + dimension: i32, + ) -> Vec { + let batch = dataset + .scan() + .project(&[column]) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let vectors = batch + .column_by_name(column) + .expect("vector column should exist") + .as_fixed_size_list(); + let values = vectors.values().as_primitive::(); + let centroids = train_kmeans::( + values, + KMeansParams::new(None, 10, 1, DistanceType::L2), + dimension as usize, + 2, + 2, + ) + .unwrap() + .centroids + .as_primitive::() + .clone(); + let centroids = + Arc::new(FixedSizeListArray::try_new_from_values(centroids, dimension).unwrap()); + let params = VectorIndexParams::with_ivf_flat_params( + DistanceType::L2, + IvfBuildParams::try_with_centroids(2, centroids).unwrap(), + ); + let fragment_ids = dataset + .get_fragments() + .iter() + .map(|fragment| fragment.id() as u32) + .collect::>(); + let columns = [column]; + + let mut segments = Vec::with_capacity(fragment_ids.len()); + for fragment_id in fragment_ids { + let mut builder = dataset.create_index_builder(&columns, IndexType::Vector, ¶ms); + builder = builder + .name(index_name.to_string()) + .fragments(vec![fragment_id]); + segments.push(builder.execute_uncommitted().await.unwrap()); + } + + let segment_ids = segments + .iter() + .map(|segment| segment.uuid) + .collect::>(); + dataset + .commit_existing_index_segments(index_name, column, segments) + .await + .unwrap(); + segment_ids + } + + #[tokio::test] + async fn test_open_logical_vector_index_single_segment_quality_apis() { + const DIMENSION: i32 = 8; + + let test_dir = tempfile::tempdir().unwrap(); + let test_uri = test_dir.path().to_str().unwrap(); + let mut dataset = write_fragmented_vector_dataset(test_uri, DIMENSION).await; + let params = + VectorIndexParams::with_ivf_flat_params(DistanceType::L2, IvfBuildParams::new(2)); + + dataset + .create_index( + &["vector"], + IndexType::Vector, + Some("vector_idx".to_string()), + ¶ms, + true, + ) + .await + .unwrap(); + + let logical_index = dataset + .open_logical_vector_index("vector", "vector_idx") + .await + .unwrap(); + + assert_eq!(logical_index.name(), "vector_idx"); + assert_eq!(logical_index.column(), "vector"); + assert_eq!(logical_index.num_segments(), 1); + assert_eq!(logical_index.metadatas().len(), 1); + + let rows_per_segment = logical_index.num_rows_per_segment(); + assert_eq!(rows_per_segment.len(), 1); + assert_eq!(rows_per_segment[0].1, 400); + + let ivf_view = logical_index.as_ivf().unwrap(); + let partitions_per_segment = ivf_view.num_partitions_per_segment(); + assert_eq!(partitions_per_segment, vec![(rows_per_segment[0].0, 2)]); + + let partition_sizes = ivf_view.partition_sizes(); + assert_eq!(partition_sizes.len(), 1); + assert_eq!(partition_sizes[0].1.len(), 2); + assert_eq!( + partition_sizes[0].1.iter().sum::(), + rows_per_segment[0].1 as usize + ); + } + + #[tokio::test] + async fn test_open_logical_vector_index_segmented_quality_apis() { + const DIMENSION: i32 = 8; + + let test_dir = tempfile::tempdir().unwrap(); + let test_uri = test_dir.path().to_str().unwrap(); + let mut dataset = write_fragmented_vector_dataset(test_uri, DIMENSION).await; + let segment_ids = + create_segmented_vector_index(&mut dataset, "vector_idx", "vector", DIMENSION).await; + + let logical_index = dataset + .open_logical_vector_index("vector", "vector_idx") + .await + .unwrap(); + + assert_eq!(logical_index.name(), "vector_idx"); + assert_eq!(logical_index.column(), "vector"); + assert_eq!(logical_index.num_segments(), segment_ids.len()); + + let metadata_ids = logical_index + .metadatas() + .map(|metadata| metadata.uuid) + .collect::>(); + assert_eq!( + metadata_ids, + segment_ids.into_iter().collect::>() + ); + + let rows_per_segment = logical_index.num_rows_per_segment(); + assert_eq!(rows_per_segment.len(), logical_index.num_segments()); + assert_eq!( + rows_per_segment + .iter() + .map(|(_, num_rows)| *num_rows) + .sum::(), + 400 + ); + assert!( + rows_per_segment.iter().all(|(_, num_rows)| *num_rows > 0), + "each segment should contain indexed rows" + ); + + let ivf_view = logical_index.as_ivf().unwrap(); + let partitions_per_segment = ivf_view.num_partitions_per_segment(); + assert!( + partitions_per_segment + .iter() + .all(|(_, num_partitions)| *num_partitions == 2) + ); + + let row_count_by_segment = rows_per_segment.into_iter().collect::>(); + let partition_sizes = ivf_view.partition_sizes(); + assert_eq!(partition_sizes.len(), logical_index.num_segments()); + for (segment_id, sizes) in partition_sizes { + assert_eq!(sizes.len(), 2); + assert_eq!( + sizes.iter().sum::(), + row_count_by_segment[&segment_id] as usize + ); + } + } + + #[tokio::test] + async fn test_open_logical_vector_index_rejects_wrong_column() { + const DIMENSION: i32 = 8; + + let test_dir = tempfile::tempdir().unwrap(); + let test_uri = test_dir.path().to_str().unwrap(); + let mut dataset = write_fragmented_vector_dataset(test_uri, DIMENSION).await; + let params = + VectorIndexParams::with_ivf_flat_params(DistanceType::L2, IvfBuildParams::new(2)); + + dataset + .create_index( + &["vector"], + IndexType::Vector, + Some("vector_idx".to_string()), + ¶ms, + true, + ) + .await + .unwrap(); + + let err = dataset + .open_logical_vector_index("id", "vector_idx") + .await + .unwrap_err(); + assert!( + err.to_string().contains("does not belong to column 'id'"), + "unexpected error: {err}" + ); + } + #[tokio::test] async fn test_recreate_index() { const DIM: i32 = 8; diff --git a/rust/lance/src/index/append.rs b/rust/lance/src/index/append.rs index a203e555c04..b088be625f9 100644 --- a/rust/lance/src/index/append.rs +++ b/rust/lance/src/index/append.rs @@ -9,6 +9,7 @@ use lance_core::{ utils::mask::{RowAddrTreeMap, RowSetOps}, }; use lance_index::{ + INDEX_FILE_NAME, metrics::NoOpMetricsCollector, optimize::OptimizeOptions, progress::NoopIndexBuildProgress, @@ -19,6 +20,7 @@ use roaring::RoaringBitmap; use uuid::Uuid; use super::DatasetIndexInternalExt; +use super::vector::LogicalVectorIndex; use super::vector::ivf::optimize_vector_indices; use crate::dataset::Dataset; use crate::dataset::index::LanceIndexStoreExt; @@ -72,6 +74,18 @@ async fn build_stable_row_id_filter( Ok(::union_all(&row_id_map_refs)) } +async fn metadata_is_vector_index(dataset: &Dataset, index: &IndexMetadata) -> Result { + if let Some(files) = &index.files { + return Ok(files.iter().any(|file| file.path == INDEX_FILE_NAME)); + } + + let index_dir = dataset.indice_files_dir(index)?; + let index_file = index_dir + .child(index.uuid.to_string()) + .child(INDEX_FILE_NAME); + dataset.object_store.exists(&index_file).await +} + /// Merge in-inflight unindexed data, with a specific number of previous indices /// into a new index, to improve the query performance. /// @@ -120,183 +134,220 @@ pub async fn merge_indices_with_unindexed_frags<'a>( )))?; let field_path = dataset.schema().field_path(old_indices[0].fields[0])?; - let mut indices = Vec::with_capacity(old_indices.len()); - for idx in old_indices { - match dataset - .open_generic_index(&field_path, &idx.uuid.to_string(), &NoOpMetricsCollector) - .await - { - Ok(index) => indices.push(index), - Err(e) => { - log::warn!( - "Cannot open index on column '{}': {}. \ - Skipping index merge for this column.", - field_path, - e - ); - return Ok(None); - } + let first_is_vector_index = metadata_is_vector_index(dataset.as_ref(), old_indices[0]).await?; + for idx in old_indices.iter().skip(1) { + let is_vector_index = metadata_is_vector_index(dataset.as_ref(), idx).await?; + if is_vector_index != first_is_vector_index { + return Err(Error::index(format!( + "Append index: invalid mixed index deltas: {:?}", + old_indices + ))); } } - if indices - .windows(2) - .any(|w| w[0].index_type() != w[1].index_type()) - { - return Err(Error::index(format!( - "Append index: invalid index deltas: {:?}", - old_indices - ))); - } - let mut frag_bitmap = RoaringBitmap::new(); unindexed.iter().for_each(|frag| { frag_bitmap.insert(frag.id as u32); }); - let index_type = indices[0].index_type(); - let (new_uuid, indices_merged, created_index) = match index_type { - it if it.is_scalar() => { - // Use effective bitmap (intersected with existing dataset fragments) - // to avoid carrying stale data from pruned indices. - let effective_old_frags: RoaringBitmap = old_indices - .iter() - .filter_map(|idx| idx.effective_fragment_bitmap(&dataset.fragment_bitmap)) - .fold(RoaringBitmap::new(), |mut acc, b| { - acc |= &b; - acc - }); - let deleted_old_frags: RoaringBitmap = old_indices - .iter() - .filter_map(|idx| idx.deleted_fragment_bitmap(&dataset.fragment_bitmap)) - .fold(RoaringBitmap::new(), |mut acc, b| { - acc |= &b; - acc - }); - frag_bitmap |= &effective_old_frags; + let (new_uuid, indices_merged, created_index) = if first_is_vector_index { + let full_logical_index = dataset + .open_logical_vector_index(&field_path, &old_indices[0].name) + .await?; + let mut opened_indices_by_uuid = full_logical_index + .iter() + .map(|(metadata, index)| (metadata.uuid, (metadata.clone(), index.clone()))) + .collect::>(); + let mut selected_metadatas = Vec::with_capacity(old_indices.len()); + let mut selected_indices = Vec::with_capacity(old_indices.len()); + for metadata in old_indices { + let (selected_metadata, selected_index) = opened_indices_by_uuid.remove(&metadata.uuid).ok_or_else(|| { + Error::index(format!( + "Append index: logical vector index '{}' does not contain requested segment {}", + old_indices[0].name, metadata.uuid + )) + })?; + selected_metadatas.push(selected_metadata); + selected_indices.push(selected_index); + } + let logical_index = LogicalVectorIndex::try_new( + old_indices[0].name.clone(), + field_path.clone(), + selected_metadatas + .into_iter() + .zip(selected_indices) + .collect(), + )?; + let ivf_view = logical_index.as_ivf()?; + + let new_data_stream = if unindexed.is_empty() { + None + } else { + let mut scanner = dataset.scan(); + scanner + .with_fragments(unindexed.to_vec()) + .with_row_id() + .project(&[&field_path])?; + if column.nullable { + let column_expr = lance_datafusion::logical_expr::field_path_to_expr(&field_path)?; + scanner.filter_expr(column_expr.is_not_null()); + } + Some(scanner.try_into_stream().await?) + }; - let index = dataset - .open_scalar_index( - &field_path, - &old_indices[0].uuid.to_string(), - &NoOpMetricsCollector, - ) - .await?; + let (new_uuid, indices_merged) = optimize_vector_indices( + dataset.as_ref().clone(), + new_data_stream, + &field_path, + &ivf_view, + options, + ) + .boxed() + .await?; - let update_criteria = index.update_criteria(); - - let fragments = if update_criteria.requires_old_data { - None - } else { - Some(unindexed.to_vec()) - }; - let new_data_stream = load_training_data( - dataset.as_ref(), - &field_path, - &update_criteria.data_criteria, - fragments, - true, - None, - ) - .await?; + old_indices[old_indices.len() - indices_merged..] + .iter() + .for_each(|idx| { + frag_bitmap.extend(idx.fragment_bitmap.as_ref().unwrap().iter()); + }); + + let index_dir = dataset.indices_dir().child(new_uuid.to_string()); + let files = list_index_files_with_sizes(&dataset.object_store, &index_dir).await?; + + Ok(( + new_uuid, + indices_merged, + CreatedIndex { + index_details: vector_index_details(), + index_version: lance_index::IndexType::Vector.version() as u32, + files: Some(files), + }, + )) + } else { + let mut indices = Vec::with_capacity(old_indices.len()); + for idx in old_indices { + match dataset + .open_generic_index(&field_path, &idx.uuid.to_string(), &NoOpMetricsCollector) + .await + { + Ok(index) => indices.push(index), + Err(e) => { + log::warn!( + "Cannot open index on column '{}': {}. \ + Skipping index merge for this column.", + field_path, + e + ); + return Ok(None); + } + } + } - let new_uuid = Uuid::new_v4(); + if indices + .windows(2) + .any(|w| w[0].index_type() != w[1].index_type()) + { + return Err(Error::index(format!( + "Append index: invalid index deltas: {:?}", + old_indices + ))); + } - let created_index = if effective_old_frags.is_empty() { - // Old data is fully stale (bitmap pruned to empty). Rebuild - // from scratch instead of merging stale entries. - let params = index.derive_index_params()?; - super::scalar::build_scalar_index( + let index_type = indices[0].index_type(); + match index_type { + it if it.is_scalar() => { + // Use effective bitmap (intersected with existing dataset fragments) + // to avoid carrying stale data from pruned indices. + let effective_old_frags: RoaringBitmap = old_indices + .iter() + .filter_map(|idx| idx.effective_fragment_bitmap(&dataset.fragment_bitmap)) + .fold(RoaringBitmap::new(), |mut acc, b| { + acc |= &b; + acc + }); + let deleted_old_frags: RoaringBitmap = old_indices + .iter() + .filter_map(|idx| idx.deleted_fragment_bitmap(&dataset.fragment_bitmap)) + .fold(RoaringBitmap::new(), |mut acc, b| { + acc |= &b; + acc + }); + frag_bitmap |= &effective_old_frags; + + let index = dataset + .open_scalar_index( + &field_path, + &old_indices[0].uuid.to_string(), + &NoOpMetricsCollector, + ) + .await?; + + let update_criteria = index.update_criteria(); + + let fragments = if update_criteria.requires_old_data { + None + } else { + Some(unindexed.to_vec()) + }; + let new_data_stream = load_training_data( dataset.as_ref(), - column.name.as_str(), - &new_uuid.to_string(), - ¶ms, + &field_path, + &update_criteria.data_criteria, + fragments, true, None, - Some(new_data_stream), - Arc::new(NoopIndexBuildProgress), ) - .await? - } else { - let new_store = - LanceIndexStore::from_dataset_for_new(&dataset, &new_uuid.to_string())?; - let old_data_filter = if dataset.manifest.uses_stable_row_ids() { - // Stable row IDs are opaque IDs, so fragment-bit filtering on - // (row_id >> 32) is invalid. Build an exact allow-list from retained - // fragments' row-id sequences and use precise filtering. - let valid_old_row_ids = - build_stable_row_id_filter(dataset.as_ref(), &effective_old_frags).await?; - Some(OldIndexDataFilter::RowIds(valid_old_row_ids)) + .await?; + + let new_uuid = Uuid::new_v4(); + + let created_index = if effective_old_frags.is_empty() { + // Old data is fully stale (bitmap pruned to empty). Rebuild + // from scratch instead of merging stale entries. + let params = index.derive_index_params()?; + super::scalar::build_scalar_index( + dataset.as_ref(), + column.name.as_str(), + &new_uuid.to_string(), + ¶ms, + true, + None, + Some(new_data_stream), + Arc::new(NoopIndexBuildProgress), + ) + .await? } else { - // Address-style row IDs encode fragment_id in high 32 bits. - // Fragment bitmap filtering is valid and cheaper in this mode. - Some(OldIndexDataFilter::Fragments { - to_keep: effective_old_frags, - to_remove: deleted_old_frags, - }) + let new_store = + LanceIndexStore::from_dataset_for_new(&dataset, &new_uuid.to_string())?; + let old_data_filter = if dataset.manifest.uses_stable_row_ids() { + // Stable row IDs are opaque IDs, so fragment-bit filtering on + // (row_id >> 32) is invalid. Build an exact allow-list from retained + // fragments' row-id sequences and use precise filtering. + let valid_old_row_ids = + build_stable_row_id_filter(dataset.as_ref(), &effective_old_frags) + .await?; + Some(OldIndexDataFilter::RowIds(valid_old_row_ids)) + } else { + // Address-style row IDs encode fragment_id in high 32 bits. + // Fragment bitmap filtering is valid and cheaper in this mode. + Some(OldIndexDataFilter::Fragments { + to_keep: effective_old_frags, + to_remove: deleted_old_frags, + }) + }; + index + .update(new_data_stream, &new_store, old_data_filter) + .await? }; - index - .update(new_data_stream, &new_store, old_data_filter) - .await? - }; - - // TODO: don't hard-code index version - Ok((new_uuid, 1, created_index)) - } - it if it.is_vector() => { - let new_data_stream = if unindexed.is_empty() { - None - } else { - let mut scanner = dataset.scan(); - scanner - .with_fragments(unindexed.to_vec()) - .with_row_id() - .project(&[&field_path])?; - if column.nullable { - let column_expr = - lance_datafusion::logical_expr::field_path_to_expr(&field_path)?; - scanner.filter_expr(column_expr.is_not_null()); - } - Some(scanner.try_into_stream().await?) - }; - - let (new_uuid, indices_merged) = optimize_vector_indices( - dataset.as_ref().clone(), - new_data_stream, - &field_path, - &indices, - options, - ) - .boxed() - .await?; - old_indices[old_indices.len() - indices_merged..] - .iter() - .for_each(|idx| { - frag_bitmap.extend(idx.fragment_bitmap.as_ref().unwrap().iter()); - }); - - // Capture file sizes for the new vector index - let index_dir = dataset.indices_dir().child(new_uuid.to_string()); - let files = list_index_files_with_sizes(&dataset.object_store, &index_dir).await?; - - Ok(( - new_uuid, - indices_merged, - CreatedIndex { - index_details: vector_index_details(), - // retain_supported_indices guarantees all old_indices have - // index_version <= our max supported version, so we can safely - // write the current library's version for this index type. - index_version: it.version() as u32, - files: Some(files), - }, - )) + // TODO: don't hard-code index version + Ok((new_uuid, 1, created_index)) + } + _ => Err(Error::index(format!( + "Append index: invalid index type: {:?}", + indices[0].index_type() + ))), } - _ => Err(Error::index(format!( - "Append index: invalid index type: {:?}", - indices[0].index_type() - ))), }?; let removed_indices = old_indices[old_indices.len() - indices_merged..].to_vec(); @@ -321,6 +372,7 @@ mod tests { use super::*; use crate::index::DatasetIndexExt; + use crate::index::DatasetIndexInternalExt; use arrow::datatypes::{Float32Type, UInt32Type}; use arrow_array::cast::AsArray; use arrow_array::{ @@ -554,6 +606,19 @@ mod tests { assert_eq!(stats["num_indices"], 2); assert_eq!(stats["num_indexed_fragments"], 2); assert_eq!(stats["num_unindexed_fragments"], 0); + let logical_index = dataset + .open_logical_vector_index("vector", "vector_idx") + .await + .unwrap(); + assert_eq!(logical_index.num_segments(), 2); + assert_eq!( + logical_index + .num_rows_per_segment() + .into_iter() + .map(|(_, num_rows)| num_rows) + .sum::(), + 2000 + ); let results = dataset .scan() @@ -715,6 +780,83 @@ mod tests { assert_eq!(results[0].num_rows(), 10); } + #[tokio::test] + async fn test_merge_indices_with_unindexed_frags_vector_subset() { + const DIM: usize = 64; + const TOTAL: usize = 1000; + + let test_dir = TempStrDir::default(); + let test_uri = test_dir.as_str(); + + let vectors = generate_random_array(TOTAL * DIM); + let schema = Arc::new(Schema::new(vec![ + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + DIM as i32, + ), + true, + ), + Field::new("id", DataType::UInt32, false), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(FixedSizeListArray::try_new_from_values(vectors, DIM as i32).unwrap()), + Arc::new(UInt32Array::from_iter_values(0..TOTAL as u32)), + ], + ) + .unwrap(); + let batches = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone()); + let mut dataset = Dataset::write(batches, test_uri, None).await.unwrap(); + let index_params = VectorIndexParams::ivf_pq(2, 8, 4, MetricType::L2, 2); + dataset + .create_index(&["vector"], IndexType::Vector, None, &index_params, true) + .await + .unwrap(); + + let next_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new( + FixedSizeListArray::try_new_from_values( + generate_random_array(TOTAL * DIM), + DIM as i32, + ) + .unwrap(), + ), + Arc::new(UInt32Array::from_iter_values( + TOTAL as u32..(TOTAL * 2) as u32, + )), + ], + ) + .unwrap(); + let batches = RecordBatchIterator::new(vec![next_batch].into_iter().map(Ok), schema); + dataset.append(batches, None).await.unwrap(); + dataset + .optimize_indices(&OptimizeOptions::append()) + .await + .unwrap(); + + let indices = dataset.load_indices_by_name("vector_idx").await.unwrap(); + assert_eq!(indices.len(), 2); + let subset = vec![&indices[1]]; + let merge_result = merge_indices_with_unindexed_frags( + Arc::new(dataset), + &subset, + &[], + &OptimizeOptions::merge(1), + ) + .await + .unwrap(); + + assert!( + merge_result.is_some(), + "subset merges should respect the caller-provided indices" + ); + } + #[tokio::test] async fn test_optimize_btree_keeps_rows_with_stable_row_ids_after_compaction() { async fn query_id_count(dataset: &Dataset, id: &str) -> usize { diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index f681095a5cc..2d3594524cc 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -16,8 +16,11 @@ pub mod utils; mod fixture_test; use self::{ivf::*, pq::PQIndex}; -use arrow_schema::DataType; +use arrow_schema::{DataType, Schema}; use builder::IvfIndexBuilder; +use datafusion::physical_plan::SendableRecordBatchStream; +use datafusion::physical_plan::stream::RecordBatchStreamAdapter; +use futures::stream; use lance_core::utils::tempfile::TempStdDir; use lance_file::previous::reader::FileReader as PreviousFileReader; use lance_index::frag_reuse::FragReuseIndex; @@ -63,6 +66,154 @@ use crate::{Error, Result, dataset::Dataset, index::pb::vector_index_stage::Stag pub const LANCE_VECTOR_INDEX: &str = "__lance_vector_index"; +/// A materialized snapshot of one logical vector index and all of its segments. +#[derive(Debug)] +pub struct LogicalVectorIndex { + name: String, + column: String, + segments: Vec<(IndexMetadata, Arc)>, +} + +/// An IVF-specific inspection and maintenance view over a [`LogicalVectorIndex`]. +/// +/// Callers must explicitly opt into this view before accessing partition-level +/// APIs so that IVF semantics stay separated from the generic logical vector +/// index abstraction. +#[derive(Clone, Copy, Debug)] +pub struct LogicalIvfView<'a> { + logical_index: &'a LogicalVectorIndex, +} + +impl LogicalVectorIndex { + pub(crate) fn try_new( + name: String, + column: String, + segments: Vec<(IndexMetadata, Arc)>, + ) -> Result { + if segments.is_empty() { + return Err(Error::invalid_input(format!( + "LogicalVectorIndex '{}' on column '{}' must contain at least one segment", + name, column + ))); + } + + Ok(Self { + name, + column, + segments, + }) + } + + /// Returns the logical index name. + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the vector column that this logical index is built on. + pub fn column(&self) -> &str { + &self.column + } + + /// Returns the number of physical segments in this logical index. + pub fn num_segments(&self) -> usize { + self.segments.len() + } + + /// Returns the committed metadata for all physical segments. + pub fn metadatas(&self) -> impl ExactSizeIterator + '_ { + self.segments.iter().map(|(metadata, _)| metadata) + } + + /// Returns the indexed row count for each segment. + pub fn num_rows_per_segment(&self) -> Vec<(Uuid, u64)> { + self.segments + .iter() + .map(|(metadata, index)| (metadata.uuid, index.num_rows())) + .collect() + } + + /// Returns an IVF view over this logical index. + /// + /// This is currently fallible only to leave room for future non-IVF vector + /// index families. All vector indices currently opened through this path are + /// IVF-backed. + pub fn as_ivf(&self) -> Result> { + Ok(LogicalIvfView { + logical_index: self, + }) + } + + pub(crate) fn iter( + &self, + ) -> impl ExactSizeIterator)> + '_ { + self.segments + .iter() + .map(|(metadata, index)| (metadata, index)) + } +} + +impl<'a> LogicalIvfView<'a> { + pub(crate) fn indices(&self) -> impl ExactSizeIterator> + '_ { + self.logical_index.iter().map(|(_, index)| index) + } + + /// Returns the partition count for each segment in this IVF index. + pub fn num_partitions_per_segment(&self) -> Vec<(Uuid, usize)> { + self.logical_index + .iter() + .map(|(metadata, index)| (metadata.uuid, index.ivf_model().num_partitions())) + .collect() + } + + /// Returns the partition sizes for each segment in this IVF index. + pub fn partition_sizes(&self) -> Vec<(Uuid, Vec)> { + let mut partition_sizes = Vec::with_capacity(self.logical_index.num_segments()); + for (metadata, index) in self.logical_index.iter() { + let num_partitions = index.ivf_model().num_partitions(); + let mut sizes = Vec::with_capacity(num_partitions); + for partition_id in 0..num_partitions { + sizes.push(index.partition_size(partition_id)); + } + partition_sizes.push((metadata.uuid, sizes)); + } + partition_sizes + } + + /// Reads one IVF partition across all segments in this logical index. + /// + /// The returned stream preserves segment boundaries only implicitly through + /// concatenation order; callers should treat it as a merged partition view. + pub async fn read_partition( + &self, + partition_id: usize, + with_vector: bool, + ) -> Result { + let mut schema: Option> = None; + let mut partition_streams = Vec::with_capacity(self.logical_index.num_segments()); + for index in self.indices() { + let stream = index + .partition_reader(partition_id, with_vector, &NoOpMetricsCollector) + .await?; + if schema.is_none() { + schema = Some(stream.schema()); + } + partition_streams.push(stream); + } + + match schema { + Some(schema) => { + let merged = stream::select_all(partition_streams); + let stream = RecordBatchStreamAdapter::new(schema, merged); + Ok(Box::pin(stream)) + } + None => Ok(Box::pin(RecordBatchStreamAdapter::new( + Arc::new(Schema::empty()), + stream::empty(), + ))), + } + } +} + /// Parameters of each index stage. #[derive(Debug, Clone)] pub enum StageParams { diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 4d75837e212..1a4f375db2c 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -3,11 +3,12 @@ //! IVF - Inverted File index. -use super::{builder::IvfIndexBuilder, utils::PartitionLoadLock}; use super::{ + LogicalIvfView, pq::{PQIndex, build_pq_model}, utils::{filter_finite_training_data, maybe_sample_training_data}, }; +use super::{builder::IvfIndexBuilder, utils::PartitionLoadLock}; use crate::dataset::index::dataset_format_version; use crate::index::DatasetIndexInternalExt; use crate::index::vector::utils::{get_vector_dim, get_vector_type}; @@ -276,9 +277,10 @@ pub(crate) async fn optimize_vector_indices( dataset: Dataset, unindexed: Option, vector_column: &str, - existing_indices: &[Arc], + logical_index: &LogicalIvfView<'_>, options: &OptimizeOptions, ) -> Result<(Uuid, usize)> { + let existing_indices = logical_index.indices().cloned().collect::>(); // Sanity check the indices if existing_indices.is_empty() { return Err(Error::index( @@ -293,7 +295,7 @@ pub(crate) async fn optimize_vector_indices( &dataset, unindexed, vector_column, - existing_indices, + &existing_indices, options, ) .await; @@ -320,7 +322,7 @@ pub(crate) async fn optimize_vector_indices( pq_index, vector_column, unindexed, - existing_indices, + &existing_indices, options, writer, dataset.version().version, @@ -342,7 +344,7 @@ pub(crate) async fn optimize_vector_indices( hnsw_sq, vector_column, unindexed, - existing_indices, + &existing_indices, options, writer, aux_writer, @@ -363,7 +365,7 @@ pub(crate) async fn optimize_vector_indices_v2( dataset: &Dataset, unindexed: Option, vector_column: &str, - existing_indices: &[Arc], + existing_indices: &[Arc], options: &OptimizeOptions, ) -> Result<(Uuid, usize)> { // Sanity check the indices @@ -372,11 +374,7 @@ pub(crate) async fn optimize_vector_indices_v2( "optimizing vector index: no existing index found".to_string(), )); } - let existing_indices = existing_indices - .iter() - .cloned() - .map(|idx| idx.as_vector_index()) - .collect::>>()?; + let existing_indices = existing_indices.to_vec(); let new_uuid = Uuid::new_v4(); let index_dir = dataset.indices_dir().child(new_uuid.to_string()); @@ -592,7 +590,7 @@ async fn optimize_ivf_pq_indices( pq_index: &PQIndex, vector_column: &str, unindexed: Option, - existing_indices: &[Arc], + existing_indices: &[Arc], options: &OptimizeOptions, mut writer: Box, dataset_version: u64, @@ -675,7 +673,7 @@ async fn optimize_ivf_hnsw_indices( hnsw_index: &HNSWIndex, vector_column: &str, unindexed: Option, - existing_indices: &[Arc], + existing_indices: &[Arc], options: &OptimizeOptions, writer: Box, aux_writer: Box, From 408a9510353d52cca126d1c6b51e5a85173157dc Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Fri, 3 Apr 2026 11:35:51 -0500 Subject: [PATCH 138/206] refactor: rename "region" to "shard" in mem_wal implementation (#6367) Avoid confusion with object store regions (e.g., AWS regions) which are unrelated to the MemWAL concept of a unique writer/reader instance. Closes https://github.com/lance-format/lance/issues/6355 Co-authored-by: Claude Opus 4.6 (1M context) --- docs/src/format/table/index/system/mem_wal.md | 2 +- docs/src/format/table/mem_wal.md | 250 +++++++++--------- protos/table.proto | 94 +++---- protos/transaction.proto | 4 +- rust/lance-index/src/mem_wal.rs | 136 +++++----- rust/lance/benches/mem_wal_read.rs | 126 +++++---- rust/lance/benches/mem_wal_write.rs | 26 +- rust/lance/src/dataset/mem_wal.rs | 14 +- rust/lance/src/dataset/mem_wal/api.rs | 44 +-- rust/lance/src/dataset/mem_wal/manifest.rs | 160 +++++------ .../src/dataset/mem_wal/memtable/flush.rs | 98 +++---- rust/lance/src/dataset/mem_wal/scanner.rs | 4 +- .../src/dataset/mem_wal/scanner/builder.rs | 38 +-- .../src/dataset/mem_wal/scanner/collector.rs | 100 +++---- .../dataset/mem_wal/scanner/data_source.rs | 52 ++-- .../src/dataset/mem_wal/scanner/planner.rs | 121 +++++---- .../dataset/mem_wal/scanner/point_lookup.rs | 12 +- rust/lance/src/dataset/mem_wal/util.rs | 46 ++-- rust/lance/src/dataset/mem_wal/wal.rs | 34 +-- rust/lance/src/dataset/mem_wal/write.rs | 218 +++++++-------- rust/lance/src/index/mem_wal.rs | 88 +++--- rust/lance/src/io/commit/conflict_resolver.rs | 58 ++-- 22 files changed, 861 insertions(+), 864 deletions(-) diff --git a/docs/src/format/table/index/system/mem_wal.md b/docs/src/format/table/index/system/mem_wal.md index f9169bcfb76..a93debfe55c 100644 --- a/docs/src/format/table/index/system/mem_wal.md +++ b/docs/src/format/table/index/system/mem_wal.md @@ -1,7 +1,7 @@ # MemWAL Index The MemWAL Index is a system index that serves as the centralized structure for all MemWAL metadata. -It stores configuration (region specs, indexes to maintain), merge progress, and region state snapshots. +It stores configuration (shard specs, indexes to maintain), merge progress, and shard state snapshots. A table has at most one MemWAL index. diff --git a/docs/src/format/table/mem_wal.md b/docs/src/format/table/mem_wal.md index 5e6907038fb..6210ecd7da4 100644 --- a/docs/src/format/table/mem_wal.md +++ b/docs/src/format/table/mem_wal.md @@ -10,63 +10,63 @@ scan, point lookup, vector search and full-text search. A Lance table is called a **base table** under the context of the MemWAL spec. It must have an [unenforced primary key](index.md#unenforced-primary-key) defined in the table schema. -On top of the base table, the MemWAL spec defines a set of regions. -Writers write to regions, and data in each region is merged into the base table asynchronously. -An index is kept in the base table for readers to quickly discover the state of all regions at a point of time. +On top of the base table, the MemWAL spec defines a set of shards. +Writers write to shards, and data in each shard is merged into the base table asynchronously. +An index is kept in the base table for readers to quickly discover the state of all shards at a point of time. -### MemWAL Region +### MemWAL Shard -A **MemWAL Region** is the main unit to horizontally scale out writes. +A **MemWAL Shard** is the main unit to horizontally scale out writes. -Each region has exactly one active writer at any time. -Writers claim a region and then write data to that region. -Data in each region is expected to be merged into the base table asynchronously. +Each shard has exactly one active writer at any time. +Writers claim a shard and then write data to that shard. +Data in each shard is expected to be merged into the base table asynchronously. -Rows of the same primary key must be written to one and only one region. -If two regions contain rows with the same primary key, the following scenario can cause data corruption: +Rows of the same primary key must be written to one and only one shard. +If two shards contain rows with the same primary key, the following scenario can cause data corruption: -1. Region A receives a write with primary key `pk=1` at time T1 -2. Region B receives a write with primary key `pk=1` at time T2 (T2 > T1) -3. The row in region B is merged into the base table first -4. The row in region A is merged into the base table second -5. The row from Region A (older) now overwrites the row from Region B (newer) +1. Shard A receives a write with primary key `pk=1` at time T1 +2. Shard B receives a write with primary key `pk=1` at time T2 (T2 > T1) +3. The row in shard B is merged into the base table first +4. The row in shard A is merged into the base table second +5. The row from Shard A (older) now overwrites the row from Shard B (newer) This violates the expected "last write wins" semantics. -By ensuring each primary key is assigned to exactly one region via the region spec, -merge order between regions becomes irrelevant for correctness. +By ensuring each primary key is assigned to exactly one shard via the shard spec, +merge order between shards becomes irrelevant for correctness. -See [MemWAL Region Architecture](#region-architecture) for the complete region architecture. +See [MemWAL Shard Architecture](#shard-architecture) for the complete shard architecture. ### MemWAL Index A **MemWAL Index** is the centralized structure for all MemWAL metadata on top of a base table. A table has at most one MemWAL index. It stores: -- **Configuration**: Region specs defining how rows map to regions, and which indexes to maintain -- **Merge progress**: Last generation merged to base table for each region +- **Configuration**: Shard specs defining how rows map to shards, and which indexes to maintain +- **Merge progress**: Last generation merged to base table for each shard - **Index catchup progress**: Which merged generation each base table index has been rebuilt to cover -- **Region snapshots**: Snapshot of all region states for read optimization +- **Shard snapshots**: Snapshot of all shard states for read optimization The index is the source of truth for **configuration**, **merge progress** and **index catchup progress** Writers and mergers read the MemWAL index to get these configurations before writing. -Each [region's manifest](#region-manifest) is authoritative for its own state. -Readers use **region snapshots** is a read-only optimization to see a point-in-time view of all regions without the need to open each region manifest. +Each [shard's manifest](#shard-manifest) is authoritative for its own state. +Readers use **shard snapshots** is a read-only optimization to see a point-in-time view of all shards without the need to open each shard manifest. See [MemWAL Index Details](#memwal-index-details) for the complete structure. -## Region Architecture +## Shard Architecture -![Region Architecture](../../images/mem_wal_regional.png) +![Shard Architecture](../../images/mem_wal_regional.png) -Within a region, writes are stored in an **in-memory table (MemTable)**. -It is also written to the region's **Write-Ahead Log (WAL)** for durability guarantee. +Within a shard, writes are stored in an **in-memory table (MemTable)**. +It is also written to the shard's **Write-Ahead Log (WAL)** for durability guarantee. The MemTable is periodically **flushed** to storage based on memory pressure and other conditions. **Flushed MemTables** in storage are then asynchronously **merged** into the base table. ### MemTable -A MemTable holds rows inserted into the region before flushing to storage. +A MemTable holds rows inserted into the shard before flushing to storage. It serves 2 purposes: 1. build up data and related indexes to be flushed to storage as a flushed MemTable @@ -93,7 +93,7 @@ When MemTable of generation `N` is discarded, the next MemTable gets assigned ge ### WAL -WAL serves as the durable storage of all MemTables in a region. +WAL serves as the durable storage of all MemTables in a shard. It consists of data in MemTables ordered by generation. Every time we write to the WAL, we call it a **WAL Flush**. @@ -121,7 +121,7 @@ If WAL entry `N` is not flushed fully, WAL entry `N+1` must not exist in storage **Replaying** a WAL means to read data in the WAL from a lower to a higher position. This is commonly used to recover the latest MemTable after it is lost, by reading from the start position of the latest MemTable generation till the highest position in the WAL, -assuming proper fencing to guard against multiple writers to the same region. +assuming proper fencing to guard against multiple writers to the same shard. See [Writer Fencing](#writer-fencing) for the full fencing mechanism. @@ -132,7 +132,7 @@ The writer epoch is stored in the stream's Arrow schema metadata with key `write #### WAL Storage Layout -Each WAL entry is stored within the WAL directory of the region located at `_mem_wal/{region_id}/wal`. +Each WAL entry is stored within the WAL directory of the shard located at `_mem_wal/{shard_id}/wal`. WAL files use bit-reversed 64-bit binary naming to distribute files evenly across the directory keyspace. This optimizes S3 throughput by spreading sequential writes across S3's internal partitions, minimizing throttling. @@ -164,11 +164,11 @@ lower numbers represent older data and must be merged to the base table first to Within a single flushed MemTable, if there are multiple rows of the same primary key, the row that is last inserted wins. -### Region Manifest +### Shard Manifest -Each region has a manifest file. This is the source of truth for the state of a region. +Each shard has a manifest file. This is the source of truth for the state of a shard. -#### Region Manifest Contents +#### Shard Manifest Contents The manifest contains: @@ -177,21 +177,21 @@ The manifest contains: - **Generation trackers**: `current_generation` (next generation to flush), `flushed_generations` list of generation number and directory path pairs (e.g., generation 1 at `a1b2c3d4_gen_1`) Note: `wal_entry_position_last_seen` is a hint that may be stale since it's not updated on WAL write. -It is updated opportunistically by any reader that can update the region manifest. +It is updated opportunistically by any reader that can update the shard manifest. The manifest itself is atomically written, but recovery must try to get newer WAL files to find the actual state beyond this hint. -The manifest is serialized as a protobuf binary file using the `RegionManifest` message. +The manifest is serialized as a protobuf binary file using the `ShardManifest` message.
    -RegionManifest protobuf message +ShardManifest protobuf message ```protobuf -%%% mem_wal.message.RegionManifest %%% +%%% mem_wal.message.ShardManifest %%% ```
    -#### Region Manifest Versioning +#### Shard Manifest Versioning Manifests are versioned starting from 1 and immutable. Each update creates a new manifest file at the next version number. @@ -212,13 +212,13 @@ To read the latest manifest version: 4. The latest version is the last found version !!!note -This works because the write rate to region manifests is significantly lower than read rates. Region manifests are only updated when region metadata changes (MemTable flush), not on every write. This ensures HEAD requests will eventually terminate and find the latest version. +This works because the write rate to shard manifests is significantly lower than read rates. Shard manifests are only updated when shard metadata changes (MemTable flush), not on every write. This ensures HEAD requests will eventually terminate and find the latest version. -#### Region Manifest Storage Layout +#### Shard Manifest Storage Layout -All region manifest versions are stored in `_mem_wal/{region_id}/manifest` directory. +All shard manifest versions are stored in `_mem_wal/{shard_id}/manifest` directory. -Each region manifest version file uses bit-reversed 64-bit binary naming, the same scheme as WAL files. +Each shard manifest version file uses bit-reversed 64-bit binary naming, the same scheme as WAL files. For example, version 5 becomes `1010000000000000000000000000000000000000000000000000000000000000.binpb`. ## MemWAL Index Details @@ -228,24 +228,24 @@ The MemWAL Index uses the [standard index storage](index/index.md#index-storage) The index stores its data in two parts: 1. **Index details** (`index_details` in `IndexMetadata`): Contains configuration, merge progress, and snapshot metadata -2. **Region snapshots**: Stored as a Lance file or inline, depending on region count +2. **Shard snapshots**: Stored as a Lance file or inline, depending on shard count ### Index Details The `index_details` field in `IndexMetadata` contains a `MemWalIndexDetails` protobuf message with the following key fields: -- **Configuration fields** (`region_specs`, `maintained_indexes`) are the source of truth for MemWAL configuration. +- **Configuration fields** (`shard_specs`, `maintained_indexes`) are the source of truth for MemWAL configuration. Writers read these fields to determine how to partition data and which indexes to maintain. -- **Merge progress** (`merged_generations`) tracks the last generation merged to the base table for each region. +- **Merge progress** (`merged_generations`) tracks the last generation merged to the base table for each shard. This field is updated atomically with merge-insert data commits, enabling conflict resolution when multiple mergers operate concurrently. - Each entry contains the region UUID and generation number. + Each entry contains the shard UUID and generation number. - **Index catchup progress** (`index_catchup`) tracks which merged generation each base table index has been rebuilt to cover. When data is merged from a flushed MemTable to the base table, the base table's indexes may be rebuilt asynchronously. During this window, queries should use the flushed MemTable's pre-built indexes instead of scanning unindexed data in the base table. See [Indexed Read Plan](#indexed-read-plan) for details. -- **Region snapshot fields** (`snapshot_ts_millis`, `num_regions`, `inline_snapshots`) provide a snapshot of region states. - The actual region manifests remain authoritative for region state. - When `num_regions` is 0, the `inline_snapshots` field may be `None` or an empty Lance file with 0 rows but proper schema. +- **Shard snapshot fields** (`snapshot_ts_millis`, `num_shards`, `inline_snapshots`) provide a snapshot of shard states. + The actual shard manifests remain authoritative for shard state. + When `num_shards` is 0, the `inline_snapshots` field may be `None` or an empty Lance file with 0 rows but proper schema.
    MemWalIndexDetails protobuf message @@ -256,55 +256,55 @@ The `index_details` field in `IndexMetadata` contains a `MemWalIndexDetails` pro
    -### Region Identifier +### Shard Identifier -Each region has a unique identifier across all regions following UUID v4 standard. -When a new region is created, it is assigned a new identifier. +Each shard has a unique identifier across all shards following UUID v4 standard. +When a new shard is created, it is assigned a new identifier. -### Region Spec +### Shard Spec -A **Region Spec** defines how all rows in a table are logically divided into different regions, -enabling automatic region assignment and query-time region pruning. +A **Shard Spec** defines how all rows in a table are logically divided into different shards, +enabling automatic shard assignment and query-time shard pruning. -Each region spec has: +Each shard spec has: - **Spec ID**: A positive integer that uniquely identifies this spec within the MemWAL index. IDs are never reused. -- **Region fields**: An array of field definitions that determine how to compute region values. +- **Shard fields**: An array of field definitions that determine how to compute shard values. -Each region is bound to a specific region spec ID, recorded in its [manifest](#region-manifest). -Regions without a spec ID (`spec_id = 0`) are manually-created regions not governed by any spec. +Each shard is bound to a specific shard spec ID, recorded in its [manifest](#shard-manifest). +Shards without a spec ID (`spec_id = 0`) are manually-created shards not governed by any spec. -A region spec's field array consists of **region field** definitions. -Each region field has the following properties: +A shard spec's field array consists of **shard field** definitions. +Each shard field has the following properties: | Property | Description | | ------------- | ------------------------------------------------------------------------- | -| `field_id` | Unique string identifier for this region field | +| `field_id` | Unique string identifier for this shard field | | `source_ids` | Array of field IDs referencing source columns in the schema | -| `transform` | A well-known region expression, specify this or `expression` | +| `transform` | A well-known shard expression, specify this or `expression` | | `expression` | A DataFusion SQL expression for custom logic, specify this or `transform` | -| `result_type` | The output type of the region value | +| `result_type` | The output type of the shard value | -#### Region Expression +#### Shard Expression -A **Region Expression** is a [DataFusion SQL expression](https://datafusion.apache.org/user-guide/sql/index.html) that derives a region value from source column(s). +A **Shard Expression** is a [DataFusion SQL expression](https://datafusion.apache.org/user-guide/sql/index.html) that derives a shard value from source column(s). Source columns are referenced as `col0`, `col1`, etc., corresponding to the order of field IDs in `source_ids`. -Region expressions must satisfy the following requirements: +Shard expressions must satisfy the following requirements: 1. **Deterministic**: The same input value must always produce the same output value. 2. **Stateless**: The expression must not depend on external state (e.g., current time, random values, session variables). -3. **Type-promotion resistant**: The expression must produce the same result for equivalent values regardless of their numeric type (e.g., `int32(5)` and `int64(5)` must yield the same region value). +3. **Type-promotion resistant**: The expression must produce the same result for equivalent values regardless of their numeric type (e.g., `int32(5)` and `int64(5)` must yield the same shard value). 4. **Column removal resistant**: If a source field ID is not found in the schema, the column should be interpreted as NULL. 5. **NULL-safe**: The expression should properly handle NULL inputs and have defined behavior (e.g., return NULL if input is NULL for single-column expressions). 6. **Consistent with result type**: The expression's return type must be consistent with `result_type` in non-NULL cases. -#### Region Transform +#### Shard Transform -A **Region Transform** is a well-known region expression with a predefined name. +A **Shard Transform** is a well-known shard expression with a predefined name. When a transform is specified, the expression is derived automatically. -| Transform | Parameters | Region Expression | Result Type | +| Transform | Parameters | Shard Expression | Result Type | | -------------- | ------------- | --------------------------------------------------------- | -------------- | | `identity` | (none) | `col0` | same as source | | `year` | (none) | `date_part('year', col0)` | `int32` | @@ -322,48 +322,48 @@ The `bucket` and `multi_bucket` transforms use Murmur3 hash functions: The hash result is wrapped with `abs()` and modulo `N` to produce a non-negative bucket number in the range `[0, N)`. -### Region Snapshot Storage +### Shard Snapshot Storage -Region snapshots are stored using one of two strategies based on the number of regions: +Shard snapshots are stored using one of two strategies based on the number of shards: -| Region Count | Storage Strategy | Location | +| Shard Count | Storage Strategy | Location | | ------------------ | ------------------- | ----------------------------------------- | | <= 100 (threshold) | Inline | `inline_snapshots` field in index details | | > 100 | External Lance file | `_indices/{UUID}/index.lance` | -The threshold (100 regions) is implementation-defined and may vary. +The threshold (100 shards) is implementation-defined and may vary. -**Inline storage**: For small region counts, snapshots are serialized as a Lance file and stored in the `inline_snapshots` field. +**Inline storage**: For small shard counts, snapshots are serialized as a Lance file and stored in the `inline_snapshots` field. This keeps the index metadata compact while avoiding an additional file read for common cases. -**External Lance file**: For large region counts, snapshots are stored as a Lance file at `_indices/{UUID}/index.lance`. -This file uses standard Lance format with the region snapshot schema, enabling efficient columnar access and compression. +**External Lance file**: For large shard counts, snapshots are stored as a Lance file at `_indices/{UUID}/index.lance`. +This file uses standard Lance format with the shard snapshot schema, enabling efficient columnar access and compression. -### Region Snapshot Arrow Schema +### Shard Snapshot Arrow Schema -Region snapshots are stored as a Lance file with one row per region. -The schema has one column per `RegionManifest` field plus region spec columns: +Shard snapshots are stored as a Lance file with one row per shard. +The schema has one column per `ShardManifest` field plus shard spec columns: | Column | Type | Description | | --------------------------------- | ------------------------------------------------ | -------------------------------------------------------- | -| `region_id` | `fixed_size_binary(16)` | Region UUID bytes | -| `version` | `uint64` | Region manifest version | -| `region_spec_id` | `uint32` | Region spec ID (0 if manual) | +| `shard_id` | `fixed_size_binary(16)` | Shard UUID bytes | +| `version` | `uint64` | Shard manifest version | +| `shard_spec_id` | `uint32` | Shard spec ID (0 if manual) | | `writer_epoch` | `uint64` | Writer fencing token | | `replay_after_wal_entry_position` | `uint64` | Last WAL entry position (0-based) flushed to MemTable | | `wal_entry_position_last_seen` | `uint64` | Last WAL entry position (0-based) seen (hint) | | `current_generation` | `uint64` | Next generation to flush | | `flushed_generations` | `list>` | Flushed MemTable paths | -| `region_field_{field_id}` | varies | Region field value (one column per field in region spec) | +| `region_field_{field_id}` | varies | Shard field value (one column per field in shard spec) | -For example, with a region spec containing a field `user_bucket` of type `int32`: +For example, with a shard spec containing a field `user_bucket` of type `int32`: | Column | Type | Description | | -------------------------- | ------- | ---------------------------- | | ... | ... | (base columns above) | -| `region_field_user_bucket` | `int32` | Bucket value for this region | +| `region_field_user_bucket` | `int32` | Bucket value for this shard | -This schema directly corresponds to the fields in the `RegionManifest` protobuf message plus the computed region field values. +This schema directly corresponds to the fields in the `ShardManifest` protobuf message plus the computed shard field values. ## Storage Layout @@ -373,12 +373,12 @@ Here is a recap of the storage layout with all the files and concepts defined so {table_path}/ ├── _indices/ │ └── {index_uuid}/ # MemWAL Index (uses standard index storage) -│ └── index.lance # Serialized region snapshots (Lance file) +│ └── index.lance # Serialized shard snapshots (Lance file) │ └── _mem_wal/ - └── {region_uuid}/ # Region directory (UUID v4) + └── {region_uuid}/ # Shard directory (UUID v4) ├── manifest/ - │ ├── {bit_reversed_version}.binpb # Serialized region manifest (bit-reversed naming) + │ ├── {bit_reversed_version}.binpb # Serialized shard manifest (bit-reversed naming) │ └── version_hint.json # Version hint file ├── wal/ │ ├── {bit_reversed_entry_id}.lance # WAL data files (bit-reversed naming) @@ -398,7 +398,7 @@ This specification describes the storage layout for the LSM tree architecture. I The specification defines: -- **Storage layout**: The directory structure, file formats, and naming conventions for WAL entries, flushed MemTables, region manifests, and the MemWAL index +- **Storage layout**: The directory structure, file formats, and naming conventions for WAL entries, flushed MemTables, shard manifests, and the MemWAL index - **Durability guarantees**: How data is persisted through WAL entries and flushed MemTables - **Consistency model**: How readers and writers coordinate through manifests and epoch-based fencing @@ -413,19 +413,19 @@ As long as the storage layout is correct and the documented invariants are maint ## Writer Expectations -A writer operates on a single region and is responsible for: +A writer operates on a single shard and is responsible for: -1. Claiming the region using epoch-based fencing +1. Claiming the shard using epoch-based fencing 2. Writing data to WAL entries and flushed MemTables following the [storage layout](#storage-layout) -3. Maintaining the region manifest to track WAL and generation progress +3. Maintaining the shard manifest to track WAL and generation progress ### Writer Fencing -Writers use epoch-based fencing to ensure single-writer semantics per region. +Writers use epoch-based fencing to ensure single-writer semantics per shard. -To claim a region: +To claim a shard: -1. Load the latest region manifest +1. Load the latest shard manifest 2. Increment `writer_epoch` by one 3. Atomically write a new manifest version 4. If the write fails (another writer claimed the epoch), reload and retry with a higher epoch @@ -443,20 +443,20 @@ Background jobs handle merging flushed MemTables to the base table and garbage c ### MemTable Merger -Flushed MemTables must be merged to the base table in **ascending generation order** within each region. This ordering is essential for correct upsert semantics: newer generations must overwrite older ones. +Flushed MemTables must be merged to the base table in **ascending generation order** within each shard. This ordering is essential for correct upsert semantics: newer generations must overwrite older ones. The merge uses Lance's merge-insert operation with atomic transaction semantics: -- `merged_generations[region_id]` is updated atomically with the data commit +- `merged_generations[shard_id]` is updated atomically with the data commit - On commit conflict, check the conflicting commit's `merged_generations` to determine if the generation was already merged For a concrete example, see [Appendix 2: Concurrent Merger Example](#appendix-2-concurrent-merger-example). ### Garbage Collector -The garbage collector removes obsolete data from region directories. Flushed MemTables and their referenced WAL files may be deleted after: +The garbage collector removes obsolete data from shard directories. Flushed MemTables and their referenced WAL files may be deleted after: -1. The generation has been merged to the base table (`generation <= merged_generations[region_id]`) +1. The generation has been merged to the base table (`generation <= merged_generations[shard_id]`) 2. All maintained indexes have caught up (`generation <= min(index_catchup[I].caught_up_generation)`) 3. No retained base table version references the generation for time travel @@ -486,9 +486,9 @@ Without proper merging, queries would return duplicate or stale rows. Reader consistency depends on two factors: 1. access to in-memory MemTables -2. the source of region metadata (either through MemWAL index or region manifests) +2. the source of shard metadata (either through MemWAL index or shard manifests) -Strong consistency requires access to in-memory MemTables for all regions involved in the query and reading region manifests directly. +Strong consistency requires access to in-memory MemTables for all shards involved in the query and reading shard manifests directly. Otherwise, the query is eventually consistent due to missing unflushed data or stale MemWAL Index snapshots. !!!note @@ -510,30 +510,30 @@ Datasets come from: 3. optionally in-memory MemTables (if accessible). Each dataset is tagged with a generation number: 0 for the base table, and positive integers for MemTable generations. -Within a region, the generation number determines data freshness, with higher numbers representing newer data. -Rows from different regions do not need deduplication since each primary key maps to exactly one region. +Within a shard, the generation number determines data freshness, with higher numbers representing newer data. +Rows from different shards do not need deduplication since each primary key maps to exactly one shard. The planner also collects bloom filters from each generation for staleness detection during search queries. -#### Region Pruning +#### Shard Pruning -Before executing queries, if region spec is available, -the planner evaluates filter predicates against region specs to determine which regions may contain matching data. -This pruning step reduces the number of regions to scan. +Before executing queries, if shard spec is available, +the planner evaluates filter predicates against shard specs to determine which shards may contain matching data. +This pruning step reduces the number of shards to scan. For each filter predicate: -1. Extract predicates on columns used in region specs -2. Evaluate which region values can satisfy the predicate -3. Prune regions whose values cannot match +1. Extract predicates on columns used in shard specs +2. Evaluate which shard values can satisfy the predicate +3. Prune shards whose values cannot match -For example, with a region spec using `bucket(user_id, 10)` and a filter `user_id = 123`: +For example, with a shard spec using `bucket(user_id, 10)` and a filter `user_id = 123`: 1. Compute `bucket(123, 10) = 3` -2. Only scan regions with bucket value 3 -3. Skip all other regions +2. Only scan shards with bucket value 3 +3. Skip all other shards -Region pruning applies to both scan queries and prefilters in search queries. +Shard pruning applies to both scan queries and prefilters in search queries. #### Indexed Read Plan @@ -553,12 +553,12 @@ This ensures all reads remain indexed regardless of how far behind the async ind ### Appendix 1: Writer Fencing Example -This example demonstrates how epoch-based fencing prevents data corruption when two writers compete for the same region. +This example demonstrates how epoch-based fencing prevents data corruption when two writers compete for the same shard. #### Initial State ``` -Region manifest (version 1): +Shard manifest (version 1): writer_epoch: 5 replay_after_wal_entry_position: 10 wal_entry_position_last_seen: 12 @@ -611,9 +611,9 @@ This example demonstrates how MemWAL Index and conflict resolution handle concur ``` MemWAL Index: - merged_generations: {region: 5} + merged_generations: {shard: 5} -Region manifest (version 1): +Shard manifest (version 1): current_generation: 8 flushed_generations: [(6, "abc123_gen_6"), (7, "def456_gen_7")] ``` @@ -625,10 +625,10 @@ Two mergers both try to merge generation 6 concurrently. | Step | Merger A | Merger B | MemWAL Index | | ---- | ------------------------- | ------------------------------ | ---------------- | | 1 | Reads index: merged_gen=5 | | merged_gen=5 | -| 2 | Reads region manifest | | | +| 2 | Reads shard manifest | | | | 3 | Starts merging gen 6 | | | | 4 | | Reads index: merged_gen=5 | merged_gen=5 | -| 5 | | Reads region manifest | | +| 5 | | Reads shard manifest | | | 6 | | Starts merging gen 6 | | | 7 | Commits (merged_gen=6) | | **merged_gen=6** | | 8 | | Tries to commit | | @@ -648,7 +648,7 @@ Merger A crashes after committing to the table. | 2 | Merges gen 6, commits | | **merged_gen=6** | | 3 | **CRASH** | | merged_gen=6 | | 4 | | Reads index: merged_gen=6 | merged_gen=6 | -| 5 | | Reads region manifest | | +| 5 | | Reads shard manifest | | | 6 | | **Skips gen 6** (already merged) | | | 7 | | Merges gen 7, commits | **merged_gen=7** | diff --git a/protos/table.proto b/protos/table.proto index cb398e66431..e73d22b6b93 100644 --- a/protos/table.proto +++ b/protos/table.proto @@ -522,23 +522,23 @@ message FragmentReuseIndexDetails { // MemWAL Index Types // ============================================================================ -// Region manifest containing epoch-based fencing and WAL state. -// Each region has exactly one active writer at any time. -message RegionManifest { - // Region identifier (UUID v4). - UUID region_id = 11; +// Shard manifest containing epoch-based fencing and WAL state. +// Each shard has exactly one active writer at any time. +message ShardManifest { + // Shard identifier (UUID v4). + UUID shard_id = 11; // Manifest version number. // Matches the version encoded in the filename. uint64 version = 1; - // Region spec ID this region was created with. - // Set at region creation and immutable thereafter. - // A value of 0 indicates a manually-created region not governed by any spec. - uint32 region_spec_id = 10; + // Shard spec ID this shard was created with. + // Set at shard creation and immutable thereafter. + // A value of 0 indicates a manually-created shard not governed by any spec. + uint32 shard_spec_id = 10; // Writer fencing token - monotonically increasing. - // A writer must increment this when claiming the region. + // A writer must increment this when claiming the shard. uint64 writer_epoch = 2; // The most recent WAL entry position (0-based) that has been flushed to a MemTable. @@ -564,16 +564,16 @@ message FlushedGeneration { // Generation number. uint64 generation = 1; - // Directory name relative to the region directory. + // Directory name relative to the shard directory. string path = 2; } -// A region's merged generation, used in MemWalIndexDetails. +// A shard's merged generation, used in MemWalIndexDetails. message MergedGeneration { - // Region identifier (UUID v4). - UUID region_id = 1; + // Shard identifier (UUID v4). + UUID shard_id = 1; - // Last generation merged to base table for this region. + // Last generation merged to base table for this shard. uint64 generation = 2; } @@ -583,27 +583,27 @@ message IndexCatchupProgress { // Name of the base table index (must match an entry in maintained_indexes). string index_name = 1; - // Per-region progress: the generation up to which this index covers. - // If a region is not present, the index is assumed to be fully caught up - // (i.e., caught_up_generation >= merged_generation for that region). + // Per-shard progress: the generation up to which this index covers. + // If a shard is not present, the index is assumed to be fully caught up + // (i.e., caught_up_generation >= merged_generation for that shard). repeated MergedGeneration caught_up_generations = 2; } // Index details for MemWAL Index, stored in IndexMetadata.index_details. // This is the centralized structure for all MemWAL metadata: -// - Configuration (region specs, indexes to maintain) -// - Merge progress (merged generations per region) -// - Region state snapshots +// - Configuration (shard specs, indexes to maintain) +// - Merge progress (merged generations per shard) +// - Shard state snapshots // // Writers read this index to get configuration before writing. -// Readers read this index to discover regions and their state. -// A background process updates the index periodically to keep region snapshots current. +// Readers read this index to discover shards and their state. +// A background process updates the index periodically to keep shard snapshots current. // -// Region snapshots are stored as a Lance file with one row per region. -// The schema has one column per RegionManifest field, with region fields as columns: -// region_id: fixed_size_binary(16) -- UUID bytes +// Shard snapshots are stored as a Lance file with one row per shard. +// The schema has one column per ShardManifest field, with shard fields as columns: +// shard_id: fixed_size_binary(16) -- UUID bytes // version: uint64 -// region_spec_id: uint32 +// shard_spec_id: uint32 // writer_epoch: uint64 // replay_after_wal_entry_position: uint64 // wal_entry_position_last_seen: uint64 @@ -614,19 +614,19 @@ message MemWalIndexDetails { // Snapshot timestamp (Unix timestamp in milliseconds). int64 snapshot_ts_millis = 1; - // Number of regions in the snapshot. + // Number of shards in the snapshot. // Used to determine storage format without reading the snapshot data. - uint32 num_regions = 2; + uint32 num_shards = 2; - // Inline region snapshots for small region counts. - // When num_regions <= threshold (implementation-defined, e.g., 100), + // Inline shard snapshots for small shard counts. + // When num_shards <= threshold (implementation-defined, e.g., 100), // snapshots are stored inline as serialized bytes. - // Format: Lance file bytes with the region snapshot schema. + // Format: Lance file bytes with the shard snapshot schema. optional bytes inline_snapshots = 3; - // Region specs defining how to derive region identifiers. - // This configuration determines how rows are partitioned into regions. - repeated RegionSpec region_specs = 7; + // Shard specs defining how to derive shard identifiers. + // This configuration determines how rows are partitioned into shards. + repeated ShardSpec shard_specs = 7; // Indexes from the base table to maintain in MemTables. // These are index names referencing indexes defined on the base table. @@ -637,13 +637,13 @@ message MemWalIndexDetails { // SQ params) from the base table index to ensure distance comparability. repeated string maintained_indexes = 8; - // Last generation merged to base table for each region. + // Last generation merged to base table for each shard. // This is updated atomically with merge-insert data commits, enabling // conflict resolution when multiple mergers operate concurrently. // - // Note: This is separate from region snapshots because: + // Note: This is separate from shard snapshots because: // 1. merged_generations is updated by mergers (atomic with data commit) - // 2. region snapshots are updated by background index builder + // 2. shard snapshots are updated by background index builder repeated MergedGeneration merged_generations = 9; // Per-index catchup progress tracking. @@ -658,25 +658,25 @@ message MemWalIndexDetails { repeated IndexCatchupProgress index_catchup = 10; } -// Region spec definition. -message RegionSpec { +// Shard spec definition. +message ShardSpec { // Unique identifier for this spec within the index. // IDs are never reused. uint32 spec_id = 1; - // Region field definitions that determine how to compute region identifiers. - repeated RegionField fields = 2; + // Shard field definitions that determine how to compute shard identifiers. + repeated ShardField fields = 2; } -// Region field definition. -message RegionField { - // Unique string identifier for this region field. +// Shard field definition. +message ShardField { + // Unique string identifier for this shard field. string field_id = 1; // Field IDs referencing source columns in the schema. repeated int32 source_ids = 2; - // Well-known region transform name (e.g., "identity", "year", "bucket"). + // Well-known shard transform name (e.g., "identity", "year", "bucket"). // Mutually exclusive with expression. optional string transform = 3; @@ -684,7 +684,7 @@ message RegionField { // Mutually exclusive with transform. optional string expression = 4; - // Output type of the region value (Arrow type name). + // Output type of the shard value (Arrow type name). string result_type = 5; // Transform parameters (e.g., num_buckets for bucket transform). diff --git a/protos/transaction.proto b/protos/transaction.proto index bbdb61dbcce..06268feb252 100644 --- a/protos/transaction.proto +++ b/protos/transaction.proto @@ -238,7 +238,7 @@ message Transaction { repeated DataFragment new_fragments = 3; // The ids of the fields that have been modified. repeated uint32 fields_modified = 4; - /// List of MemWAL region generations to mark as merged after this transaction + /// List of MemWAL shard generations to mark as merged after this transaction repeated MergedGeneration merged_generations = 5; /// The fields that used to judge whether to preserve the new frag's id into /// the frag bitmap of the specified indices. @@ -312,7 +312,7 @@ message Transaction { // This operation is used during merge-insert to atomically record which // generations have been merged to the base table. message UpdateMemWalState { - // Regions and generations being marked as merged. + // Shards and generations being marked as merged. repeated MergedGeneration merged_generations = 1; } diff --git a/rust/lance-index/src/mem_wal.rs b/rust/lance-index/src/mem_wal.rs index 5bafc370eb4..cbc123b8881 100644 --- a/rust/lance-index/src/mem_wal.rs +++ b/rust/lance-index/src/mem_wal.rs @@ -17,8 +17,8 @@ use crate::{Index, IndexType}; pub const MEM_WAL_INDEX_NAME: &str = "__lance_mem_wal"; -/// Type alias for region identifier (UUID v4). -pub type RegionId = Uuid; +/// Type alias for shard identifier (UUID v4). +pub type ShardId = Uuid; /// A flushed MemTable generation and its storage location. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeepSizeOf)] @@ -45,10 +45,10 @@ impl From for FlushedGeneration { } } -/// A region's merged generation, used in MemWalIndexDetails. +/// A shard's merged generation, used in MemWalIndexDetails. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash, Serialize, Deserialize)] pub struct MergedGeneration { - pub region_id: Uuid, + pub shard_id: Uuid, pub generation: u64, } @@ -59,9 +59,9 @@ impl DeepSizeOf for MergedGeneration { } impl MergedGeneration { - pub fn new(region_id: Uuid, generation: u64) -> Self { + pub fn new(shard_id: Uuid, generation: u64) -> Self { Self { - region_id, + shard_id, generation, } } @@ -70,7 +70,7 @@ impl MergedGeneration { impl From<&MergedGeneration> for pb::MergedGeneration { fn from(mg: &MergedGeneration) -> Self { Self { - region_id: Some((&mg.region_id).into()), + shard_id: Some((&mg.shard_id).into()), generation: mg.generation, } } @@ -80,13 +80,13 @@ impl TryFrom for MergedGeneration { type Error = Error; fn try_from(mg: pb::MergedGeneration) -> lance_core::Result { - let region_id = mg - .region_id + let shard_id = mg + .shard_id .as_ref() .map(Uuid::try_from) - .ok_or_else(|| Error::invalid_input("Missing region_id in MergedGeneration"))??; + .ok_or_else(|| Error::invalid_input("Missing shard_id in MergedGeneration"))??; Ok(Self { - region_id, + shard_id, generation: mg.generation, }) } @@ -108,12 +108,12 @@ impl IndexCatchupProgress { } } - /// Get the caught up generation for a specific region. - /// Returns None if the region is not present (assumed fully caught up). - pub fn caught_up_generation_for_region(&self, region_id: &Uuid) -> Option { + /// Get the caught up generation for a specific shard. + /// Returns None if the shard is not present (assumed fully caught up). + pub fn caught_up_generation_for_shard(&self, shard_id: &Uuid) -> Option { self.caught_up_generations .iter() - .find(|mg| &mg.region_id == region_id) + .find(|mg| &mg.shard_id == shard_id) .map(|mg| mg.generation) } } @@ -146,13 +146,13 @@ impl TryFrom for IndexCatchupProgress { } } -/// Region manifest containing epoch-based fencing and WAL state. -/// Each region has exactly one active writer at any time. +/// Shard manifest containing epoch-based fencing and WAL state. +/// Each shard has exactly one active writer at any time. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RegionManifest { - pub region_id: Uuid, +pub struct ShardManifest { + pub shard_id: Uuid, pub version: u64, - pub region_spec_id: u32, + pub shard_spec_id: u32, pub writer_epoch: u64, /// The most recent WAL entry position (0-based) flushed to a MemTable. /// Recovery replays from `replay_after_wal_entry_position + 1`. @@ -163,18 +163,18 @@ pub struct RegionManifest { pub flushed_generations: Vec, } -impl DeepSizeOf for RegionManifest { +impl DeepSizeOf for ShardManifest { fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize { self.flushed_generations.deep_size_of_children(context) } } -impl From<&RegionManifest> for pb::RegionManifest { - fn from(rm: &RegionManifest) -> Self { +impl From<&ShardManifest> for pb::ShardManifest { + fn from(rm: &ShardManifest) -> Self { Self { - region_id: Some((&rm.region_id).into()), + shard_id: Some((&rm.shard_id).into()), version: rm.version, - region_spec_id: rm.region_spec_id, + shard_spec_id: rm.shard_spec_id, writer_epoch: rm.writer_epoch, replay_after_wal_entry_position: rm.replay_after_wal_entry_position, wal_entry_position_last_seen: rm.wal_entry_position_last_seen, @@ -184,19 +184,19 @@ impl From<&RegionManifest> for pb::RegionManifest { } } -impl TryFrom for RegionManifest { +impl TryFrom for ShardManifest { type Error = Error; - fn try_from(rm: pb::RegionManifest) -> lance_core::Result { - let region_id = rm - .region_id + fn try_from(rm: pb::ShardManifest) -> lance_core::Result { + let shard_id = rm + .shard_id .as_ref() .map(Uuid::try_from) - .ok_or_else(|| Error::invalid_input("Missing region_id in RegionManifest"))??; + .ok_or_else(|| Error::invalid_input("Missing shard_id in ShardManifest"))??; Ok(Self { - region_id, + shard_id, version: rm.version, - region_spec_id: rm.region_spec_id, + shard_spec_id: rm.shard_spec_id, writer_epoch: rm.writer_epoch, replay_after_wal_entry_position: rm.replay_after_wal_entry_position, wal_entry_position_last_seen: rm.wal_entry_position_last_seen, @@ -210,9 +210,9 @@ impl TryFrom for RegionManifest { } } -/// Region field definition. +/// Shard field definition. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeepSizeOf)] -pub struct RegionField { +pub struct ShardField { pub field_id: String, pub source_ids: Vec, pub transform: Option, @@ -221,8 +221,8 @@ pub struct RegionField { pub parameters: HashMap, } -impl From<&RegionField> for pb::RegionField { - fn from(rf: &RegionField) -> Self { +impl From<&ShardField> for pb::ShardField { + fn from(rf: &ShardField) -> Self { Self { field_id: rf.field_id.clone(), source_ids: rf.source_ids.clone(), @@ -234,8 +234,8 @@ impl From<&RegionField> for pb::RegionField { } } -impl From for RegionField { - fn from(rf: pb::RegionField) -> Self { +impl From for ShardField { + fn from(rf: pb::ShardField) -> Self { Self { field_id: rf.field_id, source_ids: rf.source_ids, @@ -247,15 +247,15 @@ impl From for RegionField { } } -/// Region spec definition. +/// Shard spec definition. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeepSizeOf)] -pub struct RegionSpec { +pub struct ShardSpec { pub spec_id: u32, - pub fields: Vec, + pub fields: Vec, } -impl From<&RegionSpec> for pb::RegionSpec { - fn from(rs: &RegionSpec) -> Self { +impl From<&ShardSpec> for pb::ShardSpec { + fn from(rs: &ShardSpec) -> Self { Self { spec_id: rs.spec_id, fields: rs.fields.iter().map(|f| f.into()).collect(), @@ -263,11 +263,11 @@ impl From<&RegionSpec> for pb::RegionSpec { } } -impl From for RegionSpec { - fn from(rs: pb::RegionSpec) -> Self { +impl From for ShardSpec { + fn from(rs: pb::ShardSpec) -> Self { Self { spec_id: rs.spec_id, - fields: rs.fields.into_iter().map(RegionField::from).collect(), + fields: rs.fields.into_iter().map(ShardField::from).collect(), } } } @@ -276,9 +276,9 @@ impl From for RegionSpec { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, DeepSizeOf)] pub struct MemWalIndexDetails { pub snapshot_ts_millis: i64, - pub num_regions: u32, + pub num_shards: u32, pub inline_snapshots: Option>, - pub region_specs: Vec, + pub shard_specs: Vec, pub maintained_indexes: Vec, pub merged_generations: Vec, pub index_catchup: Vec, @@ -288,9 +288,9 @@ impl From<&MemWalIndexDetails> for pb::MemWalIndexDetails { fn from(details: &MemWalIndexDetails) -> Self { Self { snapshot_ts_millis: details.snapshot_ts_millis, - num_regions: details.num_regions, + num_shards: details.num_shards, inline_snapshots: details.inline_snapshots.clone(), - region_specs: details.region_specs.iter().map(|rs| rs.into()).collect(), + shard_specs: details.shard_specs.iter().map(|rs| rs.into()).collect(), maintained_indexes: details.maintained_indexes.clone(), merged_generations: details .merged_generations @@ -308,12 +308,12 @@ impl TryFrom for MemWalIndexDetails { fn try_from(details: pb::MemWalIndexDetails) -> lance_core::Result { Ok(Self { snapshot_ts_millis: details.snapshot_ts_millis, - num_regions: details.num_regions, + num_shards: details.num_shards, inline_snapshots: details.inline_snapshots, - region_specs: details - .region_specs + shard_specs: details + .shard_specs .into_iter() - .map(RegionSpec::from) + .map(ShardSpec::from) .collect(), maintained_indexes: details.maintained_indexes, merged_generations: details @@ -341,29 +341,29 @@ impl MemWalIndex { Self { details } } - pub fn merged_generation_for_region(&self, region_id: &Uuid) -> Option { + pub fn merged_generation_for_shard(&self, shard_id: &Uuid) -> Option { self.details .merged_generations .iter() - .find(|mg| &mg.region_id == region_id) + .find(|mg| &mg.shard_id == shard_id) .map(|mg| mg.generation) } - /// Get the caught up generation for a specific index and region. + /// Get the caught up generation for a specific index and shard. /// Returns None if the index is not tracked (assumed fully caught up). - pub fn index_caught_up_generation(&self, index_name: &str, region_id: &Uuid) -> Option { + pub fn index_caught_up_generation(&self, index_name: &str, shard_id: &Uuid) -> Option { self.details .index_catchup .iter() .find(|icp| icp.index_name == index_name) - .and_then(|icp| icp.caught_up_generation_for_region(region_id)) + .and_then(|icp| icp.caught_up_generation_for_shard(shard_id)) } - /// Check if an index is fully caught up for a region. - /// Returns true if the index covers all merged data for the region. - pub fn is_index_caught_up(&self, index_name: &str, region_id: &Uuid) -> bool { - let merged_gen = self.merged_generation_for_region(region_id).unwrap_or(0); - let caught_up_gen = self.index_caught_up_generation(index_name, region_id); + /// Check if an index is fully caught up for a shard. + /// Returns true if the index covers all merged data for the shard. + pub fn is_index_caught_up(&self, index_name: &str, shard_id: &Uuid) -> bool { + let merged_gen = self.merged_generation_for_shard(shard_id).unwrap_or(0); + let caught_up_gen = self.index_caught_up_generation(index_name, shard_id); // If not tracked in index_catchup, assumed fully caught up caught_up_gen.is_none_or(|generation| generation >= merged_gen) @@ -372,9 +372,9 @@ impl MemWalIndex { #[derive(Serialize)] struct MemWalStatistics { - num_regions: u32, + num_shards: u32, num_merged_generations: usize, - num_region_specs: usize, + num_shard_specs: usize, num_maintained_indexes: usize, num_index_catchup_entries: usize, } @@ -397,9 +397,9 @@ impl Index for MemWalIndex { fn statistics(&self) -> lance_core::Result { let stats = MemWalStatistics { - num_regions: self.details.num_regions, + num_shards: self.details.num_shards, num_merged_generations: self.details.merged_generations.len(), - num_region_specs: self.details.region_specs.len(), + num_shard_specs: self.details.shard_specs.len(), num_maintained_indexes: self.details.maintained_indexes.len(), num_index_catchup_entries: self.details.index_catchup.len(), }; diff --git a/rust/lance/benches/mem_wal_read.rs b/rust/lance/benches/mem_wal_read.rs index c2293c1eb26..80501606d8a 100644 --- a/rust/lance/benches/mem_wal_read.rs +++ b/rust/lance/benches/mem_wal_read.rs @@ -59,9 +59,9 @@ use datafusion::prelude::SessionContext; use futures::TryStreamExt; use lance::dataset::mem_wal::scanner::{ ActiveMemTableRef, LsmDataSourceCollector, LsmPointLookupPlanner, LsmScanner, - LsmVectorSearchPlanner, RegionSnapshot, + LsmVectorSearchPlanner, ShardSnapshot, }; -use lance::dataset::mem_wal::{DatasetMemWalExt, MemWalConfig, RegionWriterConfig}; +use lance::dataset::mem_wal::{DatasetMemWalExt, MemWalConfig, ShardWriterConfig}; use lance::dataset::{Dataset, WriteParams}; use lance_linalg::distance::DistanceType; #[cfg(target_os = "linux")] @@ -170,8 +170,8 @@ struct BenchContext { base_dataset: Arc, /// Dataset with MemWAL for LSM scan. lsm_dataset: Arc, - /// Region snapshots with flushed generations. - region_snapshots: Vec, + /// Shard snapshots with flushed generations. + shard_snapshots: Vec, /// Active memtable reference. active_memtable: Option<(Uuid, ActiveMemTableRef)>, /// Total rows across all sources. @@ -232,7 +232,7 @@ async fn setup_benchmark( // Initialize MemWAL lsm_dataset .initialize_mem_wal(MemWalConfig { - region_spec: None, + shard_spec: None, maintained_indexes: vec![], }) .await @@ -240,22 +240,22 @@ async fn setup_benchmark( let lsm_dataset = Arc::new(lsm_dataset); - // Create RegionWriter with small memtable size to trigger flushes - let region_id = Uuid::new_v4(); - let config = RegionWriterConfig { - region_id, - region_spec_id: 0, + // Create ShardWriter with small memtable size to trigger flushes + let shard_id = Uuid::new_v4(); + let config = ShardWriterConfig { + shard_id, + shard_spec_id: 0, durable_write: false, sync_indexed_write: false, max_memtable_size: memtable_rows * 50, // ~50 bytes per row, triggers flush after memtable_rows max_memtable_rows: memtable_rows, max_wal_flush_interval: Some(Duration::from_secs(60)), // Long interval to avoid time-based flushes - ..RegionWriterConfig::default() + ..ShardWriterConfig::default() }; let writer = lsm_dataset .as_ref() - .mem_wal_writer(region_id, config) + .mem_wal_writer(shard_id, config) .await .unwrap(); @@ -309,13 +309,12 @@ async fn setup_benchmark( // Get active memtable reference let active_memtable_ref = writer.active_memtable_ref().await; - // Build region snapshot - let mut region_snapshot = RegionSnapshot::new(region_id); + // Build shard snapshot + let mut shard_snapshot = ShardSnapshot::new(shard_id); if let Some(ref m) = manifest { - region_snapshot = region_snapshot.with_current_generation(m.current_generation); + shard_snapshot = shard_snapshot.with_current_generation(m.current_generation); for fg in &m.flushed_generations { - region_snapshot = - region_snapshot.with_flushed_generation(fg.generation, fg.path.clone()); + shard_snapshot = shard_snapshot.with_flushed_generation(fg.generation, fg.path.clone()); } } @@ -346,8 +345,8 @@ async fn setup_benchmark( BenchContext { base_dataset, lsm_dataset, - region_snapshots: vec![region_snapshot], - active_memtable: Some((region_id, active_memtable_ref)), + shard_snapshots: vec![shard_snapshot], + active_memtable: Some((shard_id, active_memtable_ref)), total_rows: base_rows + memtable_rows * 2 + gen3_rows, pk_columns, } @@ -408,14 +407,14 @@ fn bench_scan(c: &mut Criterion) { &(), |b, _| { let dataset = ctx.lsm_dataset.clone(); - let region_snapshots = ctx.region_snapshots.clone(); + let shard_snapshots = ctx.shard_snapshots.clone(); let pk_columns = ctx.pk_columns.clone(); b.to_async(&rt).iter(|| { let dataset = dataset.clone(); - let region_snapshots = region_snapshots.clone(); + let shard_snapshots = shard_snapshots.clone(); let pk_columns = pk_columns.clone(); async move { - let scanner = LsmScanner::new(dataset, region_snapshots, pk_columns); + let scanner = LsmScanner::new(dataset, shard_snapshots, pk_columns); let batches: Vec = scanner .try_into_stream() .await @@ -431,20 +430,20 @@ fn bench_scan(c: &mut Criterion) { ); // LSM scan: base + flushed + active memtable - if let Some((region_id, ref active_memtable)) = ctx.active_memtable { + if let Some((shard_id, ref active_memtable)) = ctx.active_memtable { group.bench_with_input(BenchmarkId::new("LSM_Full", &label), &(), |b, _| { let dataset = ctx.lsm_dataset.clone(); - let region_snapshots = ctx.region_snapshots.clone(); + let shard_snapshots = ctx.shard_snapshots.clone(); let pk_columns = ctx.pk_columns.clone(); let active = active_memtable.clone(); b.to_async(&rt).iter(|| { let dataset = dataset.clone(); - let region_snapshots = region_snapshots.clone(); + let shard_snapshots = shard_snapshots.clone(); let pk_columns = pk_columns.clone(); let active = active.clone(); async move { - let scanner = LsmScanner::new(dataset, region_snapshots, pk_columns) - .with_active_memtable(region_id, active); + let scanner = LsmScanner::new(dataset, shard_snapshots, pk_columns) + .with_active_memtable(shard_id, active); let batches: Vec = scanner .try_into_stream() .await @@ -510,23 +509,23 @@ fn bench_scan_with_projection(c: &mut Criterion) { ); // LSM scan with projection - if let Some((region_id, ref active_memtable)) = ctx.active_memtable { + if let Some((shard_id, ref active_memtable)) = ctx.active_memtable { group.bench_with_input( BenchmarkId::new("LSM_Full_Projected", &label), &(), |b, _| { let dataset = ctx.lsm_dataset.clone(); - let region_snapshots = ctx.region_snapshots.clone(); + let shard_snapshots = ctx.shard_snapshots.clone(); let pk_columns = ctx.pk_columns.clone(); let active = active_memtable.clone(); b.to_async(&rt).iter(|| { let dataset = dataset.clone(); - let region_snapshots = region_snapshots.clone(); + let shard_snapshots = shard_snapshots.clone(); let pk_columns = pk_columns.clone(); let active = active.clone(); async move { - let scanner = LsmScanner::new(dataset, region_snapshots, pk_columns) - .with_active_memtable(region_id, active) + let scanner = LsmScanner::new(dataset, shard_snapshots, pk_columns) + .with_active_memtable(shard_id, active) .project(&["id"]); let batches: Vec = scanner .try_into_stream() @@ -609,7 +608,7 @@ fn bench_point_lookup(c: &mut Criterion) { ); // LSM point lookup: key in base table - if let Some((region_id, ref active_memtable)) = ctx.active_memtable { + if let Some((shard_id, ref active_memtable)) = ctx.active_memtable { let arrow_schema: Arc = Arc::new(ctx.lsm_dataset.schema().into()); group.bench_with_input( @@ -617,20 +616,20 @@ fn bench_point_lookup(c: &mut Criterion) { &(), |b, _| { let dataset = ctx.lsm_dataset.clone(); - let region_snapshots = ctx.region_snapshots.clone(); + let shard_snapshots = ctx.shard_snapshots.clone(); let pk_columns = ctx.pk_columns.clone(); let schema = arrow_schema.clone(); let active = active_memtable.clone(); let lookup_id = base_lookup_id; b.to_async(&rt).iter(|| { let dataset = dataset.clone(); - let region_snapshots = region_snapshots.clone(); + let shard_snapshots = shard_snapshots.clone(); let pk_columns = pk_columns.clone(); let schema = schema.clone(); let active = active.clone(); async move { - let collector = LsmDataSourceCollector::new(dataset, region_snapshots) - .with_active_memtable(region_id, active); + let collector = LsmDataSourceCollector::new(dataset, shard_snapshots) + .with_active_memtable(shard_id, active); let planner = LsmPointLookupPlanner::new(collector, pk_columns, schema); let plan = planner .plan_lookup(&[ScalarValue::Int64(Some(lookup_id))], None) @@ -652,20 +651,20 @@ fn bench_point_lookup(c: &mut Criterion) { &(), |b, _| { let dataset = ctx.lsm_dataset.clone(); - let region_snapshots = ctx.region_snapshots.clone(); + let shard_snapshots = ctx.shard_snapshots.clone(); let pk_columns = ctx.pk_columns.clone(); let schema = arrow_schema.clone(); let active = active_memtable.clone(); let lookup_id = flushed_lookup_id; b.to_async(&rt).iter(|| { let dataset = dataset.clone(); - let region_snapshots = region_snapshots.clone(); + let shard_snapshots = shard_snapshots.clone(); let pk_columns = pk_columns.clone(); let schema = schema.clone(); let active = active.clone(); async move { - let collector = LsmDataSourceCollector::new(dataset, region_snapshots) - .with_active_memtable(region_id, active); + let collector = LsmDataSourceCollector::new(dataset, shard_snapshots) + .with_active_memtable(shard_id, active); let planner = LsmPointLookupPlanner::new(collector, pk_columns, schema); let plan = planner .plan_lookup(&[ScalarValue::Int64(Some(lookup_id))], None) @@ -687,20 +686,20 @@ fn bench_point_lookup(c: &mut Criterion) { &(), |b, _| { let dataset = ctx.lsm_dataset.clone(); - let region_snapshots = ctx.region_snapshots.clone(); + let shard_snapshots = ctx.shard_snapshots.clone(); let pk_columns = ctx.pk_columns.clone(); let schema = arrow_schema.clone(); let active = active_memtable.clone(); let lookup_id = active_lookup_id; b.to_async(&rt).iter(|| { let dataset = dataset.clone(); - let region_snapshots = region_snapshots.clone(); + let shard_snapshots = shard_snapshots.clone(); let pk_columns = pk_columns.clone(); let schema = schema.clone(); let active = active.clone(); async move { - let collector = LsmDataSourceCollector::new(dataset, region_snapshots) - .with_active_memtable(region_id, active); + let collector = LsmDataSourceCollector::new(dataset, shard_snapshots) + .with_active_memtable(shard_id, active); let planner = LsmPointLookupPlanner::new(collector, pk_columns, schema); let plan = planner .plan_lookup(&[ScalarValue::Int64(Some(lookup_id))], None) @@ -786,7 +785,7 @@ fn create_query_vector(dim: usize) -> FixedSizeListArray { struct VectorBenchContext { base_dataset: Arc, lsm_dataset: Arc, - region_snapshots: Vec, + shard_snapshots: Vec, active_memtable: Option<(Uuid, ActiveMemTableRef)>, total_rows: usize, pk_columns: Vec, @@ -842,7 +841,7 @@ async fn setup_vector_benchmark( // Initialize MemWAL lsm_dataset .initialize_mem_wal(MemWalConfig { - region_spec: None, + shard_spec: None, maintained_indexes: vec![], }) .await @@ -850,21 +849,21 @@ async fn setup_vector_benchmark( let lsm_dataset = Arc::new(lsm_dataset); - let region_id = Uuid::new_v4(); - let config = RegionWriterConfig { - region_id, - region_spec_id: 0, + let shard_id = Uuid::new_v4(); + let config = ShardWriterConfig { + shard_id, + shard_spec_id: 0, durable_write: false, sync_indexed_write: false, max_memtable_size: memtable_rows * (dim * 4 + 8), max_memtable_rows: memtable_rows, max_wal_flush_interval: Some(Duration::from_secs(60)), - ..RegionWriterConfig::default() + ..ShardWriterConfig::default() }; let writer = lsm_dataset .as_ref() - .mem_wal_writer(region_id, config) + .mem_wal_writer(shard_id, config) .await .unwrap(); @@ -909,12 +908,11 @@ async fn setup_vector_benchmark( let manifest = writer.manifest().await.unwrap(); let active_memtable_ref = writer.active_memtable_ref().await; - let mut region_snapshot = RegionSnapshot::new(region_id); + let mut shard_snapshot = ShardSnapshot::new(shard_id); if let Some(ref m) = manifest { - region_snapshot = region_snapshot.with_current_generation(m.current_generation); + shard_snapshot = shard_snapshot.with_current_generation(m.current_generation); for fg in &m.flushed_generations { - region_snapshot = - region_snapshot.with_flushed_generation(fg.generation, fg.path.clone()); + shard_snapshot = shard_snapshot.with_flushed_generation(fg.generation, fg.path.clone()); } } @@ -931,8 +929,8 @@ async fn setup_vector_benchmark( VectorBenchContext { base_dataset, lsm_dataset, - region_snapshots: vec![region_snapshot], - active_memtable: Some((region_id, active_memtable_ref)), + shard_snapshots: vec![shard_snapshot], + active_memtable: Some((shard_id, active_memtable_ref)), total_rows: base_rows + memtable_rows * 2 + gen3_rows, pk_columns, vector_dim: dim, @@ -992,26 +990,26 @@ fn bench_vector_search(c: &mut Criterion) { }); // LSM vector search - if let Some((region_id, ref active_memtable)) = ctx.active_memtable { + if let Some((shard_id, ref active_memtable)) = ctx.active_memtable { let arrow_schema: Arc = Arc::new(ctx.lsm_dataset.schema().into()); group.bench_with_input(BenchmarkId::new("LSM_KNN", &label), &(), |b, _| { let dataset = ctx.lsm_dataset.clone(); - let region_snapshots = ctx.region_snapshots.clone(); + let shard_snapshots = ctx.shard_snapshots.clone(); let pk_columns = ctx.pk_columns.clone(); let schema = arrow_schema.clone(); let active = active_memtable.clone(); let query = create_query_vector(ctx.vector_dim); b.to_async(&rt).iter(|| { let dataset = dataset.clone(); - let region_snapshots = region_snapshots.clone(); + let shard_snapshots = shard_snapshots.clone(); let pk_columns = pk_columns.clone(); let schema = schema.clone(); let active = active.clone(); let query = query.clone(); async move { - let collector = LsmDataSourceCollector::new(dataset, region_snapshots) - .with_active_memtable(region_id, active); + let collector = LsmDataSourceCollector::new(dataset, shard_snapshots) + .with_active_memtable(shard_id, active); let planner = LsmVectorSearchPlanner::new( collector, pk_columns, diff --git a/rust/lance/benches/mem_wal_write.rs b/rust/lance/benches/mem_wal_write.rs index 5de7867288d..eafb981ae3a 100644 --- a/rust/lance/benches/mem_wal_write.rs +++ b/rust/lance/benches/mem_wal_write.rs @@ -31,9 +31,9 @@ //! - `NUM_BATCHES`: Total number of batches to write (default: 1000) //! - `DURABLE_WRITE`: yes/no/both (default: no) - whether writes wait for WAL flush //! - `INDEXED_WRITE`: yes/no/both (default: no) - whether writes update indexes synchronously -//! - `MAX_WAL_BUFFER_SIZE`: WAL buffer size in bytes (default: 1MB from RegionWriterConfig) +//! - `MAX_WAL_BUFFER_SIZE`: WAL buffer size in bytes (default: 1MB from ShardWriterConfig) //! - `MAX_FLUSH_INTERVAL_MS`: WAL flush interval in milliseconds, 0 to disable (default: 1000ms) -//! - `MAX_MEMTABLE_SIZE`: MemTable size threshold in bytes (default: 64MB from RegionWriterConfig) +//! - `MAX_MEMTABLE_SIZE`: MemTable size threshold in bytes (default: 64MB from ShardWriterConfig) //! - `VECTOR_DIM`: Vector dimension for the vector column (default: 512) //! - `MEMWAL_MAINTAINED_INDEXES`: Comma-separated list of index names to maintain in MemWAL (default: id_btree) //! - Available indexes: id_btree, text_fts, vector_ivfpq (all created on base table) @@ -52,7 +52,7 @@ use arrow_array::{ }; use arrow_schema::{DataType, Field, Schema as ArrowSchema}; use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; -use lance::dataset::mem_wal::{DatasetMemWalExt, MemWalConfig, RegionWriterConfig}; +use lance::dataset::mem_wal::{DatasetMemWalExt, MemWalConfig, ShardWriterConfig}; use lance::dataset::{Dataset, WriteParams}; use lance::index::DatasetIndexExt; use lance::index::vector::VectorIndexParams; @@ -393,7 +393,7 @@ async fn create_dataset( // Initialize MemWAL with specified maintained indexes dataset .initialize_mem_wal(MemWalConfig { - region_spec: None, + shard_spec: None, maintained_indexes: maintained_indexes.to_vec(), }) .await @@ -479,7 +479,7 @@ fn bench_lance_memwal_write(c: &mut Criterion) { let total_bytes = (total_rows * row_size_bytes) as u64; // Get effective config values for display - let default_config = RegionWriterConfig::default(); + let default_config = ShardWriterConfig::default(); let effective_wal_buffer = max_wal_buffer_size.unwrap_or(default_config.max_wal_buffer_size); let effective_flush_interval = max_flush_interval.unwrap_or(default_config.max_wal_flush_interval); @@ -530,7 +530,7 @@ fn bench_lance_memwal_write(c: &mut Criterion) { let name_prefix = build_name_prefix(durable, indexed); // Create dataset ONCE before benchmark iterations - // Each iteration will use a different region on the same dataset + // Each iteration will use a different shard on the same dataset let dataset = rt.block_on(create_dataset( &schema, &name_prefix, @@ -572,12 +572,12 @@ fn bench_lance_memwal_write(c: &mut Criterion) { // Re-open dataset (cheap operation) let dataset = Dataset::open(&dataset_uri).await.unwrap(); - // Create a NEW region for each iteration - let region_id = Uuid::new_v4(); - let default_config = RegionWriterConfig::default(); - let config = RegionWriterConfig { - region_id, - region_spec_id: 0, + // Create a NEW shard for each iteration + let shard_id = Uuid::new_v4(); + let default_config = ShardWriterConfig::default(); + let config = ShardWriterConfig { + shard_id, + shard_spec_id: 0, durable_write: durable, sync_indexed_write: indexed, max_wal_buffer_size: max_wal_buffer_size @@ -603,7 +603,7 @@ fn bench_lance_memwal_write(c: &mut Criterion) { // Get writer through Dataset API (index configs loaded automatically) let writer = - dataset.mem_wal_writer(region_id, config).await.unwrap(); + dataset.mem_wal_writer(shard_id, config).await.unwrap(); // Time writes (excluding close to measure pure put throughput) let start = Instant::now(); diff --git a/rust/lance/src/dataset/mem_wal.rs b/rust/lance/src/dataset/mem_wal.rs index 0092385edf7..c207eef7174 100644 --- a/rust/lance/src/dataset/mem_wal.rs +++ b/rust/lance/src/dataset/mem_wal.rs @@ -8,7 +8,7 @@ //! //! ## Architecture //! -//! Each region has: +//! Each shard has: //! - A **MemTable** for in-memory data (immediately queryable) //! - A **WAL Buffer** for durability (persisted to object storage) //! - **In-memory indexes** (BTree, IVF-PQ, FTS) for indexed queries @@ -29,8 +29,8 @@ //! //! ## Epoch-Based Fencing //! -//! Each region has exactly one active writer at any time, enforced via -//! monotonically increasing writer epochs in the region manifest. +//! Each shard has exactly one active writer at any time, enforced via +//! monotonically increasing writer epochs in the shard manifest. mod api; mod index; @@ -42,8 +42,8 @@ mod wal; pub mod write; pub use api::{DatasetMemWalExt, MemWalConfig}; -pub use manifest::RegionManifestStore; +pub use manifest::ShardManifestStore; pub use memtable::scanner::MemTableScanner; -pub use scanner::{LsmDataSource, LsmGeneration, LsmScanner, RegionSnapshot}; -pub use write::RegionWriter; -pub use write::RegionWriterConfig; +pub use scanner::{LsmDataSource, LsmGeneration, LsmScanner, ShardSnapshot}; +pub use write::ShardWriter; +pub use write::ShardWriterConfig; diff --git a/rust/lance/src/dataset/mem_wal/api.rs b/rust/lance/src/dataset/mem_wal/api.rs index 30c6a10811a..83bfa014771 100644 --- a/rust/lance/src/dataset/mem_wal/api.rs +++ b/rust/lance/src/dataset/mem_wal/api.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use crate::index::DatasetIndexExt; use async_trait::async_trait; use lance_core::{Error, Result}; -use lance_index::mem_wal::{MEM_WAL_INDEX_NAME, MemWalIndexDetails, RegionSpec}; +use lance_index::mem_wal::{MEM_WAL_INDEX_NAME, MemWalIndexDetails, ShardSpec}; use lance_index::vector::ivf::storage::IvfModel; use lance_index::vector::pq::ProductQuantizer; use lance_io::object_store::ObjectStore; @@ -24,19 +24,19 @@ use crate::dataset::transaction::{Operation, Transaction}; use crate::index::DatasetIndexInternalExt; use crate::index::mem_wal::new_mem_wal_index_meta; -use super::RegionWriterConfig; +use super::ShardWriterConfig; use super::write::MemIndexConfig; -use super::write::RegionWriter; +use super::write::ShardWriter; /// Configuration for initializing MemWAL on a Dataset. #[derive(Debug, Clone, Default)] pub struct MemWalConfig { - /// Optional region specification for partitioning writes. + /// Optional shard specification for partitioning writes. /// - /// If None, MemWAL is initialized without any region spec (manual region management). + /// If None, MemWAL is initialized without any shard spec (manual shard management). /// - /// TODO: Add `add_region_spec()` API to add region specs after initialization. - pub region_spec: Option, + /// TODO: Add `add_shard_spec()` API to add shard specs after initialization. + pub shard_spec: Option, /// Index names to maintain in MemTables. /// These must reference indexes already defined on the base table. pub maintained_indexes: Vec, @@ -55,20 +55,20 @@ pub trait DatasetMemWalExt { /// ```ignore /// let mut dataset = Dataset::open("s3://bucket/dataset").await?; /// dataset.initialize_mem_wal(MemWalConfig { - /// region_specs: vec![], + /// shard_specs: vec![], /// maintained_indexes: vec!["id_btree".to_string()], /// }).await?; /// ``` async fn initialize_mem_wal(&mut self, config: MemWalConfig) -> Result<()>; - /// Get a RegionWriter for the specified region. + /// Get a ShardWriter for the specified shard. /// /// Automatically loads index configurations from the MemWalIndex /// and creates the appropriate in-memory indexes. /// /// # Arguments /// - /// * `region_id` - UUID identifying this region + /// * `shard_id` - UUID identifying this shard /// * `config` - Writer configuration (durability, buffer sizes, etc.) /// /// # Example @@ -76,15 +76,15 @@ pub trait DatasetMemWalExt { /// ```ignore /// let writer = dataset.mem_wal_writer( /// Uuid::new_v4(), - /// RegionWriterConfig::default(), + /// ShardWriterConfig::default(), /// ).await?; /// writer.put(vec![batch1, batch2]).await?; /// ``` async fn mem_wal_writer( &self, - region_id: Uuid, - config: RegionWriterConfig, - ) -> Result; + shard_id: Uuid, + config: ShardWriterConfig, + ) -> Result; } #[async_trait] @@ -119,7 +119,7 @@ impl DatasetMemWalExt for Dataset { // Create MemWalIndexDetails let details = MemWalIndexDetails { - region_specs: config.region_spec.into_iter().collect(), + shard_specs: config.shard_spec.into_iter().collect(), maintained_indexes: config.maintained_indexes, ..Default::default() }; @@ -149,9 +149,9 @@ impl DatasetMemWalExt for Dataset { async fn mem_wal_writer( &self, - region_id: Uuid, - mut config: RegionWriterConfig, - ) -> Result { + shard_id: Uuid, + mut config: ShardWriterConfig, + ) -> Result { use lance_index::metrics::NoOpMetricsCollector; // Load MemWalIndex to get maintained_indexes @@ -214,15 +214,15 @@ impl DatasetMemWalExt for Dataset { }; } - // Set region_id in config - config.region_id = region_id; + // Set shard_id in config + config.shard_id = shard_id; // Get object store and base path let base_uri = self.uri(); let (store, base_path) = ObjectStore::from_uri(base_uri).await?; - // Create RegionWriter - RegionWriter::open( + // Create ShardWriter + ShardWriter::open( store, base_path, base_uri, diff --git a/rust/lance/src/dataset/mem_wal/manifest.rs b/rust/lance/src/dataset/mem_wal/manifest.rs index 6bca29568c2..1aceac60346 100644 --- a/rust/lance/src/dataset/mem_wal/manifest.rs +++ b/rust/lance/src/dataset/mem_wal/manifest.rs @@ -1,15 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -//! Region manifest storage with bit-reversed versioned naming. +//! Shard manifest storage with bit-reversed versioned naming. //! -//! Region manifests are stored as versioned protobuf files using bit-reversed +//! Shard manifests are stored as versioned protobuf files using bit-reversed //! naming scheme to distribute files across object store keyspace. //! //! ## File Layout //! //! ```text -//! _mem_wal/{region_id}/manifest/ +//! _mem_wal/{shard_id}/manifest/ //! ├── {bit_reversed_version}.binpb # Versioned manifest files //! └── version_hint.json # Best-effort version hint //! ``` @@ -33,7 +33,7 @@ use bytes::Bytes; use futures::StreamExt; use futures::stream::FuturesUnordered; use lance_core::{Error, Result}; -use lance_index::mem_wal::RegionManifest; +use lance_index::mem_wal::ShardManifest; use lance_io::object_store::ObjectStore; use lance_table::format::pb; use log::{info, warn}; @@ -44,7 +44,7 @@ use prost::Message; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use super::util::{manifest_filename, parse_bit_reversed_filename, region_manifest_path}; +use super::util::{manifest_filename, parse_bit_reversed_filename, shard_manifest_path}; /// Version hint file structure. #[derive(Debug, Serialize, Deserialize)] @@ -52,37 +52,37 @@ struct VersionHint { version: u64, } -/// Store for reading and writing region manifests. +/// Store for reading and writing shard manifests. /// /// Handles versioned manifest files with bit-reversed naming scheme /// and PUT-IF-NOT-EXISTS atomicity. #[derive(Debug)] -pub struct RegionManifestStore { +pub struct ShardManifestStore { object_store: Arc, - region_id: Uuid, + shard_id: Uuid, manifest_dir: Path, manifest_scan_batch_size: usize, } -impl RegionManifestStore { - /// Create a new manifest store for the given region. +impl ShardManifestStore { + /// Create a new manifest store for the given shard. /// /// # Arguments /// /// * `object_store` - Object store for reading/writing manifests /// * `base_path` - Base path within the object store (from ObjectStore::from_uri) - /// * `region_id` - Region UUID + /// * `shard_id` - Shard UUID /// * `manifest_scan_batch_size` - Batch size for parallel HEAD requests when scanning versions pub fn new( object_store: Arc, base_path: &Path, - region_id: Uuid, + shard_id: Uuid, manifest_scan_batch_size: usize, ) -> Self { - let manifest_dir = region_manifest_path(base_path, ®ion_id); + let manifest_dir = shard_manifest_path(base_path, &shard_id); Self { object_store, - region_id, + shard_id, manifest_dir, manifest_scan_batch_size, } @@ -90,8 +90,8 @@ impl RegionManifestStore { /// Read the latest manifest version. /// - /// Returns `None` if no manifest exists (new region). - pub async fn read_latest(&self) -> Result> { + /// Returns `None` if no manifest exists (new shard). + pub async fn read_latest(&self) -> Result> { let version = self.find_latest_version().await?; if version == 0 { return Ok(None); @@ -101,14 +101,14 @@ impl RegionManifestStore { } /// Read a specific manifest version. - pub async fn read_version(&self, version: u64) -> Result { + pub async fn read_version(&self, version: u64) -> Result { let filename = manifest_filename(version); let path = self.manifest_dir.child(filename.as_str()); let data = self.object_store.inner.get(&path).await.map_err(|e| { Error::io(format!( - "Failed to read manifest version {} for region {}: {}", - version, self.region_id, e + "Failed to read manifest version {} for shard {}: {}", + version, self.shard_id, e )) })?; @@ -117,10 +117,10 @@ impl RegionManifestStore { .await .map_err(|e| Error::io(format!("Failed to read manifest bytes: {}", e)))?; - let pb_manifest = pb::RegionManifest::decode(bytes) + let pb_manifest = pb::ShardManifest::decode(bytes) .map_err(|e| Error::io(format!("Failed to decode manifest protobuf: {}", e)))?; - RegionManifest::try_from(pb_manifest) + ShardManifest::try_from(pb_manifest) } /// Write a new manifest version atomically. @@ -134,12 +134,12 @@ impl RegionManifestStore { /// # Errors /// /// Returns `Error::AlreadyExists` if another writer already wrote this version. - pub async fn write(&self, manifest: &RegionManifest) -> Result { + pub async fn write(&self, manifest: &ShardManifest) -> Result { let version = manifest.version; let filename = manifest_filename(version); let path = self.manifest_dir.child(filename.as_str()); - let pb_manifest = pb::RegionManifest::from(manifest); + let pb_manifest = pb::ShardManifest::from(manifest); let bytes = pb_manifest.encode_to_vec(); if self.object_store.is_local() { @@ -166,16 +166,16 @@ impl RegionManifestStore { // Clean up temp file let _ = self.object_store.delete(&temp_path).await; return Err(Error::io(format!( - "Manifest version {} already exists for region {}", - version, self.region_id + "Manifest version {} already exists for shard {}", + version, self.shard_id ))); } Err(e) => { // Clean up temp file let _ = self.object_store.delete(&temp_path).await; return Err(Error::io(format!( - "Failed to write manifest version {} for region {}: {}", - version, self.region_id, e + "Failed to write manifest version {} for shard {}: {}", + version, self.shard_id, e ))); } } @@ -193,13 +193,13 @@ impl RegionManifestStore { .map_err(|e| { if matches!(e, object_store::Error::AlreadyExists { .. }) { Error::io(format!( - "Manifest version {} already exists for region {}", - version, self.region_id + "Manifest version {} already exists for shard {}", + version, self.shard_id )) } else { Error::io(format!( - "Failed to write manifest version {} for region {}: {}", - version, self.region_id, e + "Failed to write manifest version {} for shard {}: {}", + version, self.shard_id, e )) } })?; @@ -299,8 +299,8 @@ impl RegionManifestStore { .await { warn!( - "Failed to write version hint for region {}: {}", - self.region_id, e + "Failed to write version hint for shard {}: {}", + self.shard_id, e ); } } @@ -341,35 +341,35 @@ impl RegionManifestStore { Ok(versions) } - /// Get the region ID. - pub fn region_id(&self) -> Uuid { - self.region_id + /// Get the shard ID. + pub fn shard_id(&self) -> Uuid { + self.shard_id } // ======================================================================== // Epoch-based Writer Fencing // ======================================================================== - /// Claim a region by incrementing its writer epoch. + /// Claim a shard by incrementing its writer epoch. /// /// This establishes single-writer semantics by: /// 1. Loading the current manifest (or creating initial state) /// 2. Incrementing the writer epoch /// 3. Atomically writing the new manifest /// - /// If another writer has already claimed the region (version conflict), + /// If another writer has already claimed the shard (version conflict), /// this fails immediately rather than retrying. This prevents "epoch wars" /// where multiple writers keep fencing each other. /// /// # Returns /// - /// A tuple of `(epoch, RegionManifest)` where the manifest is the + /// A tuple of `(epoch, ShardManifest)` where the manifest is the /// claimed state (may be freshly created or loaded and epoch-bumped). /// /// # Errors /// - /// Returns an error if another writer already claimed the region. - pub async fn claim_epoch(&self, region_spec_id: u32) -> Result<(u64, RegionManifest)> { + /// Returns an error if another writer already claimed the shard. + pub async fn claim_epoch(&self, shard_spec_id: u32) -> Result<(u64, ShardManifest)> { let current = self.read_latest().await?; let (next_version, next_epoch, base_manifest) = match current { @@ -378,16 +378,16 @@ impl RegionManifestStore { }; let new_manifest = if let Some(base) = base_manifest { - RegionManifest { + ShardManifest { version: next_version, writer_epoch: next_epoch, ..base } } else { - RegionManifest { - region_id: self.region_id, + ShardManifest { + shard_id: self.shard_id, version: next_version, - region_spec_id, + shard_spec_id, writer_epoch: next_epoch, replay_after_wal_entry_position: 0, wal_entry_position_last_seen: 0, @@ -398,14 +398,14 @@ impl RegionManifestStore { self.write(&new_manifest).await.map_err(|e| { Error::io(format!( - "Failed to claim region {} (version {}): another writer may have claimed it: {}", - self.region_id, next_version, e + "Failed to claim shard {} (version {}): another writer may have claimed it: {}", + self.shard_id, next_version, e )) })?; info!( - "Claimed region {} with epoch {} (version {})", - self.region_id, next_epoch, next_version + "Claimed shard {} with epoch {} (version {})", + self.shard_id, next_epoch, next_version ); Ok((next_epoch, new_manifest)) @@ -417,19 +417,19 @@ impl RegionManifestStore { /// is higher than the local epoch, the writer has been fenced. pub async fn check_fenced(&self, local_epoch: u64) -> Result<()> { let current = self.read_latest().await?; - Self::check_fenced_against(¤t, local_epoch, self.region_id) + Self::check_fenced_against(¤t, local_epoch, self.shard_id) } /// Check fencing against a pre-read manifest (avoids redundant read). fn check_fenced_against( - manifest: &Option, + manifest: &Option, local_epoch: u64, - region_id: Uuid, + shard_id: Uuid, ) -> Result<()> { match manifest { Some(m) if m.writer_epoch > local_epoch => Err(Error::io(format!( - "Writer fenced: local epoch {} < stored epoch {} for region {}", - local_epoch, m.writer_epoch, region_id + "Writer fenced: local epoch {} < stored epoch {} for shard {}", + local_epoch, m.writer_epoch, shard_id ))), _ => Ok(()), } @@ -452,9 +452,9 @@ impl RegionManifestStore { /// # Returns /// /// The successfully written manifest. - pub async fn commit_update(&self, local_epoch: u64, prepare_fn: F) -> Result + pub async fn commit_update(&self, local_epoch: u64, prepare_fn: F) -> Result where - F: Fn(&RegionManifest) -> RegionManifest, + F: Fn(&ShardManifest) -> ShardManifest, { const MAX_RETRIES: usize = 10; @@ -463,10 +463,10 @@ impl RegionManifestStore { let current = self .read_latest() .await? - .ok_or_else(|| Error::io("Region manifest not found"))?; + .ok_or_else(|| Error::io("Shard manifest not found"))?; // Step 2: Check fencing - Self::check_fenced_against(&Some(current.clone()), local_epoch, self.region_id)?; + Self::check_fenced_against(&Some(current.clone()), local_epoch, self.shard_id)?; // Step 3: Prepare new manifest let new_manifest = prepare_fn(¤t); @@ -498,8 +498,8 @@ impl RegionManifestStore { } Err(Error::io(format!( - "Failed to update manifest for region {} after {} attempts", - self.region_id, MAX_RETRIES + "Failed to update manifest for shard {} after {} attempts", + self.shard_id, MAX_RETRIES ))) } } @@ -516,11 +516,11 @@ mod tests { (store, path, temp_dir) } - fn create_test_manifest(region_id: Uuid, version: u64, epoch: u64) -> RegionManifest { - RegionManifest { - region_id, + fn create_test_manifest(shard_id: Uuid, version: u64, epoch: u64) -> ShardManifest { + ShardManifest { + shard_id, version, - region_spec_id: 0, + shard_spec_id: 0, writer_epoch: epoch, replay_after_wal_entry_position: 0, wal_entry_position_last_seen: 0, @@ -532,8 +532,8 @@ mod tests { #[tokio::test] async fn test_read_latest_empty() { let (store, base_path, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = RegionManifestStore::new(store, &base_path, region_id, 2); + let shard_id = Uuid::new_v4(); + let manifest_store = ShardManifestStore::new(store, &base_path, shard_id, 2); let result = manifest_store.read_latest().await.unwrap(); assert!(result.is_none()); @@ -542,27 +542,27 @@ mod tests { #[tokio::test] async fn test_write_and_read_manifest() { let (store, base_path, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = RegionManifestStore::new(store, &base_path, region_id, 2); + let shard_id = Uuid::new_v4(); + let manifest_store = ShardManifestStore::new(store, &base_path, shard_id, 2); - let manifest = create_test_manifest(region_id, 1, 1); + let manifest = create_test_manifest(shard_id, 1, 1); manifest_store.write(&manifest).await.unwrap(); let loaded = manifest_store.read_latest().await.unwrap().unwrap(); assert_eq!(loaded.version, 1); assert_eq!(loaded.writer_epoch, 1); - assert_eq!(loaded.region_id, region_id); + assert_eq!(loaded.shard_id, shard_id); } #[tokio::test] async fn test_multiple_versions() { let (store, base_path, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = RegionManifestStore::new(store, &base_path, region_id, 2); + let shard_id = Uuid::new_v4(); + let manifest_store = ShardManifestStore::new(store, &base_path, shard_id, 2); // Write multiple versions for version in 1..=5 { - let manifest = create_test_manifest(region_id, version, version); + let manifest = create_test_manifest(shard_id, version, version); manifest_store.write(&manifest).await.unwrap(); } @@ -579,11 +579,11 @@ mod tests { #[tokio::test] async fn test_read_specific_version() { let (store, base_path, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = RegionManifestStore::new(store, &base_path, region_id, 2); + let shard_id = Uuid::new_v4(); + let manifest_store = ShardManifestStore::new(store, &base_path, shard_id, 2); for version in 1..=3 { - let manifest = create_test_manifest(region_id, version, version * 10); + let manifest = create_test_manifest(shard_id, version, version * 10); manifest_store.write(&manifest).await.unwrap(); } @@ -595,14 +595,14 @@ mod tests { #[tokio::test] async fn test_put_if_not_exists() { let (store, base_path, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = RegionManifestStore::new(store, &base_path, region_id, 2); + let shard_id = Uuid::new_v4(); + let manifest_store = ShardManifestStore::new(store, &base_path, shard_id, 2); - let manifest1 = create_test_manifest(region_id, 1, 1); + let manifest1 = create_test_manifest(shard_id, 1, 1); manifest_store.write(&manifest1).await.unwrap(); // Second write to same version should fail - let manifest2 = create_test_manifest(region_id, 1, 2); + let manifest2 = create_test_manifest(shard_id, 1, 2); let result = manifest_store.write(&manifest2).await; assert!(result.is_err()); } diff --git a/rust/lance/src/dataset/mem_wal/memtable/flush.rs b/rust/lance/src/dataset/mem_wal/memtable/flush.rs index 9ff133413ba..4a46ed062c0 100644 --- a/rust/lance/src/dataset/mem_wal/memtable/flush.rs +++ b/rust/lance/src/dataset/mem_wal/memtable/flush.rs @@ -9,7 +9,7 @@ use bytes::Bytes; use lance_core::cache::LanceCache; use lance_core::{Error, Result}; use lance_index::IndexType; -use lance_index::mem_wal::{FlushedGeneration, RegionManifest}; +use lance_index::mem_wal::{FlushedGeneration, ShardManifest}; use lance_index::scalar::{IndexStore, ScalarIndexParams}; use lance_io::object_store::ObjectStore; use lance_table::format::IndexMetadata; @@ -20,7 +20,7 @@ use uuid::Uuid; use super::super::index::MemIndexConfig; use super::super::memtable::MemTable; use crate::Dataset; -use crate::dataset::mem_wal::manifest::RegionManifestStore; +use crate::dataset::mem_wal::manifest::ShardManifestStore; use crate::dataset::mem_wal::util::{flushed_memtable_path, generate_random_hash}; #[derive(Debug, Clone)] @@ -34,8 +34,8 @@ pub struct MemTableFlusher { object_store: Arc, base_path: Path, base_uri: String, - region_id: Uuid, - manifest_store: Arc, + shard_id: Uuid, + manifest_store: Arc, } impl MemTableFlusher { @@ -43,14 +43,14 @@ impl MemTableFlusher { object_store: Arc, base_path: Path, base_uri: impl Into, - region_id: Uuid, - manifest_store: Arc, + shard_id: Uuid, + manifest_store: Arc, ) -> Self { Self { object_store, base_path, base_uri: base_uri.into(), - region_id, + shard_id, manifest_store, } } @@ -94,7 +94,7 @@ impl MemTableFlusher { let generation = memtable.generation(); let gen_folder_name = format!("{}_gen_{}", random_hash, generation); let gen_path = - flushed_memtable_path(&self.base_path, &self.region_id, &random_hash, generation); + flushed_memtable_path(&self.base_path, &self.shard_id, &random_hash, generation); info!( "Flushing MemTable generation {} to {} ({} rows, {} batches)", @@ -116,8 +116,8 @@ impl MemTableFlusher { .await?; info!( - "Flushed generation {} for region {} (manifest version {})", - generation, self.region_id, new_manifest.version + "Flushed generation {} for shard {} (manifest version {})", + generation, self.shard_id, new_manifest.version ); Ok(FlushResult { @@ -202,7 +202,7 @@ impl MemTableFlusher { let generation = memtable.generation(); let gen_folder_name = format!("{}_gen_{}", random_hash, generation); let gen_path = - flushed_memtable_path(&self.base_path, &self.region_id, &random_hash, generation); + flushed_memtable_path(&self.base_path, &self.shard_id, &random_hash, generation); info!( "Flushing MemTable generation {} with indexes to {} ({} rows, {} batches)", @@ -287,8 +287,8 @@ impl MemTableFlusher { .await?; info!( - "Flushed generation {} for region {} (manifest version {})", - generation, self.region_id, new_manifest.version + "Flushed generation {} for shard {} (manifest version {})", + generation, self.shard_id, new_manifest.version ); Ok(FlushResult { @@ -726,14 +726,14 @@ impl MemTableFlusher { Ok(index_meta) } - /// Update the region manifest with the new flushed generation. + /// Update the shard manifest with the new flushed generation. async fn update_manifest( &self, epoch: u64, generation: u64, gen_path: &str, covered_wal_entry_position: u64, - ) -> Result { + ) -> Result { let gen_path = gen_path.to_string(); self.manifest_store @@ -744,7 +744,7 @@ impl MemTableFlusher { path: gen_path.clone(), }); - RegionManifest { + ShardManifest { version: current.version + 1, replay_after_wal_entry_position: covered_wal_entry_position, wal_entry_position_last_seen: current @@ -856,15 +856,15 @@ mod tests { #[tokio::test] async fn test_flusher_requires_wal_flush() { let (store, base_path, base_uri, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = Arc::new(RegionManifestStore::new( + let shard_id = Uuid::new_v4(); + let manifest_store = Arc::new(ShardManifestStore::new( store.clone(), &base_path, - region_id, + shard_id, 2, )); - // Claim region + // Claim shard let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap(); let schema = create_test_schema(); @@ -877,7 +877,7 @@ mod tests { // Not flushed to WAL yet assert!(!memtable.all_flushed_to_wal()); - let flusher = MemTableFlusher::new(store, base_path, base_uri, region_id, manifest_store); + let flusher = MemTableFlusher::new(store, base_path, base_uri, shard_id, manifest_store); let result = flusher.flush(&memtable, epoch).await; assert!(result.is_err()); @@ -892,21 +892,21 @@ mod tests { #[tokio::test] async fn test_flusher_empty_memtable() { let (store, base_path, base_uri, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = Arc::new(RegionManifestStore::new( + let shard_id = Uuid::new_v4(); + let manifest_store = Arc::new(ShardManifestStore::new( store.clone(), &base_path, - region_id, + shard_id, 2, )); - // Claim region + // Claim shard let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap(); let schema = create_test_schema(); let memtable = MemTable::new(schema, 1, vec![]).unwrap(); - let flusher = MemTableFlusher::new(store, base_path, base_uri, region_id, manifest_store); + let flusher = MemTableFlusher::new(store, base_path, base_uri, shard_id, manifest_store); let result = flusher.flush(&memtable, epoch).await; assert!(result.is_err()); @@ -916,15 +916,15 @@ mod tests { #[tokio::test] async fn test_flusher_success() { let (store, base_path, base_uri, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = Arc::new(RegionManifestStore::new( + let shard_id = Uuid::new_v4(); + let manifest_store = Arc::new(ShardManifestStore::new( store.clone(), &base_path, - region_id, + shard_id, 2, )); - // Claim region + // Claim shard let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap(); let schema = create_test_schema(); @@ -942,7 +942,7 @@ mod tests { store.clone(), base_path, base_uri, - region_id, + shard_id, manifest_store.clone(), ); let result = flusher.flush(&memtable, epoch).await.unwrap(); @@ -965,15 +965,15 @@ mod tests { use crate::index::DatasetIndexExt; let (store, base_path, base_uri, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = Arc::new(RegionManifestStore::new( + let shard_id = Uuid::new_v4(); + let manifest_store = Arc::new(ShardManifestStore::new( store.clone(), &base_path, - region_id, + shard_id, 2, )); - // Claim region + // Claim shard let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap(); // Create index config for the 'id' column (field_id = 0) @@ -1002,7 +1002,7 @@ mod tests { store.clone(), base_path.clone(), base_uri.clone(), - region_id, + shard_id, manifest_store.clone(), ); let result = flusher @@ -1017,7 +1017,7 @@ mod tests { // result.generation.path is just the folder name, construct full URI let gen_uri = format!( "{}/_mem_wal/{}/{}", - base_uri, region_id, result.generation.path + base_uri, shard_id, result.generation.path ); let dataset = Dataset::open(&gen_uri).await.unwrap(); let indices = dataset.load_indices().await.unwrap(); @@ -1069,15 +1069,15 @@ mod tests { use lance_linalg::distance::DistanceType; let (store, base_path, base_uri, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = Arc::new(RegionManifestStore::new( + let shard_id = Uuid::new_v4(); + let manifest_store = Arc::new(ShardManifestStore::new( store.clone(), &base_path, - region_id, + shard_id, 2, )); - // Claim region + // Claim shard let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap(); // Create schema with vector column @@ -1188,7 +1188,7 @@ mod tests { store.clone(), base_path.clone(), base_uri.clone(), - region_id, + shard_id, manifest_store.clone(), ); let result = flusher @@ -1202,7 +1202,7 @@ mod tests { // Verify the flushed dataset has the IVF-PQ index let gen_uri = format!( "{}/_mem_wal/{}/{}", - base_uri, region_id, result.generation.path + base_uri, shard_id, result.generation.path ); let dataset = Dataset::open(&gen_uri).await.unwrap(); let indices = dataset.load_indices().await.unwrap(); @@ -1290,15 +1290,15 @@ mod tests { use std::sync::Arc; let (store, base_path, base_uri, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let manifest_store = Arc::new(RegionManifestStore::new( + let shard_id = Uuid::new_v4(); + let manifest_store = Arc::new(ShardManifestStore::new( store.clone(), &base_path, - region_id, + shard_id, 2, )); - // Claim region + // Claim shard let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap(); // Create schema with text column @@ -1343,7 +1343,7 @@ mod tests { store.clone(), base_path.clone(), base_uri.clone(), - region_id, + shard_id, manifest_store.clone(), ); let result = flusher @@ -1357,7 +1357,7 @@ mod tests { // Verify the flushed dataset has the FTS index let gen_uri = format!( "{}/_mem_wal/{}/{}", - base_uri, region_id, result.generation.path + base_uri, shard_id, result.generation.path ); let dataset = Dataset::open(&gen_uri).await.unwrap(); let indices = dataset.load_indices().await.unwrap(); diff --git a/rust/lance/src/dataset/mem_wal/scanner.rs b/rust/lance/src/dataset/mem_wal/scanner.rs index a6adb9f75e1..2d7daf44e5a 100644 --- a/rust/lance/src/dataset/mem_wal/scanner.rs +++ b/rust/lance/src/dataset/mem_wal/scanner.rs @@ -23,7 +23,7 @@ //! ```ignore //! use lance::dataset::mem_wal::scanner::LsmScanner; //! -//! let scanner = LsmScanner::new(base_table, region_snapshots, vec!["pk".to_string()]) +//! let scanner = LsmScanner::new(base_table, shard_snapshots, vec!["pk".to_string()]) //! .project(&["id", "name"]) //! .filter("id > 10")? //! .limit(100, None); @@ -41,6 +41,6 @@ mod vector_search; pub use builder::LsmScanner; pub use collector::{ActiveMemTableRef, LsmDataSourceCollector}; -pub use data_source::{FlushedGeneration, LsmDataSource, LsmGeneration, RegionSnapshot}; +pub use data_source::{FlushedGeneration, LsmDataSource, LsmGeneration, ShardSnapshot}; pub use point_lookup::LsmPointLookupPlanner; pub use vector_search::{DISTANCE_COLUMN, LsmVectorSearchPlanner}; diff --git a/rust/lance/src/dataset/mem_wal/scanner/builder.rs b/rust/lance/src/dataset/mem_wal/scanner/builder.rs index ddc14c08b33..3168f8f5feb 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/builder.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/builder.rs @@ -16,7 +16,7 @@ use lance_core::{Error, Result}; use uuid::Uuid; use super::collector::{ActiveMemTableRef, LsmDataSourceCollector}; -use super::data_source::RegionSnapshot; +use super::data_source::ShardSnapshot; use super::planner::LsmScanPlanner; use crate::dataset::Dataset; @@ -34,7 +34,7 @@ use crate::dataset::Dataset; /// # Example /// /// ```ignore -/// let scanner = LsmScanner::new(base_table, region_snapshots, vec!["pk".to_string()]) +/// let scanner = LsmScanner::new(base_table, shard_snapshots, vec!["pk".to_string()]) /// .project(&["id", "name"]) /// .filter("id > 10")? /// .limit(100, None); @@ -44,7 +44,7 @@ use crate::dataset::Dataset; pub struct LsmScanner { // Data sources base_table: Arc, - region_snapshots: Vec, + shard_snapshots: Vec, active_memtables: HashMap, // Query configuration @@ -67,16 +67,16 @@ impl LsmScanner { /// # Arguments /// /// * `base_table` - The base Lance table (merged data) - /// * `region_snapshots` - Snapshots of region states from MemWAL index + /// * `shard_snapshots` - Snapshots of shard states from MemWAL index /// * `pk_columns` - Primary key column names for deduplication pub fn new( base_table: Arc, - region_snapshots: Vec, + shard_snapshots: Vec, pk_columns: Vec, ) -> Self { Self { base_table, - region_snapshots, + shard_snapshots, active_memtables: HashMap::new(), projection: None, filter: None, @@ -93,8 +93,8 @@ impl LsmScanner { /// Active MemTables contain data that may not be persisted yet. /// Including them provides strong consistency at the cost of /// requiring coordination with the writer. - pub fn with_active_memtable(mut self, region_id: Uuid, memtable: ActiveMemTableRef) -> Self { - self.active_memtables.insert(region_id, memtable); + pub fn with_active_memtable(mut self, shard_id: Uuid, memtable: ActiveMemTableRef) -> Self { + self.active_memtables.insert(shard_id, memtable); self } @@ -223,10 +223,10 @@ impl LsmScanner { /// Build the data source collector. fn build_collector(&self) -> LsmDataSourceCollector { let mut collector = - LsmDataSourceCollector::new(self.base_table.clone(), self.region_snapshots.clone()); + LsmDataSourceCollector::new(self.base_table.clone(), self.shard_snapshots.clone()); - for (region_id, memtable) in &self.active_memtables { - collector = collector.with_active_memtable(*region_id, memtable.clone()); + for (shard_id, memtable) in &self.active_memtables { + collector = collector.with_active_memtable(*shard_id, memtable.clone()); } collector @@ -237,7 +237,7 @@ impl std::fmt::Debug for LsmScanner { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LsmScanner") .field("base_table", &self.base_table.uri()) - .field("num_regions", &self.region_snapshots.len()) + .field("num_shards", &self.shard_snapshots.len()) .field("num_active_memtables", &self.active_memtables.len()) .field("projection", &self.projection) .field("limit", &self.limit) @@ -257,26 +257,26 @@ mod tests { // Full integration tests would require a real dataset let pk_columns = ["id".to_string()]; - let region_snapshots: Vec = vec![]; + let shard_snapshots: Vec = vec![]; // We can't easily create an Arc without I/O, // so just test the type construction assert_eq!(pk_columns.len(), 1); - assert!(region_snapshots.is_empty()); + assert!(shard_snapshots.is_empty()); } #[test] - fn test_region_snapshot_construction() { - use super::super::data_source::RegionSnapshot; + fn test_shard_snapshot_construction() { + use super::super::data_source::ShardSnapshot; - let region_id = Uuid::new_v4(); - let snapshot = RegionSnapshot::new(region_id) + let shard_id = Uuid::new_v4(); + let snapshot = ShardSnapshot::new(shard_id) .with_spec_id(1) .with_current_generation(5) .with_flushed_generation(1, "path/gen_1".to_string()) .with_flushed_generation(2, "path/gen_2".to_string()); - assert_eq!(snapshot.region_id, region_id); + assert_eq!(snapshot.shard_id, shard_id); assert_eq!(snapshot.spec_id, 1); assert_eq!(snapshot.current_generation, 5); assert_eq!(snapshot.flushed_generations.len(), 2); diff --git a/rust/lance/src/dataset/mem_wal/scanner/collector.rs b/rust/lance/src/dataset/mem_wal/scanner/collector.rs index 90f38b3ea25..80c9bd26aec 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/collector.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/collector.rs @@ -10,7 +10,7 @@ use arrow_schema::SchemaRef; use lance_core::Result; use uuid::Uuid; -use super::data_source::{LsmDataSource, LsmGeneration, RegionSnapshot}; +use super::data_source::{LsmDataSource, LsmGeneration, ShardSnapshot}; use crate::dataset::Dataset; use crate::dataset::mem_wal::write::{BatchStore, IndexStore}; @@ -27,39 +27,39 @@ pub struct ActiveMemTableRef { pub generation: u64, } -/// Collects data sources from base table and MemWAL regions. +/// Collects data sources from base table and MemWAL shards. /// /// This collector gathers all data sources that need to be scanned /// for a query, including: /// - The base table (merged data) -/// - Flushed MemTables from each region +/// - Flushed MemTables from each shard /// - Active MemTables (optional, for strong consistency) pub struct LsmDataSourceCollector { /// Base Lance table. base_table: Arc, /// Base path for resolving relative paths. base_path: String, - /// Region snapshots from MemWAL index. - region_snapshots: Vec, - /// Active MemTables by region (for strong consistency). + /// Shard snapshots from MemWAL index. + shard_snapshots: Vec, + /// Active MemTables by shard (for strong consistency). active_memtables: HashMap, } impl LsmDataSourceCollector { - /// Create a new collector from base table and region snapshots. + /// Create a new collector from base table and shard snapshots. /// /// # Arguments /// /// * `base_table` - The base Lance table (merged data) - /// * `region_snapshots` - Snapshots of region states from MemWAL index - pub fn new(base_table: Arc, region_snapshots: Vec) -> Self { + /// * `shard_snapshots` - Snapshots of shard states from MemWAL index + pub fn new(base_table: Arc, shard_snapshots: Vec) -> Self { // Use the dataset's URI as base path for resolving relative paths. // This ensures memory:// and other scheme-based URIs work correctly. let base_path = base_table.uri().trim_end_matches('/').to_string(); Self { base_table, base_path, - region_snapshots, + shard_snapshots, active_memtables: HashMap::new(), } } @@ -69,8 +69,8 @@ impl LsmDataSourceCollector { /// Active MemTables contain data that may not be persisted yet. /// Including them provides strong consistency at the cost of /// requiring coordination with the writer. - pub fn with_active_memtable(mut self, region_id: Uuid, memtable: ActiveMemTableRef) -> Self { - self.active_memtables.insert(region_id, memtable); + pub fn with_active_memtable(mut self, shard_id: Uuid, memtable: ActiveMemTableRef) -> Self { + self.active_memtables.insert(shard_id, memtable); self } @@ -79,9 +79,9 @@ impl LsmDataSourceCollector { &self.base_table } - /// Get all region snapshots. - pub fn region_snapshots(&self) -> &[RegionSnapshot] { - &self.region_snapshots + /// Get all shard snapshots. + pub fn shard_snapshots(&self) -> &[ShardSnapshot] { + &self.shard_snapshots } /// Get active MemTables. @@ -93,8 +93,8 @@ impl LsmDataSourceCollector { /// /// Returns sources in a consistent order: /// 1. Base table (gen=0) - /// 2. Flushed MemTables per region, ordered by generation - /// 3. Active MemTables per region + /// 2. Flushed MemTables per shard, ordered by generation + /// 3. Active MemTables per shard pub fn collect(&self) -> Result> { let mut sources = Vec::new(); @@ -103,25 +103,25 @@ impl LsmDataSourceCollector { dataset: self.base_table.clone(), }); - // 2. Add flushed MemTables from each region - for snapshot in &self.region_snapshots { + // 2. Add flushed MemTables from each shard + for snapshot in &self.shard_snapshots { for flushed in &snapshot.flushed_generations { - let path = self.resolve_flushed_path(&snapshot.region_id, &flushed.path); + let path = self.resolve_flushed_path(&snapshot.shard_id, &flushed.path); sources.push(LsmDataSource::FlushedMemTable { path, - region_id: snapshot.region_id, + shard_id: snapshot.shard_id, generation: LsmGeneration::memtable(flushed.generation), }); } } // 3. Add active MemTables - for (region_id, memtable) in &self.active_memtables { + for (shard_id, memtable) in &self.active_memtables { sources.push(LsmDataSource::ActiveMemTable { batch_store: memtable.batch_store.clone(), index_store: memtable.index_store.clone(), schema: memtable.schema.clone(), - region_id: *region_id, + shard_id: *shard_id, generation: LsmGeneration::memtable(memtable.generation), }); } @@ -129,40 +129,40 @@ impl LsmDataSourceCollector { Ok(sources) } - /// Collect data sources for specific regions only. + /// Collect data sources for specific shards only. /// - /// This is used after region pruning to avoid loading data from - /// regions that cannot contain matching rows. + /// This is used after shard pruning to avoid loading data from + /// shards that cannot contain matching rows. /// /// The base table is always included since it may contain data - /// from any region (after merging). - pub fn collect_for_regions(&self, region_ids: &HashSet) -> Result> { + /// from any shard (after merging). + pub fn collect_for_shards(&self, shard_ids: &HashSet) -> Result> { let mut sources = Vec::new(); - // Base table is always included (contains merged data from all regions) + // Base table is always included (contains merged data from all shards) sources.push(LsmDataSource::BaseTable { dataset: self.base_table.clone(), }); - // Filter flushed MemTables by region - for snapshot in &self.region_snapshots { - if !region_ids.contains(&snapshot.region_id) { + // Filter flushed MemTables by shard + for snapshot in &self.shard_snapshots { + if !shard_ids.contains(&snapshot.shard_id) { continue; } for flushed in &snapshot.flushed_generations { - let path = self.resolve_flushed_path(&snapshot.region_id, &flushed.path); + let path = self.resolve_flushed_path(&snapshot.shard_id, &flushed.path); sources.push(LsmDataSource::FlushedMemTable { path, - region_id: snapshot.region_id, + shard_id: snapshot.shard_id, generation: LsmGeneration::memtable(flushed.generation), }); } } - // Filter active MemTables by region - for (region_id, memtable) in &self.active_memtables { - if !region_ids.contains(region_id) { + // Filter active MemTables by shard + for (shard_id, memtable) in &self.active_memtables { + if !shard_ids.contains(shard_id) { continue; } @@ -170,7 +170,7 @@ impl LsmDataSourceCollector { batch_store: memtable.batch_store.clone(), index_store: memtable.index_store.clone(), schema: memtable.schema.clone(), - region_id: *region_id, + shard_id: *shard_id, generation: LsmGeneration::memtable(memtable.generation), }); } @@ -181,7 +181,7 @@ impl LsmDataSourceCollector { /// Get the total number of data sources. pub fn num_sources(&self) -> usize { let flushed_count: usize = self - .region_snapshots + .shard_snapshots .iter() .map(|s| s.flushed_generations.len()) .sum(); @@ -190,10 +190,10 @@ impl LsmDataSourceCollector { /// Resolve a flushed MemTable path to an absolute path. /// - /// Flushed MemTables are stored at: `{base_path}/_mem_wal/{region_id}/{folder_name}` + /// Flushed MemTables are stored at: `{base_path}/_mem_wal/{shard_id}/{folder_name}` /// The `folder_name` is what's stored in `FlushedGeneration.path`. - fn resolve_flushed_path(&self, region_id: &Uuid, folder_name: &str) -> String { - format!("{}/_mem_wal/{}/{}", self.base_path, region_id, folder_name) + fn resolve_flushed_path(&self, shard_id: &Uuid, folder_name: &str) -> String { + format!("{}/_mem_wal/{}/{}", self.base_path, shard_id, folder_name) } } @@ -202,13 +202,13 @@ mod tests { use super::*; use crate::dataset::mem_wal::scanner::data_source::FlushedGeneration; - fn create_test_snapshots() -> Vec { - let region_a = Uuid::new_v4(); - let region_b = Uuid::new_v4(); + fn create_test_snapshots() -> Vec { + let shard_a = Uuid::new_v4(); + let shard_b = Uuid::new_v4(); vec![ - RegionSnapshot { - region_id: region_a, + ShardSnapshot { + shard_id: shard_a, spec_id: 1, current_generation: 3, flushed_generations: vec![ @@ -222,8 +222,8 @@ mod tests { }, ], }, - RegionSnapshot { - region_id: region_b, + ShardSnapshot { + shard_id: shard_b, spec_id: 1, current_generation: 2, flushed_generations: vec![FlushedGeneration { @@ -237,7 +237,7 @@ mod tests { #[test] fn test_collector_num_sources() { let snapshots = create_test_snapshots(); - // 1 base table + 2 flushed from region_a + 1 flushed from region_b = 4 + // 1 base table + 2 flushed from shard_a + 1 flushed from shard_b = 4 // Using a mock dataset is complex, so we just test the counting logic assert_eq!(snapshots[0].flushed_generations.len(), 2); assert_eq!(snapshots[1].flushed_generations.len(), 1); diff --git a/rust/lance/src/dataset/mem_wal/scanner/data_source.rs b/rust/lance/src/dataset/mem_wal/scanner/data_source.rs index f94b5199dc5..1a6207f27e3 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/data_source.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/data_source.rs @@ -79,15 +79,15 @@ pub struct FlushedGeneration { pub path: String, } -/// Snapshot of a region's state at a point in time. +/// Snapshot of a shard's state at a point in time. /// /// This is read from the MemWAL index for eventual consistency, -/// or from region manifests directly for strong consistency. +/// or from shard manifests directly for strong consistency. #[derive(Debug, Clone)] -pub struct RegionSnapshot { - /// Region UUID. - pub region_id: Uuid, - /// Region spec ID (0 if manual region). +pub struct ShardSnapshot { + /// Shard UUID. + pub shard_id: Uuid, + /// Shard spec ID (0 if manual shard). pub spec_id: u32, /// Current generation being written (next flush will be this generation). pub current_generation: u64, @@ -95,11 +95,11 @@ pub struct RegionSnapshot { pub flushed_generations: Vec, } -impl RegionSnapshot { - /// Create a new region snapshot. - pub fn new(region_id: Uuid) -> Self { +impl ShardSnapshot { + /// Create a new shard snapshot. + pub fn new(shard_id: Uuid) -> Self { Self { - region_id, + shard_id, spec_id: 0, current_generation: 1, flushed_generations: Vec::new(), @@ -137,8 +137,8 @@ pub enum LsmDataSource { FlushedMemTable { /// Absolute path to the flushed MemTable directory. path: String, - /// Region this MemTable belongs to. - region_id: Uuid, + /// Shard this MemTable belongs to. + shard_id: Uuid, /// Generation number (1, 2, 3, ...). generation: LsmGeneration, }, @@ -150,8 +150,8 @@ pub enum LsmDataSource { index_store: Arc, /// Schema of the data. schema: SchemaRef, - /// Region this MemTable belongs to. - region_id: Uuid, + /// Shard this MemTable belongs to. + shard_id: Uuid, /// Generation number. generation: LsmGeneration, }, @@ -167,12 +167,12 @@ impl LsmDataSource { } } - /// Get the region ID if this is a regional source. - pub fn region_id(&self) -> Option { + /// Get the shard ID if this is a shard source. + pub fn shard_id(&self) -> Option { match self { Self::BaseTable { .. } => None, - Self::FlushedMemTable { region_id, .. } => Some(*region_id), - Self::ActiveMemTable { region_id, .. } => Some(*region_id), + Self::FlushedMemTable { shard_id, .. } => Some(*shard_id), + Self::ActiveMemTable { shard_id, .. } => Some(*shard_id), } } @@ -191,15 +191,15 @@ impl LsmDataSource { match self { Self::BaseTable { .. } => "base_table".to_string(), Self::FlushedMemTable { - region_id, + shard_id, generation, .. - } => format!("flushed[{}:{}]", ®ion_id.to_string()[..8], generation), + } => format!("flushed[{}:{}]", &shard_id.to_string()[..8], generation), Self::ActiveMemTable { - region_id, + shard_id, generation, .. - } => format!("memtable[{}:{}]", ®ion_id.to_string()[..8], generation), + } => format!("memtable[{}:{}]", &shard_id.to_string()[..8], generation), } } } @@ -251,15 +251,15 @@ mod tests { } #[test] - fn test_region_snapshot_builder() { - let region_id = Uuid::new_v4(); - let snapshot = RegionSnapshot::new(region_id) + fn test_shard_snapshot_builder() { + let shard_id = Uuid::new_v4(); + let snapshot = ShardSnapshot::new(shard_id) .with_spec_id(1) .with_current_generation(5) .with_flushed_generation(1, "abc123_gen_1".to_string()) .with_flushed_generation(2, "def456_gen_2".to_string()); - assert_eq!(snapshot.region_id, region_id); + assert_eq!(snapshot.shard_id, shard_id); assert_eq!(snapshot.spec_id, 1); assert_eq!(snapshot.current_generation, 5); assert_eq!(snapshot.flushed_generations.len(), 2); diff --git a/rust/lance/src/dataset/mem_wal/scanner/planner.rs b/rust/lance/src/dataset/mem_wal/scanner/planner.rs index f86fc6894d1..d75a304480e 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/planner.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/planner.rs @@ -387,7 +387,7 @@ impl LsmScanPlanner { #[cfg(test)] mod tests { use super::*; - use crate::dataset::mem_wal::scanner::data_source::RegionSnapshot; + use crate::dataset::mem_wal::scanner::data_source::ShardSnapshot; fn create_test_schema() -> SchemaRef { Arc::new(Schema::new(vec![ @@ -422,9 +422,9 @@ mod tests { } #[test] - fn test_region_snapshot() { - let region_id = uuid::Uuid::new_v4(); - let snapshot = RegionSnapshot::new(region_id) + fn test_shard_snapshot() { + let shard_id = uuid::Uuid::new_v4(); + let snapshot = ShardSnapshot::new(shard_id) .with_current_generation(5) .with_flushed_generation(1, "gen_1".to_string()) .with_flushed_generation(2, "gen_2".to_string()); @@ -452,7 +452,7 @@ mod integration_tests { use crate::dataset::mem_wal::scanner::LsmScanner; use crate::dataset::mem_wal::scanner::collector::ActiveMemTableRef; - use crate::dataset::mem_wal::scanner::data_source::RegionSnapshot; + use crate::dataset::mem_wal::scanner::data_source::ShardSnapshot; use crate::dataset::mem_wal::write::{BatchStore, IndexStore}; use crate::dataset::{Dataset, WriteParams}; use crate::utils::test::assert_plan_node_equals; @@ -513,7 +513,7 @@ mod integration_tests { /// - id=7: "active_7" (added in active) async fn setup_multi_level_lsm() -> ( Arc, - Vec, + Vec, Option<(Uuid, ActiveMemTableRef)>, Vec, String, // temp_dir path for cleanup @@ -528,18 +528,18 @@ mod integration_tests { let base_dataset = Arc::new(create_dataset(&base_uri, vec![base_batch]).await); // Create flushed gen1 as a separate dataset - let region_id = Uuid::new_v4(); - let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, region_id); + let shard_id = Uuid::new_v4(); + let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id); let gen1_batch = create_test_batch(&schema, &[3, 4], "gen1"); create_dataset(&gen1_uri, vec![gen1_batch]).await; // Create flushed gen2 as a separate dataset - let gen2_uri = format!("{}/_mem_wal/{}/gen_2", base_uri, region_id); + let gen2_uri = format!("{}/_mem_wal/{}/gen_2", base_uri, shard_id); let gen2_batch = create_test_batch(&schema, &[4, 5, 6], "gen2"); create_dataset(&gen2_uri, vec![gen2_batch]).await; - // Build region snapshot - let region_snapshot = RegionSnapshot::new(region_id) + // Build shard snapshot + let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(3) .with_flushed_generation(1, "gen_1".to_string()) .with_flushed_generation(2, "gen_2".to_string()); @@ -564,8 +564,8 @@ mod integration_tests { ( base_dataset, - vec![region_snapshot], - Some((region_id, active_memtable)), + vec![shard_snapshot], + Some((shard_id, active_memtable)), pk_columns, temp_path, ) @@ -573,13 +573,13 @@ mod integration_tests { #[tokio::test] async fn test_lsm_scan_query_plan_without_memtable_gen() { - let (base_dataset, region_snapshots, active_memtable, pk_columns, _temp_path) = + let (base_dataset, shard_snapshots, active_memtable, pk_columns, _temp_path) = setup_multi_level_lsm().await; // Create scanner without requesting _memtable_gen - let mut scanner = LsmScanner::new(base_dataset, region_snapshots, pk_columns); - if let Some((region_id, memtable)) = active_memtable { - scanner = scanner.with_active_memtable(region_id, memtable); + let mut scanner = LsmScanner::new(base_dataset, shard_snapshots, pk_columns); + if let Some((shard_id, memtable)) = active_memtable { + scanner = scanner.with_active_memtable(shard_id, memtable); } let plan = scanner.create_plan().await.unwrap(); @@ -609,14 +609,14 @@ mod integration_tests { #[tokio::test] async fn test_lsm_scan_query_plan_with_memtable_gen() { - let (base_dataset, region_snapshots, active_memtable, pk_columns, _temp_path) = + let (base_dataset, shard_snapshots, active_memtable, pk_columns, _temp_path) = setup_multi_level_lsm().await; // Create scanner requesting _memtable_gen let mut scanner = - LsmScanner::new(base_dataset, region_snapshots, pk_columns).with_memtable_gen(); - if let Some((region_id, memtable)) = active_memtable { - scanner = scanner.with_active_memtable(region_id, memtable); + LsmScanner::new(base_dataset, shard_snapshots, pk_columns).with_memtable_gen(); + if let Some((shard_id, memtable)) = active_memtable { + scanner = scanner.with_active_memtable(shard_id, memtable); } let plan = scanner.create_plan().await.unwrap(); @@ -654,13 +654,13 @@ mod integration_tests { #[tokio::test] async fn test_lsm_scan_deduplication_results() { - let (base_dataset, region_snapshots, active_memtable, pk_columns, _temp_path) = + let (base_dataset, shard_snapshots, active_memtable, pk_columns, _temp_path) = setup_multi_level_lsm().await; // Create scanner - let mut scanner = LsmScanner::new(base_dataset, region_snapshots, pk_columns); - if let Some((region_id, memtable)) = active_memtable { - scanner = scanner.with_active_memtable(region_id, memtable); + let mut scanner = LsmScanner::new(base_dataset, shard_snapshots, pk_columns); + if let Some((shard_id, memtable)) = active_memtable { + scanner = scanner.with_active_memtable(shard_id, memtable); } // Execute and collect results @@ -714,14 +714,14 @@ mod integration_tests { #[tokio::test] async fn test_lsm_scan_with_projection() { - let (base_dataset, region_snapshots, active_memtable, pk_columns, _temp_path) = + let (base_dataset, shard_snapshots, active_memtable, pk_columns, _temp_path) = setup_multi_level_lsm().await; // Create scanner with projection (only id column) let mut scanner = - LsmScanner::new(base_dataset, region_snapshots, pk_columns).project(&["id"]); - if let Some((region_id, memtable)) = active_memtable { - scanner = scanner.with_active_memtable(region_id, memtable); + LsmScanner::new(base_dataset, shard_snapshots, pk_columns).project(&["id"]); + if let Some((shard_id, memtable)) = active_memtable { + scanner = scanner.with_active_memtable(shard_id, memtable); } // Execute and collect results @@ -745,14 +745,13 @@ mod integration_tests { #[tokio::test] async fn test_lsm_scan_with_limit() { - let (base_dataset, region_snapshots, active_memtable, pk_columns, _temp_path) = + let (base_dataset, shard_snapshots, active_memtable, pk_columns, _temp_path) = setup_multi_level_lsm().await; // Create scanner with limit - let mut scanner = - LsmScanner::new(base_dataset, region_snapshots, pk_columns).limit(3, None); - if let Some((region_id, memtable)) = active_memtable { - scanner = scanner.with_active_memtable(region_id, memtable); + let mut scanner = LsmScanner::new(base_dataset, shard_snapshots, pk_columns).limit(3, None); + if let Some((shard_id, memtable)) = active_memtable { + scanner = scanner.with_active_memtable(shard_id, memtable); } // Execute and collect results @@ -773,7 +772,7 @@ mod integration_tests { async fn test_lsm_scan_base_only() { let (base_dataset, _, _, pk_columns, _temp_path) = setup_multi_level_lsm().await; - // Create scanner with only base table (no region snapshots or active memtable) + // Create scanner with only base table (no shard snapshots or active memtable) let scanner = LsmScanner::new(base_dataset, vec![], pk_columns); let plan = scanner.create_plan().await.unwrap(); @@ -816,11 +815,11 @@ mod integration_tests { #[tokio::test] async fn test_lsm_scan_flushed_only_no_active() { - let (base_dataset, region_snapshots, _, pk_columns, _temp_path) = + let (base_dataset, shard_snapshots, _, pk_columns, _temp_path) = setup_multi_level_lsm().await; // Create scanner with base + flushed (no active memtable) - let scanner = LsmScanner::new(base_dataset, region_snapshots, pk_columns); + let scanner = LsmScanner::new(base_dataset, shard_snapshots, pk_columns); // Execute and collect results let batches: Vec = scanner @@ -867,14 +866,14 @@ mod integration_tests { #[tokio::test] async fn test_lsm_scan_with_row_address() { - let (base_dataset, region_snapshots, active_memtable, pk_columns, _temp_path) = + let (base_dataset, shard_snapshots, active_memtable, pk_columns, _temp_path) = setup_multi_level_lsm().await; // Create scanner requesting _rowaddr let mut scanner = - LsmScanner::new(base_dataset, region_snapshots, pk_columns).with_row_address(); - if let Some((region_id, memtable)) = active_memtable { - scanner = scanner.with_active_memtable(region_id, memtable); + LsmScanner::new(base_dataset, shard_snapshots, pk_columns).with_row_address(); + if let Some((shard_id, memtable)) = active_memtable { + scanner = scanner.with_active_memtable(shard_id, memtable); } let plan = scanner.create_plan().await.unwrap(); @@ -927,15 +926,15 @@ mod integration_tests { #[tokio::test] async fn test_lsm_scan_with_both_memtable_gen_and_row_address() { - let (base_dataset, region_snapshots, active_memtable, pk_columns, _temp_path) = + let (base_dataset, shard_snapshots, active_memtable, pk_columns, _temp_path) = setup_multi_level_lsm().await; // Create scanner requesting both _memtable_gen and _rowaddr - let mut scanner = LsmScanner::new(base_dataset, region_snapshots, pk_columns) + let mut scanner = LsmScanner::new(base_dataset, shard_snapshots, pk_columns) .with_memtable_gen() .with_row_address(); - if let Some((region_id, memtable)) = active_memtable { - scanner = scanner.with_active_memtable(region_id, memtable); + if let Some((shard_id, memtable)) = active_memtable { + scanner = scanner.with_active_memtable(shard_id, memtable); } let plan = scanner.create_plan().await.unwrap(); @@ -971,7 +970,7 @@ mod integration_tests { /// - Flushed datasets have BTree index created (enabling ScalarIndexQuery) async fn setup_multi_level_lsm_with_btree_index() -> ( Arc, - Vec, + Vec, Option<(Uuid, ActiveMemTableRef)>, Vec, String, @@ -999,8 +998,8 @@ mod integration_tests { let base_dataset = Arc::new(Dataset::open(&base_uri).await.unwrap()); // Create flushed gen1 with BTree index - let region_id = Uuid::new_v4(); - let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, region_id); + let shard_id = Uuid::new_v4(); + let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id); let gen1_batch = create_test_batch(&schema, &[3, 4], "gen1"); let mut gen1_dataset = create_dataset(&gen1_uri, vec![gen1_batch]).await; CreateIndexBuilder::new(&mut gen1_dataset, &["id"], IndexType::BTree, ¶ms) @@ -1008,15 +1007,15 @@ mod integration_tests { .unwrap(); // Create flushed gen2 with BTree index - let gen2_uri = format!("{}/_mem_wal/{}/gen_2", base_uri, region_id); + let gen2_uri = format!("{}/_mem_wal/{}/gen_2", base_uri, shard_id); let gen2_batch = create_test_batch(&schema, &[4, 5, 6], "gen2"); let mut gen2_dataset = create_dataset(&gen2_uri, vec![gen2_batch]).await; CreateIndexBuilder::new(&mut gen2_dataset, &["id"], IndexType::BTree, ¶ms) .await .unwrap(); - // Build region snapshot - let region_snapshot = RegionSnapshot::new(region_id) + // Build shard snapshot + let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(3) .with_flushed_generation(1, "gen_1".to_string()) .with_flushed_generation(2, "gen_2".to_string()); @@ -1049,8 +1048,8 @@ mod integration_tests { ( base_dataset, - vec![region_snapshot], - Some((region_id, active_memtable)), + vec![shard_snapshot], + Some((shard_id, active_memtable)), pk_columns, temp_path, ) @@ -1058,15 +1057,15 @@ mod integration_tests { #[tokio::test] async fn test_lsm_scan_with_btree_index_filter() { - let (base_dataset, region_snapshots, active_memtable, pk_columns, _temp_path) = + let (base_dataset, shard_snapshots, active_memtable, pk_columns, _temp_path) = setup_multi_level_lsm_with_btree_index().await; // Create scanner with filter on the indexed column - let mut scanner = LsmScanner::new(base_dataset, region_snapshots, pk_columns) + let mut scanner = LsmScanner::new(base_dataset, shard_snapshots, pk_columns) .filter("id = 5") .unwrap(); - if let Some((region_id, memtable)) = active_memtable { - scanner = scanner.with_active_memtable(region_id, memtable); + if let Some((shard_id, memtable)) = active_memtable { + scanner = scanner.with_active_memtable(shard_id, memtable); } let plan = scanner.create_plan().await.unwrap(); @@ -1149,16 +1148,16 @@ mod integration_tests { #[tokio::test] async fn test_lsm_scan_with_filter_no_index() { // Test that filter still works correctly even without BTree index - let (base_dataset, region_snapshots, active_memtable, pk_columns, _temp_path) = + let (base_dataset, shard_snapshots, active_memtable, pk_columns, _temp_path) = setup_multi_level_lsm().await; // Create scanner with SQL filter // This tests that type coercion works correctly (Int64 literal -> Int32 column) - let mut scanner = LsmScanner::new(base_dataset, region_snapshots, pk_columns) + let mut scanner = LsmScanner::new(base_dataset, shard_snapshots, pk_columns) .filter("id = 3") .unwrap(); - if let Some((region_id, memtable)) = active_memtable { - scanner = scanner.with_active_memtable(region_id, memtable); + if let Some((shard_id, memtable)) = active_memtable { + scanner = scanner.with_active_memtable(shard_id, memtable); } // Execute and verify result diff --git a/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs b/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs index 3cef6a34dd4..fecdb251166 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs @@ -293,7 +293,7 @@ mod tests { use std::collections::HashMap; use uuid::Uuid; - use crate::dataset::mem_wal::scanner::data_source::RegionSnapshot; + use crate::dataset::mem_wal::scanner::data_source::ShardSnapshot; use crate::dataset::{Dataset, WriteParams}; fn create_pk_schema() -> Arc { @@ -374,18 +374,18 @@ mod tests { let base_batch = create_test_batch(&schema, &[1, 2, 3], "base"); let base_dataset = Arc::new(create_dataset(&base_uri, vec![base_batch]).await); - // Create region snapshot - let region_id = Uuid::new_v4(); - let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, region_id); + // Create shard snapshot + let shard_id = Uuid::new_v4(); + let gen1_uri = format!("{}/_mem_wal/{}/gen_1", base_uri, shard_id); let gen1_batch = create_test_batch(&schema, &[2], "gen1"); // Update id=2 create_dataset(&gen1_uri, vec![gen1_batch]).await; - let region_snapshot = RegionSnapshot::new(region_id) + let shard_snapshot = ShardSnapshot::new(shard_id) .with_current_generation(2) .with_flushed_generation(1, "gen_1".to_string()); // Create collector - let collector = LsmDataSourceCollector::new(base_dataset, vec![region_snapshot]); + let collector = LsmDataSourceCollector::new(base_dataset, vec![shard_snapshot]); let planner = LsmPointLookupPlanner::new(collector, vec!["id".to_string()], schema.clone()); diff --git a/rust/lance/src/dataset/mem_wal/util.rs b/rust/lance/src/dataset/mem_wal/util.rs index 1f8eed7bf1c..12bf6f299f5 100644 --- a/rust/lance/src/dataset/mem_wal/util.rs +++ b/rust/lance/src/dataset/mem_wal/util.rs @@ -126,39 +126,39 @@ pub fn parse_bit_reversed_filename(filename: &str) -> Option { Some(bit_reverse_u64(reversed)) } -/// Base path for a region within the MemWAL directory. +/// Base path for a shard within the MemWAL directory. /// -/// Returns: `{base_path}/_mem_wal/{region_id}/` -pub fn region_base_path(base_path: &Path, region_id: &Uuid) -> Path { +/// Returns: `{base_path}/_mem_wal/{shard_id}/` +pub fn shard_base_path(base_path: &Path, shard_id: &Uuid) -> Path { base_path .child("_mem_wal") - .child(region_id.as_hyphenated().to_string()) + .child(shard_id.as_hyphenated().to_string()) } -/// Path to the WAL directory for a region. +/// Path to the WAL directory for a shard. /// -/// Returns: `{base_path}/_mem_wal/{region_id}/wal/` -pub fn region_wal_path(base_path: &Path, region_id: &Uuid) -> Path { - region_base_path(base_path, region_id).child("wal") +/// Returns: `{base_path}/_mem_wal/{shard_id}/wal/` +pub fn shard_wal_path(base_path: &Path, shard_id: &Uuid) -> Path { + shard_base_path(base_path, shard_id).child("wal") } -/// Path to the manifest directory for a region. +/// Path to the manifest directory for a shard. /// -/// Returns: `{base_path}/_mem_wal/{region_id}/manifest/` -pub fn region_manifest_path(base_path: &Path, region_id: &Uuid) -> Path { - region_base_path(base_path, region_id).child("manifest") +/// Returns: `{base_path}/_mem_wal/{shard_id}/manifest/` +pub fn shard_manifest_path(base_path: &Path, shard_id: &Uuid) -> Path { + shard_base_path(base_path, shard_id).child("manifest") } /// Path to a flushed MemTable directory. /// -/// Returns: `{base_path}/_mem_wal/{region_id}/{random_hash}_gen_{generation}/` +/// Returns: `{base_path}/_mem_wal/{shard_id}/{random_hash}_gen_{generation}/` pub fn flushed_memtable_path( base_path: &Path, - region_id: &Uuid, + shard_id: &Uuid, random_hash: &str, generation: u64, ) -> Path { - region_base_path(base_path, region_id).child(format!("{}_gen_{}", random_hash, generation)) + shard_base_path(base_path, shard_id).child(format!("{}_gen_{}", random_hash, generation)) } /// Generate an 8-character random hex string for flushed MemTable directories. @@ -177,7 +177,7 @@ pub fn wal_entry_filename(wal_entry_position: u64) -> String { bit_reversed_filename(wal_entry_position, "arrow") } -/// Region manifest filename. +/// Shard manifest filename. /// /// Returns bit-reversed filename with .binpb extension. pub fn manifest_filename(version: u64) -> String { @@ -241,34 +241,34 @@ mod tests { } #[test] - fn test_region_paths() { + fn test_shard_paths() { let base_path = Path::from("my/dataset"); - let region_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let shard_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); assert_eq!( - region_base_path(&base_path, ®ion_id).as_ref(), + shard_base_path(&base_path, &shard_id).as_ref(), "my/dataset/_mem_wal/550e8400-e29b-41d4-a716-446655440000" ); assert_eq!( - region_wal_path(&base_path, ®ion_id).as_ref(), + shard_wal_path(&base_path, &shard_id).as_ref(), "my/dataset/_mem_wal/550e8400-e29b-41d4-a716-446655440000/wal" ); assert_eq!( - region_manifest_path(&base_path, ®ion_id).as_ref(), + shard_manifest_path(&base_path, &shard_id).as_ref(), "my/dataset/_mem_wal/550e8400-e29b-41d4-a716-446655440000/manifest" ); assert_eq!( - flushed_memtable_path(&base_path, ®ion_id, "a1b2c3d4", 5).as_ref(), + flushed_memtable_path(&base_path, &shard_id, "a1b2c3d4", 5).as_ref(), "my/dataset/_mem_wal/550e8400-e29b-41d4-a716-446655440000/a1b2c3d4_gen_5" ); // Test with empty base path let empty_base = Path::from(""); assert_eq!( - region_wal_path(&empty_base, ®ion_id).as_ref(), + shard_wal_path(&empty_base, &shard_id).as_ref(), "_mem_wal/550e8400-e29b-41d4-a716-446655440000/wal" ); } diff --git a/rust/lance/src/dataset/mem_wal/wal.rs b/rust/lance/src/dataset/mem_wal/wal.rs index d212be661e7..4e92355402c 100644 --- a/rust/lance/src/dataset/mem_wal/wal.rs +++ b/rust/lance/src/dataset/mem_wal/wal.rs @@ -23,7 +23,7 @@ use tokio::sync::{mpsc, watch}; use uuid::Uuid; -use super::util::{WatchableOnceCell, region_wal_path, wal_entry_filename}; +use super::util::{WatchableOnceCell, shard_wal_path, wal_entry_filename}; use super::index::IndexStore; use super::memtable::batch_store::{BatchStore, StoredBatch}; @@ -154,8 +154,8 @@ pub struct WalFlusher { durable_watermark_rx: watch::Receiver, /// Object store for writing WAL files. object_store: Option>, - /// Region ID. - region_id: Uuid, + /// Shard ID. + shard_id: Uuid, /// Writer epoch (stored in WAL entries for fencing). writer_epoch: u64, /// Next WAL entry ID to use. @@ -176,16 +176,16 @@ impl WalFlusher { /// # Arguments /// /// * `base_path` - Base path within the object store (from ObjectStore::from_uri) - /// * `region_id` - Region UUID + /// * `shard_id` - Shard UUID /// * `writer_epoch` - Current writer epoch - /// * `next_wal_entry_position` - Next WAL entry ID (from recovery or 1 for new region) + /// * `next_wal_entry_position` - Next WAL entry ID (from recovery or 1 for new shard) pub fn new( base_path: &Path, - region_id: Uuid, + shard_id: Uuid, writer_epoch: u64, next_wal_entry_position: u64, ) -> Self { - let wal_dir = region_wal_path(base_path, ®ion_id); + let wal_dir = shard_wal_path(base_path, &shard_id); // Initialize durable watermark at 0 (no batches durable yet) let (durable_watermark_tx, durable_watermark_rx) = watch::channel(0); // Create initial WAL flush cell for backpressure @@ -194,7 +194,7 @@ impl WalFlusher { durable_watermark_tx, durable_watermark_rx, object_store: None, - region_id, + shard_id, writer_epoch, next_wal_entry_position: AtomicU64::new(next_wal_entry_position), flush_tx: None, @@ -461,9 +461,9 @@ impl WalFlusher { self.next_wal_entry_position.load(Ordering::SeqCst) } - /// Get the region ID. - pub fn region_id(&self) -> Uuid { - self.region_id + /// Get the shard ID. + pub fn shard_id(&self) -> Uuid { + self.shard_id } /// Get the writer epoch. @@ -576,8 +576,8 @@ mod tests { #[tokio::test] async fn test_wal_flusher_track_batch() { let (store, base_path, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let mut buffer = WalFlusher::new(&base_path, region_id, 1, 1); + let shard_id = Uuid::new_v4(); + let mut buffer = WalFlusher::new(&base_path, shard_id, 1, 1); buffer.set_object_store(store); // Track a batch @@ -623,8 +623,8 @@ mod tests { #[tokio::test] async fn test_wal_flusher_flush_to_with_index_update() { let (store, base_path, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let mut buffer = WalFlusher::new(&base_path, region_id, 1, 1); + let shard_id = Uuid::new_v4(); + let mut buffer = WalFlusher::new(&base_path, shard_id, 1, 1); buffer.set_object_store(store); // Create a BatchStore with some data @@ -667,8 +667,8 @@ mod tests { #[tokio::test] async fn test_wal_entry_read() { let (store, base_path, _temp_dir) = create_local_store().await; - let region_id = Uuid::new_v4(); - let mut buffer = WalFlusher::new(&base_path, region_id, 42, 1); + let shard_id = Uuid::new_v4(); + let mut buffer = WalFlusher::new(&base_path, shard_id, 42, 1); buffer.set_object_store(store.clone()); // Create a BatchStore with some data diff --git a/rust/lance/src/dataset/mem_wal/write.rs b/rust/lance/src/dataset/mem_wal/write.rs index 6f108c87da4..4c956fd3ad2 100644 --- a/rust/lance/src/dataset/mem_wal/write.rs +++ b/rust/lance/src/dataset/mem_wal/write.rs @@ -6,7 +6,7 @@ //! Write path for MemWAL. //! //! This module contains all components for the write path: -//! - [`RegionWriter`] - Main writer interface for a single region +//! - [`ShardWriter`] - Main writer interface for a single shard //! - [`MemTable`] - In-memory table storing Arrow RecordBatches //! - [`WalFlusher`] - Write-ahead log buffer for durability (Arrow IPC format) //! - [`IndexStore`] - In-memory index management @@ -23,7 +23,7 @@ use arrow_schema::Schema as ArrowSchema; use async_trait::async_trait; use lance_core::datatypes::Schema; use lance_core::{Error, Result}; -use lance_index::mem_wal::RegionManifest; +use lance_index::mem_wal::ShardManifest; use lance_io::object_store::ObjectStore; use log::{debug, error, info, warn}; use object_store::path::Path; @@ -47,21 +47,21 @@ pub use super::wal::{WalEntry, WalEntryData, WalFlushResult, WalFlusher}; use super::memtable::flush::TriggerMemTableFlush; use super::wal::TriggerWalFlush; -use super::manifest::RegionManifestStore; +use super::manifest::ShardManifestStore; // ============================================================================ // Configuration // ============================================================================ -/// Configuration for a region writer. +/// Configuration for a shard writer. #[derive(Debug, Clone)] -pub struct RegionWriterConfig { - /// Unique identifier for this region (UUID v4). - pub region_id: Uuid, +pub struct ShardWriterConfig { + /// Unique identifier for this shard (UUID v4). + pub shard_id: Uuid, - /// Region spec ID this region was created with. - /// A value of 0 indicates a manually-created region not governed by any spec. - pub region_spec_id: u32, + /// Shard spec ID this shard was created with. + /// A value of 0 indicates a manually-created shard not governed by any spec. + pub shard_spec_id: u32, /// Whether to wait for WAL flush before returning from writes. /// @@ -182,11 +182,11 @@ pub struct RegionWriterConfig { pub stats_log_interval: Option, } -impl Default for RegionWriterConfig { +impl Default for ShardWriterConfig { fn default() -> Self { Self { - region_id: Uuid::new_v4(), - region_spec_id: 0, + shard_id: Uuid::new_v4(), + shard_spec_id: 0, durable_write: true, sync_indexed_write: true, max_wal_buffer_size: 10 * 1024 * 1024, // 10MB @@ -205,18 +205,18 @@ impl Default for RegionWriterConfig { } } -impl RegionWriterConfig { - /// Create a new configuration with the given region ID. - pub fn new(region_id: Uuid) -> Self { +impl ShardWriterConfig { + /// Create a new configuration with the given shard ID. + pub fn new(shard_id: Uuid) -> Self { Self { - region_id, + shard_id, ..Default::default() } } - /// Set the region spec ID. - pub fn with_region_spec_id(mut self, spec_id: u32) -> Self { - self.region_spec_id = spec_id; + /// Set the shard spec ID. + pub fn with_shard_spec_id(mut self, spec_id: u32) -> Self { + self.shard_spec_id = spec_id; self } @@ -567,14 +567,14 @@ pub struct BackpressureStatsSnapshot { /// Backpressure controller for managing write flow. pub struct BackpressureController { /// Configuration. - config: RegionWriterConfig, + config: ShardWriterConfig, /// Stats for monitoring. stats: Arc, } impl BackpressureController { /// Create a new backpressure controller. - pub fn new(config: RegionWriterConfig) -> Self { + pub fn new(config: ShardWriterConfig) -> Self { Self { config, stats: Arc::new(BackpressureStats::new()), @@ -653,7 +653,7 @@ pub struct WriteResult { pub batch_positions: std::ops::Range, } -/// RegionWriter state shared across tasks. +/// ShardWriter state shared across tasks. struct WriterState { memtable: MemTable, last_flushed_wal_entry_position: u64, @@ -685,7 +685,7 @@ struct SharedWriterState { wal_flusher: Arc, wal_flush_tx: mpsc::UnboundedSender, memtable_flush_tx: mpsc::UnboundedSender, - config: RegionWriterConfig, + config: ShardWriterConfig, schema: Arc, pk_field_ids: Vec, max_memtable_batches: usize, @@ -701,7 +701,7 @@ impl SharedWriterState { wal_flusher: Arc, wal_flush_tx: mpsc::UnboundedSender, memtable_flush_tx: mpsc::UnboundedSender, - config: RegionWriterConfig, + config: ShardWriterConfig, schema: Arc, pk_field_ids: Vec, max_memtable_batches: usize, @@ -924,21 +924,21 @@ impl SharedWriterState { } } -/// Main writer for a MemWAL region. -pub struct RegionWriter { - config: RegionWriterConfig, +/// Main writer for a MemWAL shard. +pub struct ShardWriter { + config: ShardWriterConfig, epoch: u64, state: Arc>, wal_flusher: Arc, task_executor: Arc, - manifest_store: Arc, + manifest_store: Arc, stats: SharedWriteStats, writer_state: Arc, backpressure: BackpressureController, } -impl RegionWriter { - /// Open or create a RegionWriter. +impl ShardWriter { + /// Open or create a ShardWriter. /// /// The `base_path` should come from `ObjectStore::from_uri()` to ensure /// WAL files are written inside the dataset directory. @@ -946,25 +946,25 @@ impl RegionWriter { object_store: Arc, base_path: Path, base_uri: impl Into, - config: RegionWriterConfig, + config: ShardWriterConfig, schema: Arc, index_configs: Vec, ) -> Result { let base_uri = base_uri.into(); - let region_id = config.region_id; - let manifest_store = Arc::new(RegionManifestStore::new( + let shard_id = config.shard_id; + let manifest_store = Arc::new(ShardManifestStore::new( object_store.clone(), &base_path, - region_id, + shard_id, config.manifest_scan_batch_size, )); - // Claim the region (epoch-based fencing) - let (epoch, manifest) = manifest_store.claim_epoch(config.region_spec_id).await?; + // Claim the shard (epoch-based fencing) + let (epoch, manifest) = manifest_store.claim_epoch(config.shard_spec_id).await?; info!( - "Opened RegionWriter for region {} (epoch {}, generation {})", - region_id, epoch, manifest.current_generation + "Opened ShardWriter for shard {} (epoch {}, generation {})", + shard_id, epoch, manifest.current_generation ); // Create MemTable with primary key field IDs from schema @@ -1007,7 +1007,7 @@ impl RegionWriter { // Create WAL flusher let mut wal_flusher = WalFlusher::new( &base_path, - region_id, + shard_id, epoch, manifest.wal_entry_position_last_seen + 1, ); @@ -1025,7 +1025,7 @@ impl RegionWriter { object_store.clone(), base_path, base_uri, - region_id, + shard_id, manifest_store.clone(), )); @@ -1083,7 +1083,7 @@ impl RegionWriter { }) } - /// Write record batches to the region. + /// Write record batches to the shard. /// /// All batches are inserted atomically with a single lock acquisition. /// This is more efficient than calling put() multiple times for Arrow IPC @@ -1182,8 +1182,8 @@ impl RegionWriter { self.stats.clone() } - /// Get the current region manifest. - pub async fn manifest(&self) -> Result> { + /// Get the current shard manifest. + pub async fn manifest(&self) -> Result> { self.manifest_store.read_latest().await } @@ -1192,9 +1192,9 @@ impl RegionWriter { self.epoch } - /// Get the region ID. - pub fn region_id(&self) -> Uuid { - self.config.region_id + /// Get the shard ID. + pub fn shard_id(&self) -> Uuid { + self.config.shard_id } /// Get current MemTable statistics. @@ -1258,7 +1258,7 @@ impl RegionWriter { /// /// Flushes pending data and shuts down background tasks. pub async fn close(self) -> Result<()> { - info!("Closing RegionWriter for region {}", self.config.region_id); + info!("Closing ShardWriter for shard {}", self.config.shard_id); // Send final WAL flush message and wait for completion let state = self.state.read().await; @@ -1294,7 +1294,7 @@ impl RegionWriter { // Shutdown background tasks self.task_executor.shutdown_all().await?; - info!("RegionWriter closed for region {}", self.config.region_id); + info!("ShardWriter closed for shard {}", self.config.shard_id); Ok(()) } } @@ -1859,13 +1859,13 @@ mod tests { } #[tokio::test] - async fn test_region_writer_basic_write() { + async fn test_shard_writer_basic_write() { let (store, base_path, base_uri, _temp_dir) = create_local_store().await; let schema = create_test_schema(); - let config = RegionWriterConfig { - region_id: Uuid::new_v4(), - region_spec_id: 0, + let config = ShardWriterConfig { + shard_id: Uuid::new_v4(), + shard_spec_id: 0, durable_write: false, sync_indexed_write: false, max_wal_buffer_size: 1024 * 1024, @@ -1875,7 +1875,7 @@ mod tests { ..Default::default() }; - let writer = RegionWriter::open( + let writer = ShardWriter::open( store, base_path, base_uri, @@ -1902,13 +1902,13 @@ mod tests { } #[tokio::test] - async fn test_region_writer_multiple_writes() { + async fn test_shard_writer_multiple_writes() { let (store, base_path, base_uri, _temp_dir) = create_local_store().await; let schema = create_test_schema(); - let config = RegionWriterConfig { - region_id: Uuid::new_v4(), - region_spec_id: 0, + let config = ShardWriterConfig { + shard_id: Uuid::new_v4(), + shard_spec_id: 0, durable_write: false, sync_indexed_write: false, max_wal_buffer_size: 1024 * 1024, @@ -1918,7 +1918,7 @@ mod tests { ..Default::default() }; - let writer = RegionWriter::open(store, base_path, base_uri, config, schema.clone(), vec![]) + let writer = ShardWriter::open(store, base_path, base_uri, config, schema.clone(), vec![]) .await .unwrap(); @@ -1937,13 +1937,13 @@ mod tests { } #[tokio::test] - async fn test_region_writer_with_indexes() { + async fn test_shard_writer_with_indexes() { let (store, base_path, base_uri, _temp_dir) = create_local_store().await; let schema = create_test_schema(); - let config = RegionWriterConfig { - region_id: Uuid::new_v4(), - region_spec_id: 0, + let config = ShardWriterConfig { + shard_id: Uuid::new_v4(), + shard_spec_id: 0, durable_write: false, sync_indexed_write: true, max_wal_buffer_size: 1024 * 1024, @@ -1959,7 +1959,7 @@ mod tests { column: "id".to_string(), })]; - let writer = RegionWriter::open( + let writer = ShardWriter::open( store, base_path, base_uri, @@ -1982,14 +1982,14 @@ mod tests { /// Test memtable auto-flush triggered by size threshold. #[tokio::test] - async fn test_region_writer_auto_flush_by_size() { + async fn test_shard_writer_auto_flush_by_size() { let (store, base_path, base_uri, _temp_dir) = create_local_store().await; let schema = create_test_schema(); // Use a small memtable size to trigger auto-flush - let config = RegionWriterConfig { - region_id: Uuid::new_v4(), - region_spec_id: 0, + let config = ShardWriterConfig { + shard_id: Uuid::new_v4(), + shard_spec_id: 0, durable_write: false, sync_indexed_write: false, max_wal_buffer_size: 1024 * 1024, @@ -1999,7 +1999,7 @@ mod tests { ..Default::default() }; - let writer = RegionWriter::open(store, base_path, base_uri, config, schema.clone(), vec![]) + let writer = ShardWriter::open(store, base_path, base_uri, config, schema.clone(), vec![]) .await .unwrap(); @@ -2026,7 +2026,7 @@ mod tests { #[tokio::test] async fn test_no_backpressure_when_under_threshold() { - let config = RegionWriterConfig::default().with_max_unflushed_memtable_bytes(1024 * 1024); // 1MB + let config = ShardWriterConfig::default().with_max_unflushed_memtable_bytes(1024 * 1024); // 1MB let controller = BackpressureController::new(config); @@ -2044,7 +2044,7 @@ mod tests { use std::sync::atomic::AtomicUsize; use std::time::Duration; - let config = RegionWriterConfig::default() + let config = ShardWriterConfig::default() .with_max_unflushed_memtable_bytes(100) // Very low threshold .with_backpressure_log_interval(Duration::from_millis(50)); @@ -2121,7 +2121,7 @@ mod tests { } #[cfg(test)] -mod region_writer_tests { +mod shard_writer_tests { use std::sync::Arc; use crate::index::DatasetIndexExt; @@ -2142,7 +2142,7 @@ mod region_writer_tests { use crate::dataset::{Dataset, WriteParams}; use crate::index::vector::VectorIndexParams; - use super::super::RegionWriterConfig; + use super::super::ShardWriterConfig; fn create_test_schema(vector_dim: i32) -> Arc { use std::collections::HashMap; @@ -2202,16 +2202,16 @@ mod region_writer_tests { .unwrap() } - /// Quick smoke test for region writer - runs against memory:// - /// Run with: cargo test -p lance region_writer_tests::test_region_writer_smoke -- --nocapture + /// Quick smoke test for shard writer - runs against memory:// + /// Run with: cargo test -p lance shard_writer_tests::test_shard_writer_smoke -- --nocapture #[tokio::test] - async fn test_region_writer_smoke() { + async fn test_shard_writer_smoke() { let vector_dim = 128; let batch_size = 20; let num_batches = 100; let schema = create_test_schema(vector_dim); - let uri = format!("memory://test_region_writer_{}", Uuid::new_v4()); + let uri = format!("memory://test_shard_writer_{}", Uuid::new_v4()); // Create initial dataset let initial_batch = create_test_batch(&schema, 0, 100, vector_dim); @@ -2223,20 +2223,20 @@ mod region_writer_tests { // Initialize MemWAL (no indexes for smoke test) dataset .initialize_mem_wal(MemWalConfig { - region_spec: None, + shard_spec: None, maintained_indexes: vec![], }) .await .expect("Failed to initialize MemWAL"); - // Create region writer - let region_id = Uuid::new_v4(); - let config = RegionWriterConfig::new(region_id) + // Create shard writer + let shard_id = Uuid::new_v4(); + let config = ShardWriterConfig::new(shard_id) .with_durable_write(false) .with_sync_indexed_write(false); let writer = dataset - .mem_wal_writer(region_id, config) + .mem_wal_writer(shard_id, config) .await .expect("Failed to create writer"); @@ -2251,11 +2251,11 @@ mod region_writer_tests { writer.close().await.expect("Failed to close"); } - /// Test region writer against S3 with IVF-PQ, BTree, and FTS indexes (requires DATASET_PREFIX env var) - /// Run with: DATASET_PREFIX=s3://bucket/path cargo test -p lance --release region_writer_tests::test_region_writer_s3_ivfpq -- --nocapture --ignored + /// Test shard writer against S3 with IVF-PQ, BTree, and FTS indexes (requires DATASET_PREFIX env var) + /// Run with: DATASET_PREFIX=s3://bucket/path cargo test -p lance --release shard_writer_tests::test_shard_writer_s3_ivfpq -- --nocapture --ignored #[tokio::test] #[ignore] - async fn test_region_writer_s3_ivfpq() { + async fn test_shard_writer_s3_ivfpq() { let prefix = std::env::var("DATASET_PREFIX").expect("DATASET_PREFIX not set"); let vector_dim = 512; @@ -2332,7 +2332,7 @@ mod region_writer_tests { // Initialize MemWAL with all three indexes dataset .initialize_mem_wal(MemWalConfig { - region_spec: None, + shard_spec: None, maintained_indexes: vec![ "id_btree".to_string(), "text_fts".to_string(), @@ -2342,14 +2342,14 @@ mod region_writer_tests { .await .expect("Failed to initialize MemWAL"); - // Create region writer with default config - let region_id = Uuid::new_v4(); - let config = RegionWriterConfig::new(region_id) + // Create shard writer with default config + let shard_id = Uuid::new_v4(); + let config = ShardWriterConfig::new(shard_id) .with_durable_write(false) .with_sync_indexed_write(false); let writer = dataset - .mem_wal_writer(region_id, config) + .mem_wal_writer(shard_id, config) .await .expect("Failed to create writer"); @@ -2364,7 +2364,7 @@ mod region_writer_tests { writer.close().await.expect("Failed to close"); } - /// End-to-end correctness test for RegionWriter with multiple memtable flushes. + /// End-to-end correctness test for ShardWriter with multiple memtable flushes. /// /// This test verifies: /// 1. Multiple memtable flushes are triggered via small memtable size @@ -2373,9 +2373,9 @@ mod region_writer_tests { /// 4. Data can be read after each flush cycle /// 5. Manifest tracks flushed generations correctly /// - /// Run with: cargo test -p lance region_writer_tests::test_region_writer_e2e_correctness -- --nocapture + /// Run with: cargo test -p lance shard_writer_tests::test_shard_writer_e2e_correctness -- --nocapture #[tokio::test] - async fn test_region_writer_e2e_correctness() { + async fn test_shard_writer_e2e_correctness() { use std::time::Duration; use tempfile::TempDir; @@ -2414,15 +2414,15 @@ mod region_writer_tests { // Initialize MemWAL with BTree index only (simpler for this test) dataset .initialize_mem_wal(MemWalConfig { - region_spec: None, + shard_spec: None, maintained_indexes: vec!["id_btree".to_string()], }) .await .expect("Failed to initialize MemWAL"); - // Create region writer with small memtable size to trigger flushes - let region_id = Uuid::new_v4(); - let config = RegionWriterConfig::new(region_id) + // Create shard writer with small memtable size to trigger flushes + let shard_id = Uuid::new_v4(); + let config = ShardWriterConfig::new(shard_id) .with_durable_write(true) // Ensure WAL files are written .with_sync_indexed_write(true) .with_max_memtable_size(50 * 1024) // 50KB - triggers flush after ~8 batches @@ -2430,7 +2430,7 @@ mod region_writer_tests { .with_max_wal_flush_interval(Duration::from_millis(50)); // Fast flush let writer = dataset - .mem_wal_writer(region_id, config) + .mem_wal_writer(shard_id, config) .await .expect("Failed to create writer"); @@ -2462,7 +2462,7 @@ mod region_writer_tests { writer.close().await.expect("Failed to close"); // === VERIFY FILE SYSTEM LAYOUT === - let mem_wal_dir = temp_dir.path().join("_mem_wal").join(region_id.to_string()); + let mem_wal_dir = temp_dir.path().join("_mem_wal").join(shard_id.to_string()); assert!(mem_wal_dir.exists(), "MemWAL directory should exist"); // Check WAL directory @@ -2494,7 +2494,7 @@ mod region_writer_tests { .await .expect("Failed to open store"); let manifest_store = - super::super::manifest::RegionManifestStore::new(store, &base_path, region_id, 2); + super::super::manifest::ShardManifestStore::new(store, &base_path, shard_id, 2); let manifest = manifest_store .read_latest() .await @@ -2507,12 +2507,12 @@ mod region_writer_tests { "Should have at least one flushed generation" ); for flushed_gen in &manifest.flushed_generations { - // The path stored in manifest is relative to the region directory - // Construct full path: temp_dir/_mem_wal/region_id/generation_folder + // The path stored in manifest is relative to the shard directory + // Construct full path: temp_dir/_mem_wal/shard_id/generation_folder let gen_path = temp_dir .path() .join("_mem_wal") - .join(region_id.to_string()) + .join(shard_id.to_string()) .join(&flushed_gen.path); // The generation directory should exist @@ -2547,26 +2547,26 @@ mod region_writer_tests { // === VERIFY DATA CAN BE READ FROM NEW WRITER === // Re-open dataset and create new writer to verify recovery let dataset = Dataset::open(&uri).await.expect("Failed to reopen dataset"); - let new_region_id = Uuid::new_v4(); - let new_config = RegionWriterConfig::new(new_region_id) + let new_shard_id = Uuid::new_v4(); + let new_config = ShardWriterConfig::new(new_shard_id) .with_durable_write(false) .with_sync_indexed_write(true); let new_writer = dataset - .mem_wal_writer(new_region_id, new_config) + .mem_wal_writer(new_shard_id, new_config) .await .expect("Failed to create new writer"); - // Write a test batch to verify the new region works + // Write a test batch to verify the new shard works let verify_batch = create_test_batch(&schema, 10000, 10, vector_dim); new_writer .put(vec![verify_batch]) .await - .expect("Failed to write to new region"); + .expect("Failed to write to new shard"); let scanner = new_writer.scan().await; let result = scanner.try_into_batch().await.expect("Failed to scan"); - assert_eq!(result.num_rows(), 10, "New region should have 10 rows"); + assert_eq!(result.num_rows(), 10, "New shard should have 10 rows"); new_writer .close() diff --git a/rust/lance/src/index/mem_wal.rs b/rust/lance/src/index/mem_wal.rs index 66d3a9aaca2..a9a966f4ee9 100644 --- a/rust/lance/src/index/mem_wal.rs +++ b/rust/lance/src/index/mem_wal.rs @@ -4,12 +4,12 @@ //! MemWAL Index operations. //! //! The MemWAL Index stores: -//! - Configuration (region_specs, maintained_indexes) -//! - Merge progress (merged_generations per region) -//! - Region state snapshots (eventually consistent) +//! - Configuration (shard_specs, maintained_indexes) +//! - Merge progress (merged_generations per shard) +//! - Shard state snapshots (eventually consistent) //! //! Writers no longer update the index on every write. Instead, they update -//! region manifests directly. This module provides functions to: +//! shard manifests directly. This module provides functions to: //! - Load the MemWAL index //! - Update merged generations (called during merge-insert commits) @@ -65,12 +65,12 @@ pub(crate) fn update_mem_wal_index_merged_generations( let current_meta = indices.remove(pos); let mut details = load_mem_wal_index_details(current_meta)?; - // Update merged_generations - for each region, keep the higher generation + // Update merged_generations - for each shard, keep the higher generation for new_mg in new_merged_generations { if let Some(existing) = details .merged_generations .iter_mut() - .find(|mg| mg.region_id == new_mg.region_id) + .find(|mg| mg.shard_id == new_mg.shard_id) { if new_mg.generation > existing.generation { existing.generation = new_mg.generation; @@ -157,13 +157,13 @@ mod tests { #[tokio::test] async fn test_update_mem_wal_state_conflict_lower_generation_no_retry() { let dataset = test_dataset().await; - let region = Uuid::new_v4(); + let shard = Uuid::new_v4(); // First commit UpdateMemWalState with generation 10 let txn1 = Transaction::new( dataset.manifest.version, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], }, None, ); @@ -177,7 +177,7 @@ mod tests { let txn2 = Transaction::new( dataset.manifest.version - 1, // Based on old version Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 5)], + merged_generations: vec![MergedGeneration::new(shard, 5)], }, None, ); @@ -194,13 +194,13 @@ mod tests { #[tokio::test] async fn test_update_mem_wal_state_conflict_equal_generation_no_retry() { let dataset = test_dataset().await; - let region = Uuid::new_v4(); + let shard = Uuid::new_v4(); // First commit UpdateMemWalState with generation 10 let txn1 = Transaction::new( dataset.manifest.version, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], }, None, ); @@ -213,7 +213,7 @@ mod tests { let txn2 = Transaction::new( dataset.manifest.version - 1, // Based on old version Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], }, None, ); @@ -231,13 +231,13 @@ mod tests { #[tokio::test] async fn test_update_mem_wal_state_conflict_higher_generation_retryable() { let dataset = test_dataset().await; - let region = Uuid::new_v4(); + let shard = Uuid::new_v4(); // First commit UpdateMemWalState with generation 5 let txn1 = Transaction::new( dataset.manifest.version, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 5)], + merged_generations: vec![MergedGeneration::new(shard, 5)], }, None, ); @@ -251,7 +251,7 @@ mod tests { let txn2 = Transaction::new( dataset.manifest.version - 1, // Based on old version Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], }, None, ); @@ -264,18 +264,18 @@ mod tests { ); } - /// Test that UpdateMemWalState on different regions don't conflict. + /// Test that UpdateMemWalState on different shards don't conflict. #[tokio::test] - async fn test_update_mem_wal_state_different_regions_no_conflict() { + async fn test_update_mem_wal_state_different_shards_no_conflict() { let dataset = test_dataset().await; - let region1 = Uuid::new_v4(); - let region2 = Uuid::new_v4(); + let shard1 = Uuid::new_v4(); + let shard2 = Uuid::new_v4(); - // First commit UpdateMemWalState for region1 + // First commit UpdateMemWalState for shard1 let txn1 = Transaction::new( dataset.manifest.version, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region1, 10)], + merged_generations: vec![MergedGeneration::new(shard1, 10)], }, None, ); @@ -284,12 +284,12 @@ mod tests { .await .unwrap(); - // Commit UpdateMemWalState for region2 based on old version - // This should succeed because different regions don't conflict + // Commit UpdateMemWalState for shard2 based on old version + // This should succeed because different shards don't conflict let txn2 = Transaction::new( dataset.manifest.version - 1, // Based on old version Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region2, 5)], + merged_generations: vec![MergedGeneration::new(shard2, 5)], }, None, ); @@ -297,11 +297,11 @@ mod tests { assert!( result.is_ok(), - "Expected success for different regions, got {:?}", + "Expected success for different shards, got {:?}", result ); - // Verify both regions are in the index + // Verify both shards are in the index let dataset = result.unwrap(); let mem_wal_idx = dataset .load_indices() @@ -320,13 +320,13 @@ mod tests { #[tokio::test] async fn test_create_index_rebase_against_update_mem_wal_state() { let dataset = test_dataset().await; - let region = Uuid::new_v4(); + let shard = Uuid::new_v4(); // First commit UpdateMemWalState with generation 10 let txn1 = Transaction::new( dataset.manifest.version, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], }, None, ); @@ -338,7 +338,7 @@ mod tests { // CreateIndex of MemWalIndex based on old version (before UpdateMemWalState) // This should succeed and merge the generations let details = MemWalIndexDetails { - num_regions: 1, + num_shards: 1, ..Default::default() }; let mem_wal_index = new_mem_wal_index_meta(dataset.manifest.version - 1, details).unwrap(); @@ -371,20 +371,20 @@ mod tests { .clone(); let details = load_mem_wal_index_details(mem_wal_idx).unwrap(); assert_eq!(details.merged_generations.len(), 1); - assert_eq!(details.merged_generations[0].region_id, region); + assert_eq!(details.merged_generations[0].shard_id, shard); assert_eq!(details.merged_generations[0].generation, 10); - assert_eq!(details.num_regions, 1); // Config from CreateIndex preserved + assert_eq!(details.num_shards, 1); // Config from CreateIndex preserved } /// Test that UpdateMemWalState against CreateIndex of MemWalIndex checks generations. #[tokio::test] async fn test_update_mem_wal_state_against_create_index_lower_generation() { let dataset = test_dataset().await; - let region = Uuid::new_v4(); + let shard = Uuid::new_v4(); // First commit CreateIndex of MemWalIndex with merged_generations let details = MemWalIndexDetails { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], ..Default::default() }; let mem_wal_index = new_mem_wal_index_meta(dataset.manifest.version, details).unwrap(); @@ -406,7 +406,7 @@ mod tests { let txn2 = Transaction::new( dataset.manifest.version - 1, // Based on old version Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 5)], + merged_generations: vec![MergedGeneration::new(shard, 5)], }, None, ); @@ -422,28 +422,28 @@ mod tests { #[test] fn test_update_merged_generations() { let mut indices = Vec::new(); - let region1 = Uuid::new_v4(); - let region2 = Uuid::new_v4(); + let shard1 = Uuid::new_v4(); + let shard2 = Uuid::new_v4(); // First update - creates new index update_mem_wal_index_merged_generations( &mut indices, 1, - vec![MergedGeneration::new(region1, 5)], + vec![MergedGeneration::new(shard1, 5)], ) .unwrap(); assert_eq!(indices.len(), 1); let details = load_mem_wal_index_details(indices[0].clone()).unwrap(); assert_eq!(details.merged_generations.len(), 1); - assert_eq!(details.merged_generations[0].region_id, region1); + assert_eq!(details.merged_generations[0].shard_id, shard1); assert_eq!(details.merged_generations[0].generation, 5); - // Second update - updates existing region + // Second update - updates existing shard update_mem_wal_index_merged_generations( &mut indices, 2, - vec![MergedGeneration::new(region1, 10)], + vec![MergedGeneration::new(shard1, 10)], ) .unwrap(); @@ -452,11 +452,11 @@ mod tests { assert_eq!(details.merged_generations.len(), 1); assert_eq!(details.merged_generations[0].generation, 10); - // Third update - adds new region + // Third update - adds new shard update_mem_wal_index_merged_generations( &mut indices, 3, - vec![MergedGeneration::new(region2, 3)], + vec![MergedGeneration::new(shard2, 3)], ) .unwrap(); @@ -468,7 +468,7 @@ mod tests { update_mem_wal_index_merged_generations( &mut indices, 4, - vec![MergedGeneration::new(region1, 8)], // lower than 10 + vec![MergedGeneration::new(shard1, 8)], // lower than 10 ) .unwrap(); @@ -476,7 +476,7 @@ mod tests { let r1_mg = details .merged_generations .iter() - .find(|mg| mg.region_id == region1) + .find(|mg| mg.shard_id == shard1) .unwrap(); assert_eq!(r1_mg.generation, 10); // Should still be 10 } diff --git a/rust/lance/src/io/commit/conflict_resolver.rs b/rust/lance/src/io/commit/conflict_resolver.rs index 8949cb383cc..c270d2ab7e4 100644 --- a/rust/lance/src/io/commit/conflict_resolver.rs +++ b/rust/lance/src/io/commit/conflict_resolver.rs @@ -1138,7 +1138,7 @@ impl<'a> TransactionRebase<'a> { merged_generations: other_merged_generations, } => { // Two UpdateMemWalState transactions conflict if they're updating - // the same region's merged_generation + // the same shard's merged_generation self.check_merged_generations_conflict( other_merged_generations, self_merged_generations, @@ -1245,11 +1245,11 @@ impl<'a> TransactionRebase<'a> { other_transaction: &Transaction, other_version: u64, ) -> Result<()> { - // Check if any region has conflicting updates + // Check if any shard has conflicting updates for committed_mg in committed { for to_commit_mg in to_commit { - if committed_mg.region_id == to_commit_mg.region_id { - // Same region being updated + if committed_mg.shard_id == to_commit_mg.shard_id { + // Same shard being updated // If committed >= to_commit, data already merged or superseded - abort without retry // If committed < to_commit, can retry with new state if committed_mg.generation >= to_commit_mg.generation { @@ -1516,13 +1516,13 @@ impl<'a> TransactionRebase<'a> { let current_meta = new_indices.remove(pos); let mut details = load_mem_wal_index_details(current_meta)?; - // Merge conflicting merged_generations - for each region, keep higher generation + // Merge conflicting merged_generations - for each shard, keep higher generation // We own self so we can consume conflicting_mem_wal_merged_gens directly for new_mg in self.conflicting_mem_wal_merged_gens { if let Some(existing) = details .merged_generations .iter_mut() - .find(|mg| mg.region_id == new_mg.region_id) + .find(|mg| mg.shard_id == new_mg.shard_id) { if new_mg.generation > existing.generation { existing.generation = new_mg.generation; @@ -3275,13 +3275,13 @@ mod tests { #[test] fn test_merged_generations_conflict_lower_generation_fails() { // Test: committed generation >= to_commit generation should be incompatible (no retry) - let region = Uuid::new_v4(); + let shard = Uuid::new_v4(); // Committed has generation 10, we're trying to commit generation 5 let committed_txn = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], }, None, ); @@ -3289,7 +3289,7 @@ mod tests { let to_commit_txn = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 5)], + merged_generations: vec![MergedGeneration::new(shard, 5)], }, None, ); @@ -3314,12 +3314,12 @@ mod tests { #[test] fn test_merged_generations_conflict_equal_generation_fails() { // Test: committed generation == to_commit generation should be incompatible (no retry) - let region = Uuid::new_v4(); + let shard = Uuid::new_v4(); let committed_txn = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], }, None, ); @@ -3327,7 +3327,7 @@ mod tests { let to_commit_txn = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], }, None, ); @@ -3352,13 +3352,13 @@ mod tests { #[test] fn test_merged_generations_conflict_higher_generation_retryable() { // Test: committed generation < to_commit generation should be retryable - let region = Uuid::new_v4(); + let shard = Uuid::new_v4(); // Committed has generation 5, we're trying to commit generation 10 let committed_txn = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 5)], + merged_generations: vec![MergedGeneration::new(shard, 5)], }, None, ); @@ -3366,7 +3366,7 @@ mod tests { let to_commit_txn = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], }, None, ); @@ -3389,15 +3389,15 @@ mod tests { } #[test] - fn test_merged_generations_different_regions_ok() { - // Test: different regions should not conflict - let region1 = Uuid::new_v4(); - let region2 = Uuid::new_v4(); + fn test_merged_generations_different_shards_ok() { + // Test: different shards should not conflict + let shard1 = Uuid::new_v4(); + let shard2 = Uuid::new_v4(); let committed_txn = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region1, 10)], + merged_generations: vec![MergedGeneration::new(shard1, 10)], }, None, ); @@ -3405,7 +3405,7 @@ mod tests { let to_commit_txn = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region2, 5)], + merged_generations: vec![MergedGeneration::new(shard2, 5)], }, None, ); @@ -3422,7 +3422,7 @@ mod tests { let result = rebase.check_txn(&committed_txn, 1); assert!( result.is_ok(), - "Expected OK for different regions, got {:?}", + "Expected OK for different shards, got {:?}", result ); } @@ -3432,11 +3432,11 @@ mod tests { use crate::index::mem_wal::new_mem_wal_index_meta; use lance_index::mem_wal::MemWalIndexDetails; - let region = Uuid::new_v4(); + let shard = Uuid::new_v4(); // Create a MemWalIndex with merged_generations let details = MemWalIndexDetails { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], ..Default::default() }; let mem_wal_index = new_mem_wal_index_meta(1, details).unwrap(); @@ -3455,7 +3455,7 @@ mod tests { let to_commit_txn = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 5)], + merged_generations: vec![MergedGeneration::new(shard, 5)], }, None, ); @@ -3480,7 +3480,7 @@ mod tests { let to_commit_txn_higher = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 15)], + merged_generations: vec![MergedGeneration::new(shard, 15)], }, None, ); @@ -3507,7 +3507,7 @@ mod tests { use crate::index::mem_wal::new_mem_wal_index_meta; use lance_index::mem_wal::MemWalIndexDetails; - let region = Uuid::new_v4(); + let shard = Uuid::new_v4(); // CreateIndex with MemWalIndex (no merged_generations initially) let details = MemWalIndexDetails::default(); @@ -3526,7 +3526,7 @@ mod tests { let committed_txn = Transaction::new( 0, Operation::UpdateMemWalState { - merged_generations: vec![MergedGeneration::new(region, 10)], + merged_generations: vec![MergedGeneration::new(shard, 10)], }, None, ); @@ -3551,7 +3551,7 @@ mod tests { // Verify that merged_generations were collected assert_eq!(rebase.conflicting_mem_wal_merged_gens.len(), 1); - assert_eq!(rebase.conflicting_mem_wal_merged_gens[0].region_id, region); + assert_eq!(rebase.conflicting_mem_wal_merged_gens[0].shard_id, shard); assert_eq!(rebase.conflicting_mem_wal_merged_gens[0].generation, 10); } From 01a25e9df75161acf89eca25649bed7dbe35cee8 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Sat, 4 Apr 2026 00:36:49 +0800 Subject: [PATCH 139/206] feat: optimize one segmented vector segment per run (#6402) This changes segmented vector index optimize so the default rebalance path keeps segment boundaries and rewrites only the single worst segment in each run. It builds on #6400's logical vector index / IVF view work and avoids the current behavior where segmented optimize treats the logical index as one physical index. I also added a regression test that creates a skewed two-segment IVF index and verifies that optimize replaces only the oversized segment while leaving the other segment untouched. --- rust/lance/src/index.rs | 128 ++++++++++++++++++++ rust/lance/src/index/append.rs | 186 +++++++++++++++++++++-------- rust/lance/src/index/vector.rs | 6 + rust/lance/src/index/vector/ivf.rs | 95 ++++++++++++++- 4 files changed, 363 insertions(+), 52 deletions(-) diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index c604e260b92..aa9d88b71c4 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -2232,6 +2232,63 @@ mod tests { .unwrap() } + async fn write_skewed_fragmented_vector_dataset(uri: &str, dimension: i32) -> Dataset { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + dimension, + ), + false, + ), + ])); + let first_fragment_rows = 20_000; + let second_fragment_rows = 100; + let first_values = vec![0.0f32; first_fragment_rows * dimension as usize]; + let second_values = vec![100.0f32; second_fragment_rows * dimension as usize]; + let first_vectors = + FixedSizeListArray::try_new_from_values(Float32Array::from(first_values), dimension) + .unwrap(); + let second_vectors = + FixedSizeListArray::try_new_from_values(Float32Array::from(second_values), dimension) + .unwrap(); + let batches = vec![ + RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..first_fragment_rows as i32)), + Arc::new(first_vectors), + ], + ) + .unwrap(), + RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values( + first_fragment_rows as i32 + ..(first_fragment_rows + second_fragment_rows) as i32, + )), + Arc::new(second_vectors), + ], + ) + .unwrap(), + ]; + let reader = RecordBatchIterator::new(batches.into_iter().map(Ok), schema); + Dataset::write( + reader, + uri, + Some(WriteParams { + max_rows_per_group: 1024, + max_rows_per_file: first_fragment_rows, + ..Default::default() + }), + ) + .await + .unwrap() + } + async fn create_segmented_vector_index( dataset: &mut Dataset, index_name: &str, @@ -2435,6 +2492,77 @@ mod tests { ); } + #[tokio::test] + async fn test_segmented_optimize_rebalances_only_one_segment() { + const DIMENSION: i32 = 8; + + let test_dir = tempfile::tempdir().unwrap(); + let test_uri = test_dir.path().to_str().unwrap(); + let mut dataset = write_skewed_fragmented_vector_dataset(test_uri, DIMENSION).await; + create_segmented_vector_index(&mut dataset, "vector_idx", "vector", DIMENSION).await; + + let before_segments = dataset.load_indices_by_name("vector_idx").await.unwrap(); + assert_eq!(before_segments.len(), 2); + let before_by_fragment = before_segments + .iter() + .map(|metadata| { + let fragments = metadata + .fragment_bitmap + .as_ref() + .unwrap() + .iter() + .collect::>(); + (fragments, metadata.uuid) + }) + .collect::>(); + assert_eq!(before_by_fragment.len(), 2); + + dataset + .optimize_indices(&OptimizeOptions::default()) + .await + .unwrap(); + + let after_segments = dataset.load_indices_by_name("vector_idx").await.unwrap(); + assert_eq!(after_segments.len(), 2); + let after_by_fragment = after_segments + .iter() + .map(|metadata| { + let fragments = metadata + .fragment_bitmap + .as_ref() + .unwrap() + .iter() + .collect::>(); + (fragments, metadata.uuid) + }) + .collect::>(); + assert_eq!(after_by_fragment.len(), 2); + + assert_ne!( + before_by_fragment[&vec![0]], + after_by_fragment[&vec![0]], + "expected optimize to replace the oversized segment" + ); + assert_eq!( + before_by_fragment[&vec![1]], + after_by_fragment[&vec![1]], + "expected optimize to leave the smaller segment untouched" + ); + + let logical_index = dataset + .open_logical_vector_index("vector", "vector_idx") + .await + .unwrap(); + let partitions_per_segment = logical_index + .as_ivf() + .unwrap() + .num_partitions_per_segment() + .into_iter() + .collect::>(); + assert_eq!(partitions_per_segment[&after_by_fragment[&vec![0]]], 3); + assert_eq!(partitions_per_segment[&after_by_fragment[&vec![1]]], 2); + } + #[tokio::test] async fn test_recreate_index() { const DIM: i32 = 8; diff --git a/rust/lance/src/index/append.rs b/rust/lance/src/index/append.rs index b088be625f9..b93e325d472 100644 --- a/rust/lance/src/index/append.rs +++ b/rust/lance/src/index/append.rs @@ -21,7 +21,7 @@ use uuid::Uuid; use super::DatasetIndexInternalExt; use super::vector::LogicalVectorIndex; -use super::vector::ivf::optimize_vector_indices; +use super::vector::ivf::{optimize_vector_indices, select_segment_for_single_rebalance}; use crate::dataset::Dataset; use crate::dataset::index::LanceIndexStoreExt; use crate::dataset::rowids::load_row_id_sequences; @@ -108,7 +108,13 @@ pub async fn merge_indices<'a>( }; let unindexed = dataset.unindexed_fragments(&old_indices[0].name).await?; - merge_indices_with_unindexed_frags(dataset, old_indices, &unindexed, options).await + Box::pin(merge_indices_with_unindexed_frags( + dataset, + old_indices, + &unindexed, + options, + )) + .await } /// Merge a list of provided unindexed data, with a specific number of previous indices @@ -145,12 +151,12 @@ pub async fn merge_indices_with_unindexed_frags<'a>( } } - let mut frag_bitmap = RoaringBitmap::new(); + let mut base_unindexed_bitmap = RoaringBitmap::new(); unindexed.iter().for_each(|frag| { - frag_bitmap.insert(frag.id as u32); + base_unindexed_bitmap.insert(frag.id as u32); }); - let (new_uuid, indices_merged, created_index) = if first_is_vector_index { + let (new_uuid, removed_indices, new_fragment_bitmap, created_index) = if first_is_vector_index { let full_logical_index = dataset .open_logical_vector_index(&field_path, &old_indices[0].name) .await?; @@ -180,50 +186,130 @@ pub async fn merge_indices_with_unindexed_frags<'a>( )?; let ivf_view = logical_index.as_ivf()?; - let new_data_stream = if unindexed.is_empty() { - None - } else { - let mut scanner = dataset.scan(); - scanner - .with_fragments(unindexed.to_vec()) - .with_row_id() - .project(&[&field_path])?; - if column.nullable { - let column_expr = lance_datafusion::logical_expr::field_path_to_expr(&field_path)?; - scanner.filter_expr(column_expr.is_not_null()); + let use_single_segment_rebalance = logical_index.num_segments() > 1 + && options.num_indices_to_merge.is_none() + && !options.retrain + && unindexed.is_empty(); + + if use_single_segment_rebalance { + let Some(selected_segment_id) = select_segment_for_single_rebalance(&ivf_view)? else { + return Ok(None); + }; + let removed_segment = old_indices + .iter() + .copied() + .find(|metadata| metadata.uuid == selected_segment_id) + .ok_or_else(|| { + Error::index(format!( + "Append index: logical vector index '{}' does not contain selected segment {}", + old_indices[0].name, selected_segment_id + )) + })?; + let (selected_metadata, selected_index) = logical_index + .iter() + .find(|(metadata, _)| metadata.uuid == selected_segment_id) + .map(|(metadata, index)| (metadata.clone(), index.clone())) + .ok_or_else(|| { + Error::index(format!( + "Append index: failed to materialize selected segment {} from logical vector index '{}'", + selected_segment_id, old_indices[0].name + )) + })?; + let selected_logical_index = LogicalVectorIndex::try_new( + old_indices[0].name.clone(), + field_path.clone(), + vec![(selected_metadata, selected_index)], + )?; + let selected_ivf_view = selected_logical_index.as_ivf()?; + let (new_uuid, indices_merged) = Box::pin(optimize_vector_indices( + dataset.as_ref().clone(), + Option::< + lance_io::stream::RecordBatchStreamAdapter< + futures::stream::Empty>, + >, + >::None, + &field_path, + &selected_ivf_view, + options, + )) + .await?; + if indices_merged == 0 { + return Ok(None); } - Some(scanner.try_into_stream().await?) - }; - let (new_uuid, indices_merged) = optimize_vector_indices( - dataset.as_ref().clone(), - new_data_stream, - &field_path, - &ivf_view, - options, - ) - .boxed() - .await?; + let index_dir = dataset.indices_dir().child(new_uuid.to_string()); + let files = list_index_files_with_sizes(&dataset.object_store, &index_dir).await?; + let new_fragment_bitmap = removed_segment + .effective_fragment_bitmap(&dataset.fragment_bitmap) + .or_else(|| removed_segment.fragment_bitmap.clone()) + .unwrap_or_default(); + + Ok(( + new_uuid, + vec![removed_segment], + new_fragment_bitmap, + CreatedIndex { + index_details: vector_index_details(), + index_version: lance_index::IndexType::Vector.version() as u32, + files: Some(files), + }, + )) + } else { + let mut frag_bitmap = base_unindexed_bitmap.clone(); + + let new_data_stream = if unindexed.is_empty() { + None + } else { + let mut scanner = dataset.scan(); + scanner + .with_fragments(unindexed.to_vec()) + .with_row_id() + .project(&[&field_path])?; + if column.nullable { + let column_expr = + lance_datafusion::logical_expr::field_path_to_expr(&field_path)?; + scanner.filter_expr(column_expr.is_not_null()); + } + Some(scanner.try_into_stream().await?) + }; + + let (new_uuid, indices_merged) = optimize_vector_indices( + dataset.as_ref().clone(), + new_data_stream, + &field_path, + &ivf_view, + options, + ) + .boxed() + .await?; - old_indices[old_indices.len() - indices_merged..] - .iter() - .for_each(|idx| { + let removed_indices = old_indices[old_indices.len() - indices_merged..].to_vec(); + removed_indices.iter().for_each(|idx| { frag_bitmap.extend(idx.fragment_bitmap.as_ref().unwrap().iter()); }); + for removed in removed_indices.iter() { + if let Some(effective) = removed.effective_fragment_bitmap(&dataset.fragment_bitmap) + { + frag_bitmap |= &effective; + } + } - let index_dir = dataset.indices_dir().child(new_uuid.to_string()); - let files = list_index_files_with_sizes(&dataset.object_store, &index_dir).await?; - - Ok(( - new_uuid, - indices_merged, - CreatedIndex { - index_details: vector_index_details(), - index_version: lance_index::IndexType::Vector.version() as u32, - files: Some(files), - }, - )) + let index_dir = dataset.indices_dir().child(new_uuid.to_string()); + let files = list_index_files_with_sizes(&dataset.object_store, &index_dir).await?; + + Ok(( + new_uuid, + removed_indices, + frag_bitmap, + CreatedIndex { + index_details: vector_index_details(), + index_version: lance_index::IndexType::Vector.version() as u32, + files: Some(files), + }, + )) + } } else { + let mut frag_bitmap = base_unindexed_bitmap; let mut indices = Vec::with_capacity(old_indices.len()); for idx in old_indices { match dataset @@ -341,7 +427,12 @@ pub async fn merge_indices_with_unindexed_frags<'a>( }; // TODO: don't hard-code index version - Ok((new_uuid, 1, created_index)) + Ok(( + new_uuid, + vec![old_indices[old_indices.len() - 1]], + frag_bitmap, + created_index, + )) } _ => Err(Error::index(format!( "Append index: invalid index type: {:?}", @@ -350,17 +441,10 @@ pub async fn merge_indices_with_unindexed_frags<'a>( } }?; - let removed_indices = old_indices[old_indices.len() - indices_merged..].to_vec(); - for removed in removed_indices.iter() { - if let Some(effective) = removed.effective_fragment_bitmap(&dataset.fragment_bitmap) { - frag_bitmap |= &effective; - } - } - Ok(Some(IndexMergeResults { new_uuid, removed_indices, - new_fragment_bitmap: frag_bitmap, + new_fragment_bitmap, new_index_version: created_index.index_version as i32, new_index_details: created_index.index_details, files: created_index.files, diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index 2d3594524cc..887ae040848 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -157,6 +157,12 @@ impl<'a> LogicalIvfView<'a> { self.logical_index.iter().map(|(_, index)| index) } + pub(crate) fn segments( + &self, + ) -> impl ExactSizeIterator)> + '_ { + self.logical_index.iter() + } + /// Returns the partition count for each segment in this IVF index. pub fn num_partitions_per_segment(&self) -> Vec<(Uuid, usize)> { self.logical_index diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 1a4f375db2c..1d388236add 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -8,7 +8,10 @@ use super::{ pq::{PQIndex, build_pq_model}, utils::{filter_finite_training_data, maybe_sample_training_data}, }; -use super::{builder::IvfIndexBuilder, utils::PartitionLoadLock}; +use super::{ + builder::{IvfIndexBuilder, index_type_string}, + utils::PartitionLoadLock, +}; use crate::dataset::index::dataset_format_version; use crate::index::DatasetIndexInternalExt; use crate::index::vector::utils::{get_vector_dim, get_vector_type}; @@ -66,6 +69,7 @@ use lance_index::vector::v3::shuffler::create_ivf_shuffler; use lance_index::vector::v3::subindex::{IvfSubIndex, SubIndexType}; use lance_index::{ INDEX_AUXILIARY_FILE_NAME, INDEX_METADATA_SCHEMA_KEY, Index, IndexMetadata, IndexType, + MAX_PARTITION_SIZE_FACTOR, MIN_PARTITION_SIZE_PERCENT, optimize::OptimizeOptions, vector::{ Query, VectorIndex, @@ -270,6 +274,95 @@ impl std::fmt::Debug for IVFIndex { } } +#[derive(Clone, Copy, Debug)] +struct SegmentRebalanceCandidate { + segment_id: Uuid, + score: usize, + created_at_ms: i64, +} + +fn candidate_is_better( + candidate: SegmentRebalanceCandidate, + current_best: Option, +) -> bool { + match current_best { + None => true, + Some(current_best) => { + candidate.score > current_best.score + || (candidate.score == current_best.score + && (candidate.created_at_ms, candidate.segment_id.as_bytes()) + < ( + current_best.created_at_ms, + current_best.segment_id.as_bytes(), + )) + } + } +} + +fn index_type_for_segmented_optimize(index: &dyn VectorIndex) -> Result { + let (sub_index_type, quantization_type) = index.sub_index_type(); + IndexType::try_from(index_type_string(sub_index_type, quantization_type).as_str()) +} + +pub(crate) fn select_segment_for_single_rebalance( + logical_index: &LogicalIvfView<'_>, +) -> Result> { + let mut best_split = None; + let mut best_join = None; + + for (metadata, index) in logical_index.segments() { + let index_type = index_type_for_segmented_optimize(index.as_ref())?; + let split_threshold = MAX_PARTITION_SIZE_FACTOR * index_type.target_partition_size(); + let join_threshold = MIN_PARTITION_SIZE_PERCENT * index_type.target_partition_size() / 100; + let num_partitions = index.ivf_model().num_partitions(); + if num_partitions == 0 { + continue; + } + + let mut split_partition_count = 0usize; + let mut join_partition_count = 0usize; + for partition_id in 0..num_partitions { + let partition_size = index.partition_size(partition_id); + if partition_size > split_threshold { + split_partition_count += 1; + } + if num_partitions > 1 && partition_size < join_threshold { + join_partition_count += 1; + } + } + + let created_at_ms = metadata + .created_at + .map(|dt| dt.timestamp_millis()) + .unwrap_or(i64::MIN); + + let split_candidate = (split_partition_count > 0).then_some(SegmentRebalanceCandidate { + segment_id: metadata.uuid, + score: split_partition_count, + created_at_ms, + }); + if let Some(candidate) = split_candidate + && candidate_is_better(candidate, best_split) + { + best_split = Some(candidate); + } + + let join_candidate = (join_partition_count > 0).then_some(SegmentRebalanceCandidate { + segment_id: metadata.uuid, + score: join_partition_count, + created_at_ms, + }); + if let Some(candidate) = join_candidate + && candidate_is_better(candidate, best_join) + { + best_join = Some(candidate); + } + } + + let selected = best_split.or(best_join); + Ok(selected.map(|candidate| candidate.segment_id)) +} + // TODO: move to `lance-index` crate. /// /// Returns (new_uuid, num_indices_merged) From 8a0607933dd611c556d19aa56c21d94349ae7d93 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:07:49 -0700 Subject: [PATCH 140/206] chore(deps): bump aiohttp from 3.12.15 to 3.13.4 in /python (#6379) [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=aiohttp&package-manager=uv&previous-version=3.12.15&new-version=3.13.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/lance-format/lance/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/uv.lock | 210 ++++++++++++++++++++++++++++--------------------- 1 file changed, 122 insertions(+), 88 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 98153628542..55e274b03e9 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -30,7 +30,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.12.15" +version = "3.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs", marker = "python_full_version >= '3.10'" }, @@ -42,93 +42,127 @@ dependencies = [ { name = "propcache", marker = "python_full_version >= '3.10'" }, { name = "yarl", marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/dc/ef9394bde9080128ad401ac7ede185267ed637df03b51f05d14d1c99ad67/aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc", size = 703921, upload-time = "2025-07-29T05:49:43.584Z" }, - { url = "https://files.pythonhosted.org/packages/8f/42/63fccfc3a7ed97eb6e1a71722396f409c46b60a0552d8a56d7aad74e0df5/aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af", size = 480288, upload-time = "2025-07-29T05:49:47.851Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a2/7b8a020549f66ea2a68129db6960a762d2393248f1994499f8ba9728bbed/aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421", size = 468063, upload-time = "2025-07-29T05:49:49.789Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f5/d11e088da9176e2ad8220338ae0000ed5429a15f3c9dfd983f39105399cd/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79", size = 1650122, upload-time = "2025-07-29T05:49:51.874Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6b/b60ce2757e2faed3d70ed45dafee48cee7bfb878785a9423f7e883f0639c/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77", size = 1624176, upload-time = "2025-07-29T05:49:53.805Z" }, - { url = "https://files.pythonhosted.org/packages/dd/de/8c9fde2072a1b72c4fadecf4f7d4be7a85b1d9a4ab333d8245694057b4c6/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c", size = 1696583, upload-time = "2025-07-29T05:49:55.338Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ad/07f863ca3d895a1ad958a54006c6dafb4f9310f8c2fdb5f961b8529029d3/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4", size = 1738896, upload-time = "2025-07-29T05:49:57.045Z" }, - { url = "https://files.pythonhosted.org/packages/20/43/2bd482ebe2b126533e8755a49b128ec4e58f1a3af56879a3abdb7b42c54f/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6", size = 1643561, upload-time = "2025-07-29T05:49:58.762Z" }, - { url = "https://files.pythonhosted.org/packages/23/40/2fa9f514c4cf4cbae8d7911927f81a1901838baf5e09a8b2c299de1acfe5/aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2", size = 1583685, upload-time = "2025-07-29T05:50:00.375Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c3/94dc7357bc421f4fb978ca72a201a6c604ee90148f1181790c129396ceeb/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d", size = 1627533, upload-time = "2025-07-29T05:50:02.306Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3f/1f8911fe1844a07001e26593b5c255a685318943864b27b4e0267e840f95/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb", size = 1638319, upload-time = "2025-07-29T05:50:04.282Z" }, - { url = "https://files.pythonhosted.org/packages/4e/46/27bf57a99168c4e145ffee6b63d0458b9c66e58bb70687c23ad3d2f0bd17/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5", size = 1613776, upload-time = "2025-07-29T05:50:05.863Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7e/1d2d9061a574584bb4ad3dbdba0da90a27fdc795bc227def3a46186a8bc1/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b", size = 1693359, upload-time = "2025-07-29T05:50:07.563Z" }, - { url = "https://files.pythonhosted.org/packages/08/98/bee429b52233c4a391980a5b3b196b060872a13eadd41c3a34be9b1469ed/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065", size = 1716598, upload-time = "2025-07-29T05:50:09.33Z" }, - { url = "https://files.pythonhosted.org/packages/57/39/b0314c1ea774df3392751b686104a3938c63ece2b7ce0ba1ed7c0b4a934f/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1", size = 1644940, upload-time = "2025-07-29T05:50:11.334Z" }, - { url = "https://files.pythonhosted.org/packages/1b/83/3dacb8d3f8f512c8ca43e3fa8a68b20583bd25636ffa4e56ee841ffd79ae/aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a", size = 429239, upload-time = "2025-07-29T05:50:12.803Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f9/470b5daba04d558c9673ca2034f28d067f3202a40e17804425f0c331c89f/aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830", size = 452297, upload-time = "2025-07-29T05:50:14.266Z" }, - { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246, upload-time = "2025-07-29T05:50:15.937Z" }, - { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515, upload-time = "2025-07-29T05:50:17.442Z" }, - { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776, upload-time = "2025-07-29T05:50:19.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977, upload-time = "2025-07-29T05:50:21.665Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645, upload-time = "2025-07-29T05:50:23.333Z" }, - { url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437, upload-time = "2025-07-29T05:50:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482, upload-time = "2025-07-29T05:50:26.693Z" }, - { url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944, upload-time = "2025-07-29T05:50:28.382Z" }, - { url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020, upload-time = "2025-07-29T05:50:30.032Z" }, - { url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292, upload-time = "2025-07-29T05:50:31.983Z" }, - { url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451, upload-time = "2025-07-29T05:50:33.989Z" }, - { url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634, upload-time = "2025-07-29T05:50:35.846Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238, upload-time = "2025-07-29T05:50:37.597Z" }, - { url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701, upload-time = "2025-07-29T05:50:39.591Z" }, - { url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758, upload-time = "2025-07-29T05:50:41.292Z" }, - { url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868, upload-time = "2025-07-29T05:50:43.063Z" }, - { url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273, upload-time = "2025-07-29T05:50:44.613Z" }, - { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, - { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, - { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, - { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, - { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, - { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, - { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, - { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, - { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, - { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, - { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, - { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, - { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, - { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, - { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, - { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/18/8d/da08099af8db234d1cd43163e6ffc8e9313d0e988cee1901610f2fa5c764/aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98", size = 706829, upload-time = "2025-07-29T05:51:54.434Z" }, - { url = "https://files.pythonhosted.org/packages/4e/94/8eed385cfb60cf4fdb5b8a165f6148f3bebeb365f08663d83c35a5f273ef/aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406", size = 481806, upload-time = "2025-07-29T05:51:56.355Z" }, - { url = "https://files.pythonhosted.org/packages/38/68/b13e1a34584fbf263151b3a72a084e89f2102afe38df1dce5a05a15b83e9/aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d", size = 469205, upload-time = "2025-07-29T05:51:58.277Z" }, - { url = "https://files.pythonhosted.org/packages/38/14/3d7348bf53aa4af54416bc64cbef3a2ac5e8b9bfa97cc45f1cf9a94d9c8d/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf", size = 1644174, upload-time = "2025-07-29T05:52:00.23Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ed/fd9b5b22b0f6ca1a85c33bb4868cbcc6ae5eae070a0f4c9c5cad003c89d7/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6", size = 1618672, upload-time = "2025-07-29T05:52:02.272Z" }, - { url = "https://files.pythonhosted.org/packages/39/f7/f6530ab5f8c8c409e44a63fcad35e839c87aabecdfe5b8e96d671ed12f64/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142", size = 1692295, upload-time = "2025-07-29T05:52:04.546Z" }, - { url = "https://files.pythonhosted.org/packages/cb/dc/3cf483bb0106566dc97ebaa2bb097f5e44d4bc4ab650a6f107151cd7b193/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89", size = 1731609, upload-time = "2025-07-29T05:52:06.552Z" }, - { url = "https://files.pythonhosted.org/packages/de/a4/fd04bf807851197077d9cac9381d58f86d91c95c06cbaf9d3a776ac4467a/aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263", size = 1637852, upload-time = "2025-07-29T05:52:08.975Z" }, - { url = "https://files.pythonhosted.org/packages/98/03/29d626ca3bcdcafbd74b45d77ca42645a5c94d396f2ee3446880ad2405fb/aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530", size = 1572852, upload-time = "2025-07-29T05:52:11.508Z" }, - { url = "https://files.pythonhosted.org/packages/5f/cd/b4777a9e204f4e01091091027e5d1e2fa86decd0fee5067bc168e4fa1e76/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75", size = 1620813, upload-time = "2025-07-29T05:52:13.891Z" }, - { url = "https://files.pythonhosted.org/packages/ae/26/1a44a6e8417e84057beaf8c462529b9e05d4b53b8605784f1eb571f0ff68/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05", size = 1630951, upload-time = "2025-07-29T05:52:15.955Z" }, - { url = "https://files.pythonhosted.org/packages/dd/7f/10c605dbd01c40e2b27df7ef9004bec75d156f0705141e11047ecdfe264d/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54", size = 1607595, upload-time = "2025-07-29T05:52:18.089Z" }, - { url = "https://files.pythonhosted.org/packages/66/f6/2560dcb01731c1d7df1d34b64de95bc4b3ed02bb78830fd82299c1eb314e/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02", size = 1695194, upload-time = "2025-07-29T05:52:20.255Z" }, - { url = "https://files.pythonhosted.org/packages/e7/02/ee105ae82dc2b981039fd25b0cf6eaa52b493731960f9bc861375a72b463/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0", size = 1710872, upload-time = "2025-07-29T05:52:22.769Z" }, - { url = "https://files.pythonhosted.org/packages/88/16/70c4e42ed6a04f78fb58d1a46500a6ce560741d13afde2a5f33840746a5f/aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09", size = 1640539, upload-time = "2025-07-29T05:52:25.733Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1d/a7eb5fa8a6967117c5c0ad5ab4b1dec0d21e178c89aa08bc442a0b836392/aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d", size = 430164, upload-time = "2025-07-29T05:52:27.905Z" }, - { url = "https://files.pythonhosted.org/packages/14/25/e0cf8793aedc41c6d7f2aad646a27e27bdacafe3b402bb373d7651c94d73/aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8", size = 453370, upload-time = "2025-07-29T05:52:29.936Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/05/6817e0390eb47b0867cf8efdb535298191662192281bc3ca62a0cb7973eb/aiohttp-3.13.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6290fe12fe8cefa6ea3c1c5b969d32c010dfe191d4392ff9b599a3f473cbe722", size = 753094, upload-time = "2026-03-28T17:14:59.928Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/e5b7f25f6dd1ab57da92aa9d226b2c8b56f223dd20475d3ddfddaba86ab8/aiohttp-3.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7520d92c0e8fbbe63f36f20a5762db349ff574ad38ad7bc7732558a650439845", size = 505213, upload-time = "2026-03-28T17:15:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e5/8f42033c7ce98b54dfd3791f03e60231cfe4a2db4471b5fc188df2b8a6ad/aiohttp-3.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2710ae1e1b81d0f187883b6e9d66cecf8794b50e91aa1e73fc78bfb5503b5d9", size = 498580, upload-time = "2026-03-28T17:15:03.879Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/bbc989f5362066b81930da1a66084a859a971d03faab799dc59a3ce3a220/aiohttp-3.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:717d17347567ded1e273aa09918650dfd6fd06f461549204570c7973537d4123", size = 1692718, upload-time = "2026-03-28T17:15:05.541Z" }, + { url = "https://files.pythonhosted.org/packages/1c/72/3775116969931f151be116689d2ae6ddafff2ec2887d8f9b4e7043f32e74/aiohttp-3.13.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:383880f7b8de5ac208fa829c7038d08e66377283b2de9e791b71e06e803153c2", size = 1660714, upload-time = "2026-03-28T17:15:08.23Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e8/d2f1a2da2743e32fe348ebf8a4c59caad14a92f5f18af616fd33381275e1/aiohttp-3.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1867087e2c1963db1216aedf001efe3b129835ed2b05d97d058176a6d08b5726", size = 1744152, upload-time = "2026-03-28T17:15:10.828Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a6/575886f417ac3c08e462f2ca237cc49f436bd992ca3f7ff95b7dd9c44205/aiohttp-3.13.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6234bf416a38d687c3ab7f79934d7fb2a42117a5b9813aca07de0a5398489023", size = 1836278, upload-time = "2026-03-28T17:15:12.537Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4c/0051d4550fb9e8b5ca4e0fe1ccd58652340915180c5164999e6741bf2083/aiohttp-3.13.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdd3393130bf6588962441ffd5bde1d3ea2d63a64afa7119b3f3ba349cebbe7", size = 1687953, upload-time = "2026-03-28T17:15:14.248Z" }, + { url = "https://files.pythonhosted.org/packages/c9/54/841e87b8c51c2adc01a3ceb9919dc45c7899fe4c21deb70aada734ea5a38/aiohttp-3.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d0dbc6c76befa76865373d6aa303e480bb8c3486e7763530f7f6e527b471118", size = 1572484, upload-time = "2026-03-28T17:15:15.911Z" }, + { url = "https://files.pythonhosted.org/packages/da/f1/21cbf5f7fa1e267af6301f886cab9b314f085e4d0097668d189d165cd7da/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10fb7b53262cf4144a083c9db0d2b4d22823d6708270a9970c4627b248c6064c", size = 1662851, upload-time = "2026-03-28T17:15:17.822Z" }, + { url = "https://files.pythonhosted.org/packages/40/15/bcad6b68d7bef27ae7443288215767263c7753ede164267cf6cf63c94a87/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:eb10ce8c03850e77f4d9518961c227be569e12f71525a7e90d17bca04299921d", size = 1671984, upload-time = "2026-03-28T17:15:19.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/ab316931afc7a73c7f493bb1b30fbd61e28ec2d3ea50353336e76293e8ec/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7c65738ac5ae32b8feef699a4ed0dc91a0c8618b347781b7461458bbcaaac7eb", size = 1713880, upload-time = "2026-03-28T17:15:21.589Z" }, + { url = "https://files.pythonhosted.org/packages/1c/45/314e8e64c7f328174964b6db511dd5e9e60c9121ab5457bc2c908b7d03a4/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6b335919ffbaf98df8ff3c74f7a6decb8775882632952fd1810a017e38f15aee", size = 1560315, upload-time = "2026-03-28T17:15:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/18/e7/93d5fa06fe00219a81466577dacae9e3732f3b4f767b12b2e2cc8c35c970/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ec75fc18cb9f4aca51c2cbace20cf6716e36850f44189644d2d69a875d5e0532", size = 1735115, upload-time = "2026-03-28T17:15:25.77Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/f64b95392ddd4e204fd9ab7cd33dd18d14ac9e4b86866f1f6a69b7cda83d/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:463fa18a95c5a635d2b8c09babe240f9d7dbf2a2010a6c0b35d8c4dff2a0e819", size = 1673916, upload-time = "2026-03-28T17:15:27.526Z" }, + { url = "https://files.pythonhosted.org/packages/52/c1/bb33be79fd285c69f32e5b074b299cae8847f748950149c3965c1b3b3adf/aiohttp-3.13.4-cp310-cp310-win32.whl", hash = "sha256:13168f5645d9045522c6cef818f54295376257ed8d02513a37c2ef3046fc7a97", size = 440277, upload-time = "2026-03-28T17:15:29.173Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/7cf1688da4dd0885f914ee40bc8e1dce776df98fe6518766de975a570538/aiohttp-3.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:a7058af1f53209fdf07745579ced525d38d481650a989b7aa4a3b484b901cdab", size = 463015, upload-time = "2026-03-28T17:15:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" }, + { url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" }, + { url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" }, + { url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" }, + { url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" }, + { url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" }, + { url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, + { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" }, + { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, + { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, + { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, + { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, + { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, + { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f9/17e8a70abe874ec694395119338fde2f13ee1903bd14f3fd5b310b77a1ea/aiohttp-3.13.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b3f00bb9403728b08eb3951e982ca0a409c7a871d709684623daeab79465b181", size = 755716, upload-time = "2026-03-28T17:18:51.918Z" }, + { url = "https://files.pythonhosted.org/packages/27/b3/fdb36e59b9fb37297b1651248d3d84e61faa49af2faabc1e243d3f75585f/aiohttp-3.13.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cb15595eb52870f84248d7cc97013a76f52ab02ff74d394be093b1d9b8b82bc0", size = 506500, upload-time = "2026-03-28T17:18:54.755Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fb/dacf759c43cfb5fa32568bd369f054eeb23906ab23f4e3663e01e04c7988/aiohttp-3.13.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:907ad36b6a65cff7d88d7aca0f77c650546ba850a4f92c92ecb83590d4613249", size = 499881, upload-time = "2026-03-28T17:18:57.302Z" }, + { url = "https://files.pythonhosted.org/packages/52/cd/7824ee57dde8ca7f62e7fbc247ebe1aa3b5495d3598f0c516f06de1ef7ab/aiohttp-3.13.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5539ec0d6a3a5c6799b661b7e79166ad1b7ae71ccb59a92fcb6b4ef89295bc94", size = 1681734, upload-time = "2026-03-28T17:19:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/7a/40/6f4ca61736a16deed2d2762a8dbeaaa48ad292974489be2a2f32f62a4e0b/aiohttp-3.13.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b4e07d8803a70dd886b5f38588e5b49f894995ca8e132b06c31a2583ae2ef6e", size = 1653787, upload-time = "2026-03-28T17:19:03.026Z" }, + { url = "https://files.pythonhosted.org/packages/89/80/3793f0a1148a42190f6824ce9a0af79910cd3df8dfc58fa784234a7d9e41/aiohttp-3.13.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce7320a945aac4bf0bb8901600e4f9409eb602f25ce3ef4d275b48f6d704a862", size = 1737964, upload-time = "2026-03-28T17:19:05.77Z" }, + { url = "https://files.pythonhosted.org/packages/15/fd/e41981d0f9e0dccfb8f2580d4e64e6c59d293b9b0815849950cc499fe53a/aiohttp-3.13.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:26ed03f7d3d6453634729e2c7600d7255d65e879559c5a48fe1bb78355cde74b", size = 1832226, upload-time = "2026-03-28T17:19:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/fa/69/e6b566c638b37bfa14b98c2c429fcdba3b097a990acc9845fcc779ce39cc/aiohttp-3.13.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3f733916e85506b8000dddc071c6b82f8c68f56c99adb328d6550017db062d", size = 1681476, upload-time = "2026-03-28T17:19:11.502Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8c/f1b7f03e745fa6281dd949673297c7ac54d7cc54d2e58beb5135ac5c6204/aiohttp-3.13.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3d525648fe7c8b4977e460c18098f9f81d7991d72edfdc2f13cf96068f279bc", size = 1573061, upload-time = "2026-03-28T17:19:14.437Z" }, + { url = "https://files.pythonhosted.org/packages/bc/56/e7e972f1bed922297d72cc1d27bae6b2e28fdc2d6a895320e396a93c0f8a/aiohttp-3.13.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4e2e68085730a03704beb2cff035fa8648f62c9f93758d7e6d70add7f7bb5b3b", size = 1653248, upload-time = "2026-03-28T17:19:17.432Z" }, + { url = "https://files.pythonhosted.org/packages/cf/98/3d63d2f2e06808911e103d6d47c400548cf26a23dd3275de594339ff8e96/aiohttp-3.13.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:797613182ffaaca0b9ad5f3b3d3ce5d21242c768f75e66c750b8292bd97c9de3", size = 1666599, upload-time = "2026-03-28T17:19:20.17Z" }, + { url = "https://files.pythonhosted.org/packages/da/c8/31e487fb16d37c89cc6ee190a424b218471750ac48a227e042e200a17687/aiohttp-3.13.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2d15e7e4f1099d9e4d863eaf77a8eee5dcb002b7d7188061b0fbee37f845899e", size = 1709919, upload-time = "2026-03-28T17:19:22.872Z" }, + { url = "https://files.pythonhosted.org/packages/c1/86/3b742bd9204b7deb4f61e6723b1f42a8211ccc60dfddb3e52a6cd4329d46/aiohttp-3.13.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:19f60011ad60e40a01d242238bb335399e3a4d8df958c63cbb835add8d5c3b5a", size = 1560523, upload-time = "2026-03-28T17:19:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/72/63/6b80cef343a0527690588808d02aad7604cc4e23eaab207179e77dd607be/aiohttp-3.13.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c344c47e85678e410b064fc2ace14db86bb69db7ed5520c234bf13aed603ec30", size = 1731336, upload-time = "2026-03-28T17:19:29.02Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/9b39bc9609cac87e19b3394b7ed4bbab3787b434b14e012b9e16be64e9d5/aiohttp-3.13.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d904084985ca66459e93797e5e05985c048a9c0633655331144c089943e53d12", size = 1667646, upload-time = "2026-03-28T17:19:31.797Z" }, + { url = "https://files.pythonhosted.org/packages/21/72/3fb0ea857c891de89f6914f737f7423b7fa4dd1f46d8ce621eb07595ff4c/aiohttp-3.13.4-cp39-cp39-win32.whl", hash = "sha256:1746338dc2a33cf706cd7446575d13d451f28f9860bebc908c7632b22e71ae3f", size = 441019, upload-time = "2026-03-28T17:19:34.79Z" }, + { url = "https://files.pythonhosted.org/packages/b1/61/8a7191782a31ae3c7f7cee2cd2e37b3ee5849666767db116d449cfe20b88/aiohttp-3.13.4-cp39-cp39-win_amd64.whl", hash = "sha256:a5444dce2e6fba0a1dc2d58d026e674f25f21de178c6f844342629bcef019f2f", size = 464025, upload-time = "2026-03-28T17:19:37.362Z" }, ] [[package]] From 2c20d750a6a317ff237012f63f1e56539d76ae23 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 3 Apr 2026 11:15:55 -0700 Subject: [PATCH 141/206] feat: support index build progress callbacks in Python bindings (#6394) Expose Python progress callbacks for index creation and segment merge. Add the IndexProgress event type, pump Rust async progress events back into Python while waiting on index work --- python/python/lance/__init__.py | 2 + python/python/lance/dataset.py | 20 +- python/python/lance/lance/__init__.pyi | 7 +- python/python/lance/progress.py | 38 ++- python/python/tests/conftest.py | 36 +++ python/python/tests/test_scalar_index.py | 103 ++++++++ python/python/tests/test_vector_index.py | 56 +++++ python/src/dataset.rs | 289 +++++++++++++++++++++-- python/src/executor.rs | 74 ++++++ 9 files changed, 606 insertions(+), 19 deletions(-) diff --git a/python/python/lance/__init__.py b/python/python/lance/__init__.py index 806f7125c07..5b39bfcf2fa 100644 --- a/python/python/lance/__init__.py +++ b/python/python/lance/__init__.py @@ -37,6 +37,7 @@ DescribeTableRequest, LanceNamespace, ) +from .progress import IndexProgress from .schema import json_to_schema, schema_to_json from .util import sanitize_ts @@ -81,6 +82,7 @@ "set_logger", "write_dataset", "FFILanceTableProvider", + "IndexProgress", ] diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index 05ca528c855..377ac546c3a 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -79,7 +79,7 @@ from .commit import CommitLock from .lance.indices import IndexDescription - from .progress import FragmentWriteProgress + from .progress import FragmentWriteProgress, IndexProgress from .types import ReaderLike QueryVectorLike = Union[ @@ -2512,6 +2512,7 @@ def create_scalar_index( train: bool = True, fragment_ids: Optional[List[int]] = None, index_uuid: Optional[str] = None, + progress_callback: Optional[Callable[[IndexProgress], None]] = None, **kwargs, ): """Create a scalar index on a column. @@ -2613,6 +2614,9 @@ def create_scalar_index( A UUID to use for the segment written by this call. If not provided, a new UUID will be generated. This parameter is passed via kwargs internally. + progress_callback : callable, optional + A callback that receives :class:`lance.progress.IndexProgress` events while + the index is being built. with_position: bool, default False This is for the ``INVERTED`` index. If True, the index will store the @@ -2796,6 +2800,8 @@ def create_scalar_index( kwargs["fragment_ids"] = fragment_ids if index_uuid is not None: kwargs["index_uuid"] = index_uuid + if progress_callback is not None: + kwargs["progress_callback"] = progress_callback self._ds.create_index([column], index_type, name, replace, train, None, kwargs) @@ -3200,6 +3206,7 @@ def create_index( *, target_partition_size: Optional[int] = None, skip_transpose: bool = False, + progress_callback: Optional[Callable[[IndexProgress], None]] = None, **kwargs, ) -> LanceDataset: """Create index on column. @@ -3279,6 +3286,9 @@ def create_index( index_uuid : str, optional A UUID to use for the segment written by this call. If not provided, a new UUID will be generated. + progress_callback : callable, optional + A callback that receives :class:`lance.progress.IndexProgress` events while + the index is being built. target_partition_size: int, optional The target partition size. If set, the number of partitions will be computed based on the target partition size. @@ -3380,6 +3390,8 @@ def create_index( `_ """ + if progress_callback is not None: + kwargs["progress_callback"] = progress_callback self._create_index_impl( column, index_type, @@ -3526,6 +3538,7 @@ def merge_index_metadata( index_uuid: str, index_type: str, batch_readhead: Optional[int] = None, + progress_callback: Optional[Callable[[IndexProgress], None]] = None, ): """ Merge distributed scalar index metadata. @@ -3553,6 +3566,9 @@ def merge_index_metadata( supported by scalar distributed merge. batch_readhead: int, optional Prefetch concurrency used by BTREE merge reader. Default: 1. + progress_callback: callable, optional + A callback that receives :class:`lance.progress.IndexProgress` events while + metadata is being merged. """ # Normalize type t = index_type.upper() @@ -3564,7 +3580,7 @@ def merge_index_metadata( ) # Merge physical index files at the index directory - self._ds.merge_index_metadata(index_uuid, t, batch_readhead) + self._ds.merge_index_metadata(index_uuid, t, batch_readhead, progress_callback) return None def merge_existing_index_segments(self, segments: List[Index]) -> Index: diff --git a/python/python/lance/lance/__init__.pyi b/python/python/lance/lance/__init__.pyi index 14c954dcd66..834b2eef7d8 100644 --- a/python/python/lance/lance/__init__.pyi +++ b/python/python/lance/lance/__init__.pyi @@ -48,6 +48,7 @@ from ..fragment import ( FragmentMetadata, ) from ..progress import FragmentWriteProgress as FragmentWriteProgress +from ..progress import IndexProgress as IndexProgress from ..types import ReaderLike as ReaderLike from ..udf import BatchUDF as BatchUDF from .debug import format_fragment as format_fragment @@ -362,7 +363,11 @@ class _Dataset: def drop_index(self, name: str): ... def prewarm_index(self, name: str): ... def merge_index_metadata( - self, index_uuid: str, index_type: str, batch_readhead: Optional[int] = None + self, + index_uuid: str, + index_type: str, + batch_readhead: Optional[int] = None, + progress_callback: Optional[Callable[[IndexProgress], None]] = None, ): ... def merge_existing_index_segments(self, segments: List[Index]) -> Index: ... def commit_existing_index_segments( diff --git a/python/python/lance/progress.py b/python/python/lance/progress.py index 0c428638648..e5c1d934d0b 100644 --- a/python/python/lance/progress.py +++ b/python/python/lance/progress.py @@ -8,13 +8,49 @@ import json import os from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Dict, Optional +from dataclasses import dataclass +from typing import TYPE_CHECKING, Dict, Literal, Optional if TYPE_CHECKING: # We don't import directly because of circular import from .fragment import FragmentMetadata +@dataclass(frozen=True) +class IndexProgress: + """Progress event emitted while creating or merging an index. + + Parameters + ---------- + event : str + The type of event. One of ``"start"``, ``"progress"``, or ``"complete"``. + stage : str + The stage name. Stage names are index-type-specific. + completed : int, optional + The amount of work completed so far, if known. + total : int, optional + The total amount of work for the stage, if known. + unit : str + The unit of work for ``completed`` / ``total``. + """ + + event: Literal["start", "progress", "complete"] + stage: str + completed: Optional[int] = None + total: Optional[int] = None + unit: str = "" + + @property + def fraction(self) -> Optional[float]: + """Return fractional progress if both ``completed`` and ``total`` are known. + + Returns ``None`` when ``total`` is ``None`` or ``0`` (treated as unknown). + """ + if self.completed is None or self.total in (None, 0): + return None + return min(self.completed / self.total, 1.0) + + class FragmentWriteProgress(ABC): """Progress tracking for Writing a Dataset or Fragment. diff --git a/python/python/tests/conftest.py b/python/python/tests/conftest.py index 49a6eeaa490..3790535efc7 100644 --- a/python/python/tests/conftest.py +++ b/python/python/tests/conftest.py @@ -1,10 +1,46 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors import sys +from typing import Optional import pytest +class ProgressRecorder: + """Reusable progress callback recorder for index build tests.""" + + def __init__( + self, + fail_after: Optional[int] = None, + fail_on_tag: Optional[str] = None, + ): + self.events = [] + self.fail_after = fail_after + self.fail_on_tag = fail_on_tag + + def __call__(self, event): + self.events.append(event) + event_tag = f"{event.event}:{event.stage}" + if self.fail_on_tag is not None and event_tag == self.fail_on_tag: + raise RuntimeError("progress callback failure") + if self.fail_after is not None and len(self.events) >= self.fail_after: + raise RuntimeError("progress callback failure") + + +def progress_event_tags(events): + return [f"{event.event}:{event.stage}" for event in events] + + +def stage_progress_values(events, stage): + return [ + event.completed + for event in events + if event.event == "progress" + and event.stage == stage + and event.completed is not None + ] + + @pytest.fixture(params=(True, False)) def provide_pandas(request, monkeypatch): if not request.param: diff --git a/python/python/tests/test_scalar_index.py b/python/python/tests/test_scalar_index.py index e25f43ffdc0..4b7cdc4b7fd 100644 --- a/python/python/tests/test_scalar_index.py +++ b/python/python/tests/test_scalar_index.py @@ -7,6 +7,7 @@ import re import shutil import string +import uuid import zipfile from datetime import date, datetime, timedelta from pathlib import Path @@ -15,6 +16,7 @@ import numpy as np import pyarrow as pa import pytest +from conftest import ProgressRecorder, progress_event_tags, stage_progress_values from lance.indices import IndexConfig from lance.query import ( BooleanQuery, @@ -262,6 +264,107 @@ def test_indexed_between(tmp_path): assert actual_data.num_rows == 0 +def test_create_inverted_index_progress_callback(tmp_path): + ds = generate_multi_fragment_dataset( + tmp_path, num_fragments=2, rows_per_fragment=75 + ) + progress_recorder = ProgressRecorder() + + ds.create_scalar_index( + column="text", + index_type="INVERTED", + remove_stop_words=False, + progress_callback=progress_recorder, + ) + + tags = progress_event_tags(progress_recorder.events) + expected_order = [ + "start:load_data", + "complete:load_data", + "start:tokenize_docs", + "complete:tokenize_docs", + "start:copy_partitions", + "complete:copy_partitions", + "start:write_metadata", + "complete:write_metadata", + ] + positions = [tags.index(tag) for tag in expected_order] + assert positions == sorted(positions) + + tokenize_progress = stage_progress_values(progress_recorder.events, "tokenize_docs") + assert tokenize_progress + assert tokenize_progress[-1] == ds.count_rows() + + assert "progress:copy_partitions" in tags + assert "progress:write_metadata" in tags + assert "start:merge_partitions" not in tags + + +def test_merge_index_metadata_inverted_index_progress_callback(tmp_path): + ds = generate_multi_fragment_dataset( + tmp_path, num_fragments=3, rows_per_fragment=60 + ) + + inverted_index_id = str(uuid.uuid4()) + for fragment in ds.get_fragments(): + ds.create_scalar_index( + column="text", + index_type="INVERTED", + name="text_inverted_progress_idx", + replace=False, + index_uuid=inverted_index_id, + fragment_ids=[fragment.fragment_id], + remove_stop_words=False, + ) + + progress_recorder = ProgressRecorder() + ds.merge_index_metadata( + inverted_index_id, + index_type="INVERTED", + progress_callback=progress_recorder, + ) + + tags = progress_event_tags(progress_recorder.events) + expected_order = [ + "start:read_partition_metadata", + "complete:read_partition_metadata", + "start:remap_partition_files", + "complete:remap_partition_files", + "start:write_merged_metadata", + "complete:write_merged_metadata", + ] + positions = [tags.index(tag) for tag in expected_order] + assert positions == sorted(positions) + + metadata_progress = stage_progress_values( + progress_recorder.events, "read_partition_metadata" + ) + assert metadata_progress + assert metadata_progress[-1] == len(ds.get_fragments()) + assert "progress:remap_partition_files" in tags + assert "progress:write_merged_metadata" in tags + + +def test_create_inverted_index_progress_callback_error_after_completion_is_ignored( + tmp_path, +): + ds = generate_multi_fragment_dataset( + tmp_path, num_fragments=2, rows_per_fragment=75 + ) + progress_recorder = ProgressRecorder(fail_on_tag="complete:write_metadata") + + ds.create_scalar_index( + column="text", + index_type="INVERTED", + remove_stop_words=False, + progress_callback=progress_recorder, + ) + + tags = progress_event_tags(progress_recorder.events) + assert tags[-1] == "complete:write_metadata" + assert any(idx.index_type == "Inverted" for idx in ds.describe_indices()) + + def test_index_combination(tmp_path): # This test regresses a bug in the index combination logic. diff --git a/python/python/tests/test_vector_index.py b/python/python/tests/test_vector_index.py index b000a99bd0e..ebaa8534abc 100644 --- a/python/python/tests/test_vector_index.py +++ b/python/python/tests/test_vector_index.py @@ -17,6 +17,7 @@ import pyarrow as pa import pyarrow.compute as pc import pytest +from conftest import ProgressRecorder, progress_event_tags, stage_progress_values from lance import LanceDataset, LanceFragment from lance.dataset import VectorIndexReader from lance.indices import IndexFileVersion, IndicesBuilder @@ -183,6 +184,61 @@ def test_ann(indexed_dataset): run(indexed_dataset) +def test_create_index_progress_callback_vector(tmp_path): + ds = _make_sample_dataset_base(tmp_path, "vector_progress", 1500, 128) + recorder = ProgressRecorder() + + ds.create_index( + column="vector", + index_type="IVF_PQ", + num_partitions=4, + num_sub_vectors=4, + progress_callback=recorder, + ) + + tags = progress_event_tags(recorder.events) + expected_order = [ + "start:train_ivf", + "complete:train_ivf", + "start:train_quantizer", + "complete:train_quantizer", + "start:shuffle", + "complete:shuffle", + "start:merge_partitions", + "complete:merge_partitions", + ] + positions = [tags.index(tag) for tag in expected_order] + assert positions == sorted(positions) + + shuffle_progress = stage_progress_values(recorder.events, "shuffle") + assert shuffle_progress + assert shuffle_progress[-1] == ds.count_rows() + + merge_progress = stage_progress_values(recorder.events, "merge_partitions") + assert merge_progress + assert merge_progress[-1] == 4 + + +def test_create_index_progress_callback_error_after_completion_is_ignored(tmp_path): + ds = _make_sample_dataset_base( + tmp_path, "vector_progress_post_commit_error", 1500, 128 + ) + recorder = ProgressRecorder(fail_on_tag="complete:merge_partitions") + + ds.create_index( + column="vector", + index_type="IVF_PQ", + num_partitions=4, + num_sub_vectors=4, + progress_callback=recorder, + ) + + tags = progress_event_tags(recorder.events) + assert tags[-1] == "complete:merge_partitions" + assert ds.has_index + assert ds.describe_indices()[0].field_names == ["vector"] + + def test_distributed_ivf_pq_partition_window_env_override(tmp_path, monkeypatch): # Keep this before other distributed vector merge tests so the process-level # lazy window size initialization reads this override. diff --git a/python/src/dataset.rs b/python/src/dataset.rs index d1b547471bd..8838b89bc0a 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -3,7 +3,8 @@ use std::collections::HashMap; use std::str; -use std::sync::Arc; +use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex}; use arrow::array::AsArray; use arrow::datatypes::UInt8Type; @@ -70,7 +71,7 @@ use lance_index::scalar::inverted::query::{ use lance_index::{ IndexParams, IndexType, optimize::OptimizeOptions, - progress::NoopIndexBuildProgress, + progress::{IndexBuildProgress, NoopIndexBuildProgress}, scalar::{FullTextSearchQuery, InvertedIndexParams, ScalarIndexParams}, vector::{ Query as VectorQuery, hnsw::builder::HnswBuildParams, ivf::IvfBuildParams, @@ -115,6 +116,7 @@ pub mod stats; const DEFAULT_NPROBES: usize = 1; const LANCE_COMMIT_MESSAGE_KEY: &str = "__lance_commit_message"; +const INDEX_PROGRESS_QUEUE_SIZE: usize = 1024; fn convert_reader(reader: &Bound) -> PyResult> { let py = reader.py(); @@ -1986,6 +1988,7 @@ impl Dataset { let replace = replace.unwrap_or(true); let train = train.unwrap_or(true); // Default to true for backward compatibility + let mut progress_handler = Self::make_index_progress_handler_from_kwargs(kwargs)?; let mut new_self = self.ds.as_ref().clone(); let mut builder = new_self .create_index_builder(&columns, idx_type, params.as_ref()) @@ -2022,6 +2025,9 @@ impl Dataset { if let Some(index_uuid) = index_uuid { builder = builder.index_uuid(index_uuid); } + if let Some(progress_handler) = progress_handler.as_ref() { + builder = builder.progress(progress_handler.progress.clone()); + } use std::future::IntoFuture; @@ -2029,11 +2035,13 @@ impl Dataset { let index_metadata = if has_fragment_ids { // For fragment-level indexing, use execute_uncommitted // Note: We don't update self.ds here as the index is not committed - rt().block_on(None, builder.execute_uncommitted())? + Self::run_index_future(builder.execute_uncommitted(), progress_handler.as_mut())? .infer_error()? } else { // For regular indexing, use the standard execute path - let index_metadata = rt().block_on(None, builder.into_future())?.infer_error()?; + let index_metadata = + Self::run_index_future(builder.into_future(), progress_handler.as_mut())? + .infer_error()?; self.ds = Arc::new(new_self); index_metadata }; @@ -2089,23 +2097,34 @@ impl Dataset { .infer_error() } - #[pyo3(signature = (index_uuid, index_type, batch_readhead=None))] + #[pyo3(signature = (index_uuid, index_type, batch_readhead=None, progress_callback=None))] fn merge_index_metadata( &self, index_uuid: &str, index_type: &str, batch_readhead: Option, + progress_callback: Option<&Bound<'_, PyAny>>, ) -> PyResult<()> { - rt().block_on(None, async { - self.ds - .merge_index_metadata( - index_uuid, - IndexType::try_from(index_type)?, - batch_readhead, - Arc::new(NoopIndexBuildProgress), - ) - .await - })? + let mut progress_handler = + Self::make_index_progress_handler_from_callback(progress_callback)?; + let progress: Arc = progress_handler + .as_ref() + .map(|handler| handler.progress.clone() as Arc) + .unwrap_or_else(|| Arc::new(NoopIndexBuildProgress)); + + Self::run_index_future( + async { + self.ds + .merge_index_metadata( + index_uuid, + IndexType::try_from(index_type)?, + batch_readhead, + progress, + ) + .await + }, + progress_handler.as_mut(), + )? .map_err(|err| PyValueError::new_err(err.to_string())) } @@ -2943,6 +2962,190 @@ impl PyWriteDest { } } +#[derive(Debug, Clone, Copy)] +enum IndexProgressEventType { + Start, + Progress, + Complete, +} + +impl IndexProgressEventType { + fn as_str(self) -> &'static str { + match self { + Self::Start => "start", + Self::Progress => "progress", + Self::Complete => "complete", + } + } +} + +#[derive(Debug, Clone)] +struct IndexProgressEvent { + event: IndexProgressEventType, + stage: String, + completed: Option, + total: Option, + unit: String, +} + +#[derive(Debug, Default)] +struct ActiveIndexProgressStage { + stage: Option, + completed: Option, + total: Option, + unit: String, +} + +#[derive(Debug)] +struct PyIndexBuildProgress { + sender: SyncSender, + active_stage: Mutex, +} + +impl PyIndexBuildProgress { + fn new(sender: SyncSender) -> Self { + Self { + sender, + active_stage: Mutex::new(ActiveIndexProgressStage::default()), + } + } + + /// Send a lifecycle event (start/complete) with blocking semantics. + /// + /// These events are rare relative to the channel capacity + /// (`INDEX_PROGRESS_QUEUE_SIZE`), so blocking here will not stall + /// the tokio runtime in practice. Progress events use the + /// non-blocking `try_send_progress_event` instead. + fn send_event(&self, event: IndexProgressEvent) -> lance::Result<()> { + self.sender.send(event).map_err(|_| { + Error::invalid_input("Index progress callback receiver disconnected".to_string()) + }) + } + + fn try_send_progress_event(&self, event: IndexProgressEvent) -> lance::Result<()> { + match self.sender.try_send(event) { + Ok(()) | Err(TrySendError::Full(_)) => Ok(()), + Err(TrySendError::Disconnected(_)) => Err(Error::invalid_input( + "Index progress callback receiver disconnected".to_string(), + )), + } + } +} + +#[async_trait] +impl IndexBuildProgress for PyIndexBuildProgress { + async fn stage_start(&self, stage: &str, total: Option, unit: &str) -> lance::Result<()> { + { + let mut active_stage = self.active_stage.lock().unwrap(); + *active_stage = ActiveIndexProgressStage { + stage: Some(stage.to_string()), + completed: Some(0), + total, + unit: unit.to_string(), + }; + } + + self.send_event(IndexProgressEvent { + event: IndexProgressEventType::Start, + stage: stage.to_string(), + completed: Some(0), + total, + unit: unit.to_string(), + }) + } + + async fn stage_progress(&self, stage: &str, completed: u64) -> lance::Result<()> { + let event = { + let mut active_stage = self.active_stage.lock().unwrap(); + if active_stage.stage.is_none() { + active_stage.stage = Some(stage.to_string()); + } + active_stage.completed = Some(completed); + IndexProgressEvent { + event: IndexProgressEventType::Progress, + stage: stage.to_string(), + completed: Some(completed), + total: active_stage.total, + unit: active_stage.unit.clone(), + } + }; + + self.try_send_progress_event(event) + } + + async fn stage_complete(&self, stage: &str) -> lance::Result<()> { + let event = { + let mut active_stage = self.active_stage.lock().unwrap(); + let completed = active_stage.completed.or(active_stage.total); + let total = active_stage.total; + let unit = active_stage.unit.clone(); + *active_stage = ActiveIndexProgressStage::default(); + IndexProgressEvent { + event: IndexProgressEventType::Complete, + stage: stage.to_string(), + completed, + total, + unit, + } + }; + + self.send_event(event) + } +} + +struct IndexProgressDispatcher { + callback: Py, + index_progress_cls: Py, + receiver: Receiver, +} + +impl IndexProgressDispatcher { + fn new( + py: Python<'_>, + callback: Py, + receiver: Receiver, + ) -> PyResult { + let index_progress_cls = py + .import("lance.progress")? + .getattr("IndexProgress")? + .unbind(); + Ok(Self { + callback, + index_progress_cls, + receiver, + }) + } + + fn drain(&mut self) -> PyResult<()> { + while let Ok(event) = self.receiver.try_recv() { + self.dispatch(event)?; + } + Ok(()) + } + + fn dispatch(&self, event: IndexProgressEvent) -> PyResult<()> { + Python::attach(|py| { + let progress = self.index_progress_cls.call1( + py, + ( + event.event.as_str(), + event.stage, + event.completed, + event.total, + event.unit, + ), + )?; + self.callback.call1(py, (progress,))?; + Ok(()) + }) + } +} + +struct IndexProgressHandler { + progress: Arc, + dispatcher: IndexProgressDispatcher, +} + impl Dataset { fn transform_ref(&self, reference: Option>) -> PyResult { if let Some(reference) = reference { @@ -3035,6 +3238,62 @@ impl Dataset { }); })) } + + fn make_index_progress_handler( + py: Python<'_>, + callback: Py, + ) -> PyResult { + if !callback.bind(py).is_callable() { + return Err(PyValueError::new_err("Progress callback must be callable")); + } + + let (sender, receiver) = mpsc::sync_channel(INDEX_PROGRESS_QUEUE_SIZE); + let progress = Arc::new(PyIndexBuildProgress::new(sender)); + let dispatcher = IndexProgressDispatcher::new(py, callback, receiver)?; + Ok(IndexProgressHandler { + progress, + dispatcher, + }) + } + + fn make_index_progress_handler_from_kwargs( + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult> { + let Some(kwargs) = kwargs else { + return Ok(None); + }; + + let Some(callback) = get_dict_opt::>(kwargs, "progress_callback")? else { + return Ok(None); + }; + + Self::make_index_progress_handler(kwargs.py(), callback).map(Some) + } + + fn make_index_progress_handler_from_callback( + callback: Option<&Bound<'_, PyAny>>, + ) -> PyResult> { + let Some(callback) = callback else { + return Ok(None); + }; + + Self::make_index_progress_handler(callback.py(), callback.clone().unbind()).map(Some) + } + + fn run_index_future( + future: F, + progress_handler: Option<&mut IndexProgressHandler>, + ) -> PyResult + where + F: std::future::Future + Send, + F::Output: Send, + { + if let Some(progress_handler) = progress_handler { + rt().block_on_pumping(None, future, || progress_handler.dispatcher.drain()) + } else { + rt().block_on(None, future) + } + } } #[pyfunction(name = "_write_dataset")] diff --git a/python/src/executor.rs b/python/src/executor.rs index c3393b0ec72..e2e573a8193 100644 --- a/python/src/executor.rs +++ b/python/src/executor.rs @@ -173,6 +173,80 @@ impl BackgroundExecutor { } } + /// Block on a future, periodically draining a caller-provided pump. + /// + /// This is intended for progress callbacks where the future emits events from + /// background tasks and the caller needs to run Python code while waiting for + /// the future to complete. + /// + /// Unlike [`block_on`], this method does **not** wrap the future in + /// `result_or_interrupt`, so keyboard interrupts are only checked on the + /// main thread between poll intervals (`SIGNAL_CHECK_INTERVAL`). This is + /// acceptable because index operations yield frequently to emit progress + /// events. + pub fn block_on_pumping( + &self, + py: Option>, + future: F, + mut pump: P, + ) -> PyResult + where + F: Future + Send, + F::Output: Send, + P: FnMut() -> PyResult<()>, + { + let mut future = std::pin::pin!(future); + + loop { + pump()?; + + let signal_check = match Python::try_attach(|py| py.check_signals()) { + Some(result) => result, + None => Ok(()), + }; + signal_check?; + + let maybe_output = if let Some(py) = py { + py.detach(|| { + self.runtime.block_on(async { + tokio::select! { + result = &mut future => Some(result), + _ = tokio::time::sleep(SIGNAL_CHECK_INTERVAL) => None, + } + }) + }) + } else if let Some(result) = Python::try_attach(|py| { + py.detach(|| { + self.runtime.block_on(async { + tokio::select! { + result = &mut future => Some(result), + _ = tokio::time::sleep(SIGNAL_CHECK_INTERVAL) => None, + } + }) + }) + }) { + result + } else { + self.runtime.block_on(async { + tokio::select! { + result = &mut future => Some(result), + _ = tokio::time::sleep(SIGNAL_CHECK_INTERVAL) => None, + } + }) + }; + + if let Some(output) = maybe_output { + if let Err(err) = pump() { + log::warn!( + "Ignoring progress callback error after operation completed successfully: {}", + err + ); + } + return Ok(output); + } + } + } + async fn result_or_interrupt(future: F) -> PyResult where F: Future + Send, From 3c8fe537ec8ac7e1809ed4afcd390e323179aa6d Mon Sep 17 00:00:00 2001 From: ForwardXu Date: Sat, 4 Apr 2026 15:27:33 +0800 Subject: [PATCH 142/206] feat(namespace): add count_table_rows, insert_into_table, query_table (#6132) close https://github.com/lance-format/lance/issues/6111 - Implement `count_table_rows` with optional version checkout and predicate filter - Implement `insert_into_table` with append/overwrite modes via Arrow IPC - Implement `query_table` supporting vector similarity search (with distance type, nprobes, refine factor, prefilter) and plain scan with filter/limit/offset/version - Add `lance-linalg` dependency for `DistanceType` in vector search - Add 8 unit tests covering all new methods and edge cases ## Test plan - [x] `cargo test -p lance-namespace-impls` passes all new tests - [x] count_table_rows returns correct count with and without predicate filter - [x] insert_into_table correctly appends and overwrites data - [x] query_table returns correct results for vector search and plain scan --------- Co-authored-by: Jack Ye --- java/dev/checkstyle.xml | 3 + java/lance-jni/src/namespace.rs | 617 +++++++++++- .../lance/namespace/DirectoryNamespace.java | 253 ++++- .../org/lance/namespace/RestNamespace.java | 242 ++++- .../java/org/lance/TestVectorDataset.java | 13 +- .../org/lance/namespace/CustomNamespace.java | 88 +- .../namespace/DirectoryNamespaceTest.java | 346 ++++++- .../namespace/DynamicContextProviderTest.java | 5 +- .../lance/namespace/RestNamespaceTest.java | 19 +- python/python/lance/namespace.py | 679 +++++++++++++ python/python/tests/test_namespace_dir.py | 327 +++++++ python/src/namespace.rs | 690 ++++++++++++- rust/lance-namespace-impls/src/dir.rs | 908 +++++++++++++++++- .../lance-namespace-impls/src/rest_adapter.rs | 250 ++++- 14 files changed, 4404 insertions(+), 36 deletions(-) diff --git a/java/dev/checkstyle.xml b/java/dev/checkstyle.xml index dd5fe8cab62..9a0e1f5fb1a 100644 --- a/java/dev/checkstyle.xml +++ b/java/dev/checkstyle.xml @@ -134,6 +134,9 @@ + + + diff --git a/java/lance-jni/src/namespace.rs b/java/lance-jni/src/namespace.rs index 7ee97587c06..cc913761bf8 100644 --- a/java/lance-jni/src/namespace.rs +++ b/java/lance-jni/src/namespace.rs @@ -1859,6 +1859,23 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_declareTableN .into_raw() } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_renameTableNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.rename_table(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + #[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_insertIntoTableNative( mut env: JNIEnv, @@ -2109,6 +2126,278 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_batchDeleteTa .into_raw() } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableScalarIndexNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.create_table_scalar_index(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_dropTableIndexNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.drop_table_index(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listAllTablesNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_all_tables(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_restoreTableNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.restore_table(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_updateTableSchemaMetadataNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.update_table_schema_metadata(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_getTableStatsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.get_table_stats(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_explainTableQueryPlanNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_string_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.explain_table_query_plan(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_analyzeTableQueryPlanNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_string_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.analyze_table_query_plan(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_alterTableAddColumnsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.alter_table_add_columns(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_alterTableAlterColumnsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.alter_table_alter_columns(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_alterTableDropColumnsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.alter_table_drop_columns(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listTableTagsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_table_tags(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_getTableTagVersionNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.get_table_tag_version(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableTagNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.create_table_tag(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_deleteTableTagNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.delete_table_tag(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_updateTableTagNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.update_table_tag(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + #[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_retrieveOpsMetricsNative( mut env: JNIEnv, @@ -2737,11 +3026,293 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_batchDeleteTableVe } #[unsafe(no_mangle)] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_retrieveOpsMetricsNative( +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableScalarIndexNative( mut env: JNIEnv, _obj: JObject, handle: jlong, -) -> jobject { + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.create_table_scalar_index(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_dropTableIndexNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.drop_table_index(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_listAllTablesNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_all_tables(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_restoreTableNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.restore_table(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_updateTableSchemaMetadataNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.update_table_schema_metadata(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_getTableStatsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.get_table_stats(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_explainTableQueryPlanNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_string_method( + &mut env, + handle, + request_json, + |namespace_client, req| { + RT.block_on(namespace_client.inner.explain_table_query_plan(req)) + } + ), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_analyzeTableQueryPlanNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_string_method( + &mut env, + handle, + request_json, + |namespace_client, req| { + RT.block_on(namespace_client.inner.analyze_table_query_plan(req)) + } + ), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_alterTableAddColumnsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.alter_table_add_columns(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_alterTableAlterColumnsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.alter_table_alter_columns(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_alterTableDropColumnsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.alter_table_drop_columns(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_listTableTagsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.list_table_tags(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_getTableTagVersionNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.get_table_tag_version(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableTagNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.create_table_tag(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_deleteTableTagNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.delete_table_tag(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_updateTableTagNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jstring { + ok_or_throw_with_return!( + env, + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + RT.block_on(namespace_client.inner.update_table_tag(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_retrieveOpsMetricsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, +) -> jobject { ok_or_throw_with_return!( env, { @@ -2817,6 +3388,27 @@ where Ok(()) } +/// Helper function for methods that return String directly (not JSON-serialized) (DirectoryNamespace) +fn call_namespace_string_method<'local, Req, F>( + env: &mut JNIEnv<'local>, + handle: jlong, + request_json: JString, + f: F, +) -> Result> +where + Req: for<'de> Deserialize<'de>, + F: FnOnce(&BlockingDirectoryNamespace, Req) -> lance_core::Result, +{ + let namespace = unsafe { &*(handle as *const BlockingDirectoryNamespace) }; + let request_str: String = env.get_string(&request_json)?.into(); + let request: Req = serde_json::from_str(&request_str) + .map_err(|e| Error::input_error(format!("Failed to parse request JSON: {}", e)))?; + + let response = f(namespace, request).map_err(Error::from)?; + + env.new_string(response).map_err(Into::into) +} + /// Helper function for count methods (DirectoryNamespace) fn call_namespace_count_method( env: &mut JNIEnv, @@ -2929,6 +3521,27 @@ where Ok(()) } +/// Helper function for methods that return String directly (not JSON-serialized) (RestNamespace) +fn call_rest_namespace_string_method<'local, Req, F>( + env: &mut JNIEnv<'local>, + handle: jlong, + request_json: JString, + f: F, +) -> Result> +where + Req: for<'de> Deserialize<'de>, + F: FnOnce(&BlockingRestNamespace, Req) -> lance_core::Result, +{ + let namespace = unsafe { &*(handle as *const BlockingRestNamespace) }; + let request_str: String = env.get_string(&request_json)?.into(); + let request: Req = serde_json::from_str(&request_str) + .map_err(|e| Error::input_error(format!("Failed to parse request JSON: {}", e)))?; + + let response = f(namespace, request).map_err(Error::from)?; + + env.new_string(response).map_err(Into::into) +} + /// Helper function for count methods (RestNamespace) fn call_rest_namespace_count_method( env: &mut JNIEnv, diff --git a/java/src/main/java/org/lance/namespace/DirectoryNamespace.java b/java/src/main/java/org/lance/namespace/DirectoryNamespace.java index 744357e817e..7bd5f9c7f9f 100644 --- a/java/src/main/java/org/lance/namespace/DirectoryNamespace.java +++ b/java/src/main/java/org/lance/namespace/DirectoryNamespace.java @@ -14,7 +14,87 @@ package org.lance.namespace; import org.lance.JniLoader; -import org.lance.namespace.model.*; +import org.lance.namespace.model.AlterTableAddColumnsRequest; +import org.lance.namespace.model.AlterTableAddColumnsResponse; +import org.lance.namespace.model.AlterTableAlterColumnsRequest; +import org.lance.namespace.model.AlterTableAlterColumnsResponse; +import org.lance.namespace.model.AlterTableDropColumnsRequest; +import org.lance.namespace.model.AlterTableDropColumnsResponse; +import org.lance.namespace.model.AlterTransactionRequest; +import org.lance.namespace.model.AlterTransactionResponse; +import org.lance.namespace.model.AnalyzeTableQueryPlanRequest; +import org.lance.namespace.model.BatchDeleteTableVersionsRequest; +import org.lance.namespace.model.BatchDeleteTableVersionsResponse; +import org.lance.namespace.model.CountTableRowsRequest; +import org.lance.namespace.model.CreateNamespaceRequest; +import org.lance.namespace.model.CreateNamespaceResponse; +import org.lance.namespace.model.CreateTableIndexRequest; +import org.lance.namespace.model.CreateTableIndexResponse; +import org.lance.namespace.model.CreateTableRequest; +import org.lance.namespace.model.CreateTableResponse; +import org.lance.namespace.model.CreateTableScalarIndexResponse; +import org.lance.namespace.model.CreateTableTagRequest; +import org.lance.namespace.model.CreateTableTagResponse; +import org.lance.namespace.model.CreateTableVersionRequest; +import org.lance.namespace.model.CreateTableVersionResponse; +import org.lance.namespace.model.DeclareTableRequest; +import org.lance.namespace.model.DeclareTableResponse; +import org.lance.namespace.model.DeleteFromTableRequest; +import org.lance.namespace.model.DeleteFromTableResponse; +import org.lance.namespace.model.DeleteTableTagRequest; +import org.lance.namespace.model.DeleteTableTagResponse; +import org.lance.namespace.model.DeregisterTableRequest; +import org.lance.namespace.model.DeregisterTableResponse; +import org.lance.namespace.model.DescribeNamespaceRequest; +import org.lance.namespace.model.DescribeNamespaceResponse; +import org.lance.namespace.model.DescribeTableIndexStatsRequest; +import org.lance.namespace.model.DescribeTableIndexStatsResponse; +import org.lance.namespace.model.DescribeTableRequest; +import org.lance.namespace.model.DescribeTableResponse; +import org.lance.namespace.model.DescribeTableVersionRequest; +import org.lance.namespace.model.DescribeTableVersionResponse; +import org.lance.namespace.model.DescribeTransactionRequest; +import org.lance.namespace.model.DescribeTransactionResponse; +import org.lance.namespace.model.DropNamespaceRequest; +import org.lance.namespace.model.DropNamespaceResponse; +import org.lance.namespace.model.DropTableIndexRequest; +import org.lance.namespace.model.DropTableIndexResponse; +import org.lance.namespace.model.DropTableRequest; +import org.lance.namespace.model.DropTableResponse; +import org.lance.namespace.model.ExplainTableQueryPlanRequest; +import org.lance.namespace.model.GetTableStatsRequest; +import org.lance.namespace.model.GetTableStatsResponse; +import org.lance.namespace.model.GetTableTagVersionRequest; +import org.lance.namespace.model.GetTableTagVersionResponse; +import org.lance.namespace.model.InsertIntoTableRequest; +import org.lance.namespace.model.InsertIntoTableResponse; +import org.lance.namespace.model.ListNamespacesRequest; +import org.lance.namespace.model.ListNamespacesResponse; +import org.lance.namespace.model.ListTableIndicesRequest; +import org.lance.namespace.model.ListTableIndicesResponse; +import org.lance.namespace.model.ListTableTagsRequest; +import org.lance.namespace.model.ListTableTagsResponse; +import org.lance.namespace.model.ListTableVersionsRequest; +import org.lance.namespace.model.ListTableVersionsResponse; +import org.lance.namespace.model.ListTablesRequest; +import org.lance.namespace.model.ListTablesResponse; +import org.lance.namespace.model.MergeInsertIntoTableRequest; +import org.lance.namespace.model.MergeInsertIntoTableResponse; +import org.lance.namespace.model.NamespaceExistsRequest; +import org.lance.namespace.model.QueryTableRequest; +import org.lance.namespace.model.RegisterTableRequest; +import org.lance.namespace.model.RegisterTableResponse; +import org.lance.namespace.model.RenameTableRequest; +import org.lance.namespace.model.RenameTableResponse; +import org.lance.namespace.model.RestoreTableRequest; +import org.lance.namespace.model.RestoreTableResponse; +import org.lance.namespace.model.TableExistsRequest; +import org.lance.namespace.model.UpdateTableRequest; +import org.lance.namespace.model.UpdateTableResponse; +import org.lance.namespace.model.UpdateTableSchemaMetadataRequest; +import org.lance.namespace.model.UpdateTableSchemaMetadataResponse; +import org.lance.namespace.model.UpdateTableTagRequest; +import org.lance.namespace.model.UpdateTableTagResponse; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -324,6 +404,14 @@ public DeclareTableResponse declareTable(DeclareTableRequest request) { return fromJson(responseJson, DeclareTableResponse.class); } + @Override + public RenameTableResponse renameTable(RenameTableRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = renameTableNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, RenameTableResponse.class); + } + @Override public InsertIntoTableResponse insertIntoTable( InsertIntoTableRequest request, byte[] requestData) { @@ -445,6 +533,135 @@ public BatchDeleteTableVersionsResponse batchDeleteTableVersions( return fromJson(responseJson, BatchDeleteTableVersionsResponse.class); } + @Override + public CreateTableScalarIndexResponse createTableScalarIndex(CreateTableIndexRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = createTableScalarIndexNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, CreateTableScalarIndexResponse.class); + } + + @Override + public DropTableIndexResponse dropTableIndex(DropTableIndexRequest request, String indexName) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = dropTableIndexNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, DropTableIndexResponse.class); + } + + @Override + public ListTablesResponse listAllTables(ListTablesRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = listAllTablesNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, ListTablesResponse.class); + } + + @Override + public RestoreTableResponse restoreTable(RestoreTableRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = restoreTableNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, RestoreTableResponse.class); + } + + @Override + public UpdateTableSchemaMetadataResponse updateTableSchemaMetadata( + UpdateTableSchemaMetadataRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = + updateTableSchemaMetadataNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, UpdateTableSchemaMetadataResponse.class); + } + + @Override + public GetTableStatsResponse getTableStats(GetTableStatsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = getTableStatsNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, GetTableStatsResponse.class); + } + + @Override + public String explainTableQueryPlan(ExplainTableQueryPlanRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + return explainTableQueryPlanNative(nativeDirectoryNamespaceHandle, requestJson); + } + + @Override + public String analyzeTableQueryPlan(AnalyzeTableQueryPlanRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + return analyzeTableQueryPlanNative(nativeDirectoryNamespaceHandle, requestJson); + } + + @Override + public AlterTableAddColumnsResponse alterTableAddColumns(AlterTableAddColumnsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = alterTableAddColumnsNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, AlterTableAddColumnsResponse.class); + } + + @Override + public AlterTableAlterColumnsResponse alterTableAlterColumns( + AlterTableAlterColumnsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = alterTableAlterColumnsNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, AlterTableAlterColumnsResponse.class); + } + + @Override + public AlterTableDropColumnsResponse alterTableDropColumns(AlterTableDropColumnsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = alterTableDropColumnsNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, AlterTableDropColumnsResponse.class); + } + + @Override + public ListTableTagsResponse listTableTags(ListTableTagsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = listTableTagsNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, ListTableTagsResponse.class); + } + + @Override + public GetTableTagVersionResponse getTableTagVersion(GetTableTagVersionRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = getTableTagVersionNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, GetTableTagVersionResponse.class); + } + + @Override + public CreateTableTagResponse createTableTag(CreateTableTagRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = createTableTagNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, CreateTableTagResponse.class); + } + + @Override + public DeleteTableTagResponse deleteTableTag(DeleteTableTagRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = deleteTableTagNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, DeleteTableTagResponse.class); + } + + @Override + public UpdateTableTagResponse updateTableTag(UpdateTableTagRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = updateTableTagNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, UpdateTableTagResponse.class); + } + @Override public void close() { if (nativeDirectoryNamespaceHandle != 0) { @@ -550,6 +767,8 @@ private native long createNativeWithProvider( private native String declareTableNative(long handle, String requestJson); + private native String renameTableNative(long handle, String requestJson); + private native String insertIntoTableNative(long handle, String requestJson, byte[] requestData); private native String mergeInsertIntoTableNative( @@ -579,6 +798,38 @@ private native String mergeInsertIntoTableNative( private native String batchDeleteTableVersionsNative(long handle, String requestJson); + private native String createTableScalarIndexNative(long handle, String requestJson); + + private native String dropTableIndexNative(long handle, String requestJson); + + private native String listAllTablesNative(long handle, String requestJson); + + private native String restoreTableNative(long handle, String requestJson); + + private native String updateTableSchemaMetadataNative(long handle, String requestJson); + + private native String getTableStatsNative(long handle, String requestJson); + + private native String explainTableQueryPlanNative(long handle, String requestJson); + + private native String analyzeTableQueryPlanNative(long handle, String requestJson); + + private native String alterTableAddColumnsNative(long handle, String requestJson); + + private native String alterTableAlterColumnsNative(long handle, String requestJson); + + private native String alterTableDropColumnsNative(long handle, String requestJson); + + private native String listTableTagsNative(long handle, String requestJson); + + private native String getTableTagVersionNative(long handle, String requestJson); + + private native String createTableTagNative(long handle, String requestJson); + + private native String deleteTableTagNative(long handle, String requestJson); + + private native String updateTableTagNative(long handle, String requestJson); + private native Map retrieveOpsMetricsNative(long handle); private native void resetOpsMetricsNative(long handle); diff --git a/java/src/main/java/org/lance/namespace/RestNamespace.java b/java/src/main/java/org/lance/namespace/RestNamespace.java index 421bd01e3d4..fbf58bd55c6 100644 --- a/java/src/main/java/org/lance/namespace/RestNamespace.java +++ b/java/src/main/java/org/lance/namespace/RestNamespace.java @@ -14,7 +14,87 @@ package org.lance.namespace; import org.lance.JniLoader; -import org.lance.namespace.model.*; +import org.lance.namespace.model.AlterTableAddColumnsRequest; +import org.lance.namespace.model.AlterTableAddColumnsResponse; +import org.lance.namespace.model.AlterTableAlterColumnsRequest; +import org.lance.namespace.model.AlterTableAlterColumnsResponse; +import org.lance.namespace.model.AlterTableDropColumnsRequest; +import org.lance.namespace.model.AlterTableDropColumnsResponse; +import org.lance.namespace.model.AlterTransactionRequest; +import org.lance.namespace.model.AlterTransactionResponse; +import org.lance.namespace.model.AnalyzeTableQueryPlanRequest; +import org.lance.namespace.model.BatchDeleteTableVersionsRequest; +import org.lance.namespace.model.BatchDeleteTableVersionsResponse; +import org.lance.namespace.model.CountTableRowsRequest; +import org.lance.namespace.model.CreateNamespaceRequest; +import org.lance.namespace.model.CreateNamespaceResponse; +import org.lance.namespace.model.CreateTableIndexRequest; +import org.lance.namespace.model.CreateTableIndexResponse; +import org.lance.namespace.model.CreateTableRequest; +import org.lance.namespace.model.CreateTableResponse; +import org.lance.namespace.model.CreateTableScalarIndexResponse; +import org.lance.namespace.model.CreateTableTagRequest; +import org.lance.namespace.model.CreateTableTagResponse; +import org.lance.namespace.model.CreateTableVersionRequest; +import org.lance.namespace.model.CreateTableVersionResponse; +import org.lance.namespace.model.DeclareTableRequest; +import org.lance.namespace.model.DeclareTableResponse; +import org.lance.namespace.model.DeleteFromTableRequest; +import org.lance.namespace.model.DeleteFromTableResponse; +import org.lance.namespace.model.DeleteTableTagRequest; +import org.lance.namespace.model.DeleteTableTagResponse; +import org.lance.namespace.model.DeregisterTableRequest; +import org.lance.namespace.model.DeregisterTableResponse; +import org.lance.namespace.model.DescribeNamespaceRequest; +import org.lance.namespace.model.DescribeNamespaceResponse; +import org.lance.namespace.model.DescribeTableIndexStatsRequest; +import org.lance.namespace.model.DescribeTableIndexStatsResponse; +import org.lance.namespace.model.DescribeTableRequest; +import org.lance.namespace.model.DescribeTableResponse; +import org.lance.namespace.model.DescribeTableVersionRequest; +import org.lance.namespace.model.DescribeTableVersionResponse; +import org.lance.namespace.model.DescribeTransactionRequest; +import org.lance.namespace.model.DescribeTransactionResponse; +import org.lance.namespace.model.DropNamespaceRequest; +import org.lance.namespace.model.DropNamespaceResponse; +import org.lance.namespace.model.DropTableIndexRequest; +import org.lance.namespace.model.DropTableIndexResponse; +import org.lance.namespace.model.DropTableRequest; +import org.lance.namespace.model.DropTableResponse; +import org.lance.namespace.model.ExplainTableQueryPlanRequest; +import org.lance.namespace.model.GetTableStatsRequest; +import org.lance.namespace.model.GetTableStatsResponse; +import org.lance.namespace.model.GetTableTagVersionRequest; +import org.lance.namespace.model.GetTableTagVersionResponse; +import org.lance.namespace.model.InsertIntoTableRequest; +import org.lance.namespace.model.InsertIntoTableResponse; +import org.lance.namespace.model.ListNamespacesRequest; +import org.lance.namespace.model.ListNamespacesResponse; +import org.lance.namespace.model.ListTableIndicesRequest; +import org.lance.namespace.model.ListTableIndicesResponse; +import org.lance.namespace.model.ListTableTagsRequest; +import org.lance.namespace.model.ListTableTagsResponse; +import org.lance.namespace.model.ListTableVersionsRequest; +import org.lance.namespace.model.ListTableVersionsResponse; +import org.lance.namespace.model.ListTablesRequest; +import org.lance.namespace.model.ListTablesResponse; +import org.lance.namespace.model.MergeInsertIntoTableRequest; +import org.lance.namespace.model.MergeInsertIntoTableResponse; +import org.lance.namespace.model.NamespaceExistsRequest; +import org.lance.namespace.model.QueryTableRequest; +import org.lance.namespace.model.RegisterTableRequest; +import org.lance.namespace.model.RegisterTableResponse; +import org.lance.namespace.model.RenameTableRequest; +import org.lance.namespace.model.RenameTableResponse; +import org.lance.namespace.model.RestoreTableRequest; +import org.lance.namespace.model.RestoreTableResponse; +import org.lance.namespace.model.TableExistsRequest; +import org.lance.namespace.model.UpdateTableRequest; +import org.lance.namespace.model.UpdateTableResponse; +import org.lance.namespace.model.UpdateTableSchemaMetadataRequest; +import org.lance.namespace.model.UpdateTableSchemaMetadataResponse; +import org.lance.namespace.model.UpdateTableTagRequest; +import org.lance.namespace.model.UpdateTableTagResponse; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -360,6 +440,134 @@ public BatchDeleteTableVersionsResponse batchDeleteTableVersions( return fromJson(responseJson, BatchDeleteTableVersionsResponse.class); } + @Override + public CreateTableScalarIndexResponse createTableScalarIndex(CreateTableIndexRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = createTableScalarIndexNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, CreateTableScalarIndexResponse.class); + } + + @Override + public DropTableIndexResponse dropTableIndex(DropTableIndexRequest request, String indexName) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = dropTableIndexNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, DropTableIndexResponse.class); + } + + @Override + public ListTablesResponse listAllTables(ListTablesRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = listAllTablesNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, ListTablesResponse.class); + } + + @Override + public RestoreTableResponse restoreTable(RestoreTableRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = restoreTableNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, RestoreTableResponse.class); + } + + @Override + public UpdateTableSchemaMetadataResponse updateTableSchemaMetadata( + UpdateTableSchemaMetadataRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = updateTableSchemaMetadataNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, UpdateTableSchemaMetadataResponse.class); + } + + @Override + public GetTableStatsResponse getTableStats(GetTableStatsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = getTableStatsNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, GetTableStatsResponse.class); + } + + @Override + public String explainTableQueryPlan(ExplainTableQueryPlanRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + return explainTableQueryPlanNative(nativeRestNamespaceHandle, requestJson); + } + + @Override + public String analyzeTableQueryPlan(AnalyzeTableQueryPlanRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + return analyzeTableQueryPlanNative(nativeRestNamespaceHandle, requestJson); + } + + @Override + public AlterTableAddColumnsResponse alterTableAddColumns(AlterTableAddColumnsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = alterTableAddColumnsNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, AlterTableAddColumnsResponse.class); + } + + @Override + public AlterTableAlterColumnsResponse alterTableAlterColumns( + AlterTableAlterColumnsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = alterTableAlterColumnsNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, AlterTableAlterColumnsResponse.class); + } + + @Override + public AlterTableDropColumnsResponse alterTableDropColumns(AlterTableDropColumnsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = alterTableDropColumnsNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, AlterTableDropColumnsResponse.class); + } + + @Override + public ListTableTagsResponse listTableTags(ListTableTagsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = listTableTagsNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, ListTableTagsResponse.class); + } + + @Override + public GetTableTagVersionResponse getTableTagVersion(GetTableTagVersionRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = getTableTagVersionNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, GetTableTagVersionResponse.class); + } + + @Override + public CreateTableTagResponse createTableTag(CreateTableTagRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = createTableTagNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, CreateTableTagResponse.class); + } + + @Override + public DeleteTableTagResponse deleteTableTag(DeleteTableTagRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = deleteTableTagNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, DeleteTableTagResponse.class); + } + + @Override + public UpdateTableTagResponse updateTableTag(UpdateTableTagRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = updateTableTagNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, UpdateTableTagResponse.class); + } + @Override public void close() { if (nativeRestNamespaceHandle != 0) { @@ -495,6 +703,38 @@ private native String mergeInsertIntoTableNative( private native String batchDeleteTableVersionsNative(long handle, String requestJson); + private native String createTableScalarIndexNative(long handle, String requestJson); + + private native String dropTableIndexNative(long handle, String requestJson); + + private native String listAllTablesNative(long handle, String requestJson); + + private native String restoreTableNative(long handle, String requestJson); + + private native String updateTableSchemaMetadataNative(long handle, String requestJson); + + private native String getTableStatsNative(long handle, String requestJson); + + private native String explainTableQueryPlanNative(long handle, String requestJson); + + private native String analyzeTableQueryPlanNative(long handle, String requestJson); + + private native String alterTableAddColumnsNative(long handle, String requestJson); + + private native String alterTableAlterColumnsNative(long handle, String requestJson); + + private native String alterTableDropColumnsNative(long handle, String requestJson); + + private native String listTableTagsNative(long handle, String requestJson); + + private native String getTableTagVersionNative(long handle, String requestJson); + + private native String createTableTagNative(long handle, String requestJson); + + private native String deleteTableTagNative(long handle, String requestJson); + + private native String updateTableTagNative(long handle, String requestJson); + private native Map retrieveOpsMetricsNative(long handle); private native void resetOpsMetricsNative(long handle); diff --git a/java/src/test/java/org/lance/TestVectorDataset.java b/java/src/test/java/org/lance/TestVectorDataset.java index f05c7dc7abb..873b0fa7f66 100644 --- a/java/src/test/java/org/lance/TestVectorDataset.java +++ b/java/src/test/java/org/lance/TestVectorDataset.java @@ -20,7 +20,10 @@ import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; -import org.apache.arrow.vector.*; +import org.apache.arrow.vector.Float4Vector; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VarCharVector; +import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.complex.FixedSizeListVector; import org.apache.arrow.vector.types.FloatingPointPrecision; import org.apache.arrow.vector.types.pojo.ArrowType; @@ -31,7 +34,13 @@ import java.io.IOException; import java.nio.file.Path; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; public class TestVectorDataset implements AutoCloseable { public static final String vectorColumnName = "vec"; diff --git a/java/src/test/java/org/lance/namespace/CustomNamespace.java b/java/src/test/java/org/lance/namespace/CustomNamespace.java index eb383e118d8..47423ab25b3 100644 --- a/java/src/test/java/org/lance/namespace/CustomNamespace.java +++ b/java/src/test/java/org/lance/namespace/CustomNamespace.java @@ -13,7 +13,93 @@ */ package org.lance.namespace; -import org.lance.namespace.model.*; +import org.lance.namespace.model.AlterTableAddColumnsRequest; +import org.lance.namespace.model.AlterTableAddColumnsResponse; +import org.lance.namespace.model.AlterTableAlterColumnsRequest; +import org.lance.namespace.model.AlterTableAlterColumnsResponse; +import org.lance.namespace.model.AlterTableDropColumnsRequest; +import org.lance.namespace.model.AlterTableDropColumnsResponse; +import org.lance.namespace.model.AlterTransactionRequest; +import org.lance.namespace.model.AlterTransactionResponse; +import org.lance.namespace.model.AnalyzeTableQueryPlanRequest; +import org.lance.namespace.model.BatchCommitTablesRequest; +import org.lance.namespace.model.BatchCommitTablesResponse; +import org.lance.namespace.model.BatchCreateTableVersionsRequest; +import org.lance.namespace.model.BatchCreateTableVersionsResponse; +import org.lance.namespace.model.BatchDeleteTableVersionsRequest; +import org.lance.namespace.model.BatchDeleteTableVersionsResponse; +import org.lance.namespace.model.CountTableRowsRequest; +import org.lance.namespace.model.CreateEmptyTableRequest; +import org.lance.namespace.model.CreateEmptyTableResponse; +import org.lance.namespace.model.CreateNamespaceRequest; +import org.lance.namespace.model.CreateNamespaceResponse; +import org.lance.namespace.model.CreateTableIndexRequest; +import org.lance.namespace.model.CreateTableIndexResponse; +import org.lance.namespace.model.CreateTableRequest; +import org.lance.namespace.model.CreateTableResponse; +import org.lance.namespace.model.CreateTableScalarIndexResponse; +import org.lance.namespace.model.CreateTableTagRequest; +import org.lance.namespace.model.CreateTableTagResponse; +import org.lance.namespace.model.CreateTableVersionRequest; +import org.lance.namespace.model.CreateTableVersionResponse; +import org.lance.namespace.model.DeclareTableRequest; +import org.lance.namespace.model.DeclareTableResponse; +import org.lance.namespace.model.DeleteFromTableRequest; +import org.lance.namespace.model.DeleteFromTableResponse; +import org.lance.namespace.model.DeleteTableTagRequest; +import org.lance.namespace.model.DeleteTableTagResponse; +import org.lance.namespace.model.DeregisterTableRequest; +import org.lance.namespace.model.DeregisterTableResponse; +import org.lance.namespace.model.DescribeNamespaceRequest; +import org.lance.namespace.model.DescribeNamespaceResponse; +import org.lance.namespace.model.DescribeTableIndexStatsRequest; +import org.lance.namespace.model.DescribeTableIndexStatsResponse; +import org.lance.namespace.model.DescribeTableRequest; +import org.lance.namespace.model.DescribeTableResponse; +import org.lance.namespace.model.DescribeTableVersionRequest; +import org.lance.namespace.model.DescribeTableVersionResponse; +import org.lance.namespace.model.DescribeTransactionRequest; +import org.lance.namespace.model.DescribeTransactionResponse; +import org.lance.namespace.model.DropNamespaceRequest; +import org.lance.namespace.model.DropNamespaceResponse; +import org.lance.namespace.model.DropTableIndexRequest; +import org.lance.namespace.model.DropTableIndexResponse; +import org.lance.namespace.model.DropTableRequest; +import org.lance.namespace.model.DropTableResponse; +import org.lance.namespace.model.ExplainTableQueryPlanRequest; +import org.lance.namespace.model.GetTableStatsRequest; +import org.lance.namespace.model.GetTableStatsResponse; +import org.lance.namespace.model.GetTableTagVersionRequest; +import org.lance.namespace.model.GetTableTagVersionResponse; +import org.lance.namespace.model.InsertIntoTableRequest; +import org.lance.namespace.model.InsertIntoTableResponse; +import org.lance.namespace.model.ListNamespacesRequest; +import org.lance.namespace.model.ListNamespacesResponse; +import org.lance.namespace.model.ListTableIndicesRequest; +import org.lance.namespace.model.ListTableIndicesResponse; +import org.lance.namespace.model.ListTableTagsRequest; +import org.lance.namespace.model.ListTableTagsResponse; +import org.lance.namespace.model.ListTableVersionsRequest; +import org.lance.namespace.model.ListTableVersionsResponse; +import org.lance.namespace.model.ListTablesRequest; +import org.lance.namespace.model.ListTablesResponse; +import org.lance.namespace.model.MergeInsertIntoTableRequest; +import org.lance.namespace.model.MergeInsertIntoTableResponse; +import org.lance.namespace.model.NamespaceExistsRequest; +import org.lance.namespace.model.QueryTableRequest; +import org.lance.namespace.model.RegisterTableRequest; +import org.lance.namespace.model.RegisterTableResponse; +import org.lance.namespace.model.RenameTableRequest; +import org.lance.namespace.model.RenameTableResponse; +import org.lance.namespace.model.RestoreTableRequest; +import org.lance.namespace.model.RestoreTableResponse; +import org.lance.namespace.model.TableExistsRequest; +import org.lance.namespace.model.UpdateTableRequest; +import org.lance.namespace.model.UpdateTableResponse; +import org.lance.namespace.model.UpdateTableSchemaMetadataRequest; +import org.lance.namespace.model.UpdateTableSchemaMetadataResponse; +import org.lance.namespace.model.UpdateTableTagRequest; +import org.lance.namespace.model.UpdateTableTagResponse; import org.apache.arrow.memory.BufferAllocator; diff --git a/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java b/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java index 7d5fc9c6096..7599fcbc6cf 100644 --- a/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java +++ b/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java @@ -22,9 +22,43 @@ import org.lance.WriteParams; import org.lance.namespace.errors.ErrorCode; import org.lance.namespace.errors.LanceNamespaceException; -import org.lance.namespace.model.*; +import org.lance.namespace.model.CountTableRowsRequest; +import org.lance.namespace.model.CreateNamespaceRequest; +import org.lance.namespace.model.CreateNamespaceResponse; +import org.lance.namespace.model.CreateTableIndexRequest; +import org.lance.namespace.model.CreateTableIndexResponse; +import org.lance.namespace.model.CreateTableRequest; +import org.lance.namespace.model.CreateTableResponse; +import org.lance.namespace.model.DeclareTableRequest; +import org.lance.namespace.model.DeclareTableResponse; +import org.lance.namespace.model.DeregisterTableRequest; +import org.lance.namespace.model.DeregisterTableResponse; +import org.lance.namespace.model.DescribeNamespaceRequest; +import org.lance.namespace.model.DescribeNamespaceResponse; +import org.lance.namespace.model.DescribeTableRequest; +import org.lance.namespace.model.DescribeTableResponse; import org.lance.namespace.model.DescribeTableVersionRequest; import org.lance.namespace.model.DescribeTableVersionResponse; +import org.lance.namespace.model.DropNamespaceRequest; +import org.lance.namespace.model.DropNamespaceResponse; +import org.lance.namespace.model.DropTableRequest; +import org.lance.namespace.model.DropTableResponse; +import org.lance.namespace.model.InsertIntoTableRequest; +import org.lance.namespace.model.InsertIntoTableResponse; +import org.lance.namespace.model.ListNamespacesRequest; +import org.lance.namespace.model.ListNamespacesResponse; +import org.lance.namespace.model.ListTableIndicesRequest; +import org.lance.namespace.model.ListTableIndicesResponse; +import org.lance.namespace.model.ListTableVersionsRequest; +import org.lance.namespace.model.ListTableVersionsResponse; +import org.lance.namespace.model.ListTablesRequest; +import org.lance.namespace.model.ListTablesResponse; +import org.lance.namespace.model.NamespaceExistsRequest; +import org.lance.namespace.model.QueryTableRequest; +import org.lance.namespace.model.QueryTableRequestVector; +import org.lance.namespace.model.RegisterTableRequest; +import org.lance.namespace.model.RegisterTableResponse; +import org.lance.namespace.model.TableExistsRequest; import org.lance.operation.Append; import org.apache.arrow.memory.BufferAllocator; @@ -1083,4 +1117,314 @@ void testConcurrentCreateThenDropFromDifferentInstance() throws Exception { assertEquals(numTables, dropSuccessCount.get(), "All drops should succeed"); assertEquals(0, dropFailCount.get(), "No drops should fail"); } + + @Test + void testCountTableRows() throws Exception { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + namespaceClient.createNamespace(createNsReq); + + // Create a table with 3 rows + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + namespaceClient.createTable(createReq, tableData); + + // Count rows + CountTableRowsRequest countReq = + new CountTableRowsRequest().id(Arrays.asList("workspace", "test_table")); + long count = namespaceClient.countTableRows(countReq); + assertEquals(3, count); + } + + @Test + void testCountTableRowsWithFilter() throws Exception { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + namespaceClient.createNamespace(createNsReq); + + // Create a table with 3 rows (ages: 30, 25, 35) + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + namespaceClient.createTable(createReq, tableData); + + // Count rows with predicate + CountTableRowsRequest countReq = + new CountTableRowsRequest() + .id(Arrays.asList("workspace", "test_table")) + .predicate("age > 28"); + long count = namespaceClient.countTableRows(countReq); + assertEquals(2, count); // Alice (30) and Charlie (35) + } + + @Test + void testInsertIntoTable() throws Exception { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + namespaceClient.createNamespace(createNsReq); + + // Create a table with 3 rows + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + namespaceClient.createTable(createReq, tableData); + + // Insert more data + byte[] newData = createTestTableData(); // Another 3 rows + InsertIntoTableRequest insertReq = + new InsertIntoTableRequest().id(Arrays.asList("workspace", "test_table")).mode("append"); + InsertIntoTableResponse insertResp = namespaceClient.insertIntoTable(insertReq, newData); + assertNotNull(insertResp); + + // Verify row count increased + CountTableRowsRequest countReq = + new CountTableRowsRequest().id(Arrays.asList("workspace", "test_table")); + long count = namespaceClient.countTableRows(countReq); + assertEquals(6, count); + } + + @Test + void testQueryTable() throws Exception { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + namespaceClient.createNamespace(createNsReq); + + // Create a table with 3 rows + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + namespaceClient.createTable(createReq, tableData); + + // Query table - just verify we can execute a simple query + QueryTableRequest queryReq = + new QueryTableRequest() + .id(Arrays.asList("workspace", "test_table")) + .k(10) + .vector(new QueryTableRequestVector()); + byte[] resultBytes = namespaceClient.queryTable(queryReq); + assertNotNull(resultBytes); + assertTrue(resultBytes.length > 0); + } + + @Test + void testListTableVersions() throws Exception { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + namespaceClient.createNamespace(createNsReq); + + // Create a table + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + namespaceClient.createTable(createReq, tableData); + + // List versions + ListTableVersionsRequest listReq = + new ListTableVersionsRequest().id(Arrays.asList("workspace", "test_table")); + ListTableVersionsResponse listResp = namespaceClient.listTableVersions(listReq); + assertNotNull(listResp); + assertNotNull(listResp.getVersions()); + assertTrue(listResp.getVersions().size() >= 1); + } + + @Test + void testRegisterTable() throws Exception { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + namespaceClient.createNamespace(createNsReq); + + // First create a table directly to get a valid location + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "source_table")); + CreateTableResponse createResp = namespaceClient.createTable(createReq, tableData); + String location = createResp.getLocation(); + + // Register the table under a new name (using relative path) + String relativePath = "source_table.lance"; + RegisterTableRequest registerReq = + new RegisterTableRequest() + .id(Arrays.asList("workspace", "registered_table")) + .location(relativePath); + RegisterTableResponse registerResp = namespaceClient.registerTable(registerReq); + assertNotNull(registerResp); + + // Verify the registered table exists + TableExistsRequest existsReq = + new TableExistsRequest().id(Arrays.asList("workspace", "registered_table")); + assertDoesNotThrow(() -> namespaceClient.tableExists(existsReq)); + } + + @Test + void testDeclareTable() throws Exception { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + namespaceClient.createNamespace(createNsReq); + + // Declare a table + DeclareTableRequest declareReq = + new DeclareTableRequest().id(Arrays.asList("workspace", "declared_table")); + DeclareTableResponse declareResp = namespaceClient.declareTable(declareReq); + assertNotNull(declareResp); + assertNotNull(declareResp.getLocation()); + } + + @Test + void testDeregisterTable() throws Exception { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + namespaceClient.createNamespace(createNsReq); + + // Create a table + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + namespaceClient.createTable(createReq, tableData); + + // Deregister the table + DeregisterTableRequest deregisterReq = + new DeregisterTableRequest().id(Arrays.asList("workspace", "test_table")); + DeregisterTableResponse deregisterResp = namespaceClient.deregisterTable(deregisterReq); + assertNotNull(deregisterResp); + + // Verify the table no longer exists + TableExistsRequest existsReq = + new TableExistsRequest().id(Arrays.asList("workspace", "test_table")); + assertThrows(LanceNamespaceException.class, () -> namespaceClient.tableExists(existsReq)); + } + + @Test + void testListTableIndices() throws Exception { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + namespaceClient.createNamespace(createNsReq); + + // Create a table + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + namespaceClient.createTable(createReq, tableData); + + // List indices (should be empty initially) + ListTableIndicesRequest listReq = + new ListTableIndicesRequest().id(Arrays.asList("workspace", "test_table")); + ListTableIndicesResponse listResp = namespaceClient.listTableIndices(listReq); + assertNotNull(listResp); + assertNotNull(listResp.getIndexes()); + assertEquals(0, listResp.getIndexes().size()); + } + + @Test + void testCreateScalarIndex() throws Exception { + // Create table at root level + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = new CreateTableRequest().id(Arrays.asList("test_table")); + namespaceClient.createTable(createReq, tableData); + + // Create scalar index on 'id' column + CreateTableIndexRequest createIndexReq = + new CreateTableIndexRequest() + .id(Arrays.asList("test_table")) + .column("id") + .indexType("BTREE") + .name("id_idx"); + CreateTableIndexResponse response = namespaceClient.createTableIndex(createIndexReq); + assertNotNull(response); + + // List indices to verify + ListTableIndicesRequest listReq = new ListTableIndicesRequest().id(Arrays.asList("test_table")); + ListTableIndicesResponse listResp = namespaceClient.listTableIndices(listReq); + assertNotNull(listResp); + assertNotNull(listResp.getIndexes()); + assertEquals(1, listResp.getIndexes().size()); + assertEquals("id_idx", listResp.getIndexes().get(0).getIndexName()); + assertTrue(listResp.getIndexes().get(0).getColumns().contains("id")); + } + + @Test + void testCreateVectorIndex() throws Exception { + // Create table with vector data (256 rows with 8-dim vectors) + byte[] tableData = createVectorTableData(256, 8); + CreateTableRequest createReq = new CreateTableRequest().id(Arrays.asList("vector_table")); + namespaceClient.createTable(createReq, tableData); + + // Create vector index using IVF_FLAT + CreateTableIndexRequest createIndexReq = + new CreateTableIndexRequest() + .id(Arrays.asList("vector_table")) + .column("vector") + .indexType("IVF_FLAT") + .name("vector_idx") + .distanceType("l2"); + CreateTableIndexResponse response = namespaceClient.createTableIndex(createIndexReq); + assertNotNull(response); + + // List indices to verify + ListTableIndicesRequest listReq = + new ListTableIndicesRequest().id(Arrays.asList("vector_table")); + ListTableIndicesResponse listResp = namespaceClient.listTableIndices(listReq); + assertNotNull(listResp); + assertNotNull(listResp.getIndexes()); + assertEquals(1, listResp.getIndexes().size()); + assertEquals("vector_idx", listResp.getIndexes().get(0).getIndexName()); + assertTrue(listResp.getIndexes().get(0).getColumns().contains("vector")); + } + + private byte[] createVectorTableData(int numRows, int dim) throws Exception { + Schema schema = + new Schema( + Arrays.asList( + new Field("id", FieldType.nullable(new ArrowType.Int(32, true)), null), + new Field( + "vector", + FieldType.nullable(new ArrowType.FixedSizeList(dim)), + Arrays.asList( + new Field( + "item", + FieldType.nullable( + new ArrowType.FloatingPoint( + org.apache.arrow.vector.types.FloatingPointPrecision.SINGLE)), + null))))); + + try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + IntVector idVector = (IntVector) root.getVector("id"); + org.apache.arrow.vector.complex.FixedSizeListVector vectorCol = + (org.apache.arrow.vector.complex.FixedSizeListVector) root.getVector("vector"); + org.apache.arrow.vector.Float4Vector itemsVector = + (org.apache.arrow.vector.Float4Vector) vectorCol.getDataVector(); + + idVector.allocateNew(numRows); + vectorCol.allocateNew(); + itemsVector.allocateNew(numRows * dim); + + for (int i = 0; i < numRows; i++) { + idVector.set(i, i); + for (int j = 0; j < dim; j++) { + itemsVector.set(i * dim + j, (float) (i * dim + j) * 0.01f); + } + vectorCol.setNotNull(i); + } + + idVector.setValueCount(numRows); + itemsVector.setValueCount(numRows * dim); + vectorCol.setValueCount(numRows); + root.setRowCount(numRows); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) { + writer.writeBatch(); + } + return out.toByteArray(); + } + } } diff --git a/java/src/test/java/org/lance/namespace/DynamicContextProviderTest.java b/java/src/test/java/org/lance/namespace/DynamicContextProviderTest.java index 7959eb9be58..1b980a24d09 100644 --- a/java/src/test/java/org/lance/namespace/DynamicContextProviderTest.java +++ b/java/src/test/java/org/lance/namespace/DynamicContextProviderTest.java @@ -13,7 +13,10 @@ */ package org.lance.namespace; -import org.lance.namespace.model.*; +import org.lance.namespace.model.CreateNamespaceRequest; +import org.lance.namespace.model.CreateNamespaceResponse; +import org.lance.namespace.model.ListNamespacesRequest; +import org.lance.namespace.model.ListNamespacesResponse; import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; diff --git a/java/src/test/java/org/lance/namespace/RestNamespaceTest.java b/java/src/test/java/org/lance/namespace/RestNamespaceTest.java index eab60969d2c..4ee8c701351 100644 --- a/java/src/test/java/org/lance/namespace/RestNamespaceTest.java +++ b/java/src/test/java/org/lance/namespace/RestNamespaceTest.java @@ -13,7 +13,24 @@ */ package org.lance.namespace; -import org.lance.namespace.model.*; +import org.lance.namespace.model.CreateNamespaceRequest; +import org.lance.namespace.model.CreateNamespaceResponse; +import org.lance.namespace.model.CreateTableRequest; +import org.lance.namespace.model.CreateTableResponse; +import org.lance.namespace.model.DescribeNamespaceRequest; +import org.lance.namespace.model.DescribeNamespaceResponse; +import org.lance.namespace.model.DescribeTableRequest; +import org.lance.namespace.model.DescribeTableResponse; +import org.lance.namespace.model.DropNamespaceRequest; +import org.lance.namespace.model.DropNamespaceResponse; +import org.lance.namespace.model.DropTableRequest; +import org.lance.namespace.model.DropTableResponse; +import org.lance.namespace.model.ListNamespacesRequest; +import org.lance.namespace.model.ListNamespacesResponse; +import org.lance.namespace.model.ListTablesRequest; +import org.lance.namespace.model.ListTablesResponse; +import org.lance.namespace.model.NamespaceExistsRequest; +import org.lance.namespace.model.TableExistsRequest; import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; diff --git a/python/python/lance/namespace.py b/python/python/lance/namespace.py index ae6316becf8..7d283b69579 100644 --- a/python/python/lance/namespace.py +++ b/python/python/lance/namespace.py @@ -16,35 +16,80 @@ from typing import Dict, Optional from lance_namespace import ( + AlterTableAddColumnsRequest, + AlterTableAddColumnsResponse, + AlterTableAlterColumnsRequest, + AlterTableAlterColumnsResponse, + AlterTableDropColumnsRequest, + AlterTableDropColumnsResponse, + AlterTransactionRequest, + AlterTransactionResponse, + AnalyzeTableQueryPlanRequest, + CountTableRowsRequest, CreateNamespaceRequest, CreateNamespaceResponse, + CreateTableIndexRequest, + CreateTableIndexResponse, CreateTableRequest, CreateTableResponse, + CreateTableTagRequest, + CreateTableTagResponse, DeclareTableRequest, DeclareTableResponse, + DeleteFromTableRequest, + DeleteFromTableResponse, + DeleteTableTagRequest, + DeleteTableTagResponse, DeregisterTableRequest, DeregisterTableResponse, DescribeNamespaceRequest, DescribeNamespaceResponse, + DescribeTableIndexStatsRequest, + DescribeTableIndexStatsResponse, DescribeTableRequest, DescribeTableResponse, + DescribeTransactionRequest, + DescribeTransactionResponse, DropNamespaceRequest, DropNamespaceResponse, + DropTableIndexRequest, + DropTableIndexResponse, DropTableRequest, DropTableResponse, + ExplainTableQueryPlanRequest, + GetTableStatsRequest, + GetTableStatsResponse, + GetTableTagVersionRequest, + GetTableTagVersionResponse, + InsertIntoTableRequest, + InsertIntoTableResponse, LanceNamespace, ListNamespacesRequest, ListNamespacesResponse, + ListTableIndicesRequest, + ListTableIndicesResponse, ListTablesRequest, ListTablesResponse, + ListTableTagsRequest, + ListTableTagsResponse, ListTableVersionsRequest, ListTableVersionsResponse, + MergeInsertIntoTableRequest, + MergeInsertIntoTableResponse, NamespaceExistsRequest, RegisterTableRequest, RegisterTableResponse, RenameTableRequest, RenameTableResponse, + RestoreTableRequest, + RestoreTableResponse, TableExistsRequest, + UpdateTableRequest, + UpdateTableResponse, + UpdateTableSchemaMetadataRequest, + UpdateTableSchemaMetadataResponse, + UpdateTableTagRequest, + UpdateTableTagResponse, ) from .lance import PyDirectoryNamespace # Low-level Rust binding @@ -395,6 +440,10 @@ def declare_table(self, request: DeclareTableRequest) -> DeclareTableResponse: response_dict = self._inner.declare_table(request.model_dump()) return DeclareTableResponse.from_dict(response_dict) + def rename_table(self, request: RenameTableRequest) -> RenameTableResponse: + response_dict = self._inner.rename_table(request.model_dump()) + return RenameTableResponse.from_dict(response_dict) + # Table version operations def list_table_versions( @@ -459,6 +508,321 @@ def batch_delete_table_versions(self, request: dict) -> dict: """ return self._inner.batch_delete_table_versions(request) + # Data manipulation operations + + def count_table_rows(self, request: CountTableRowsRequest) -> int: + """Count the number of rows in a table, optionally filtered by a predicate. + + Parameters + ---------- + request : CountTableRowsRequest + Request with table id, optional version, and optional predicate filter + + Returns + ------- + int + The number of rows matching the criteria + """ + return self._inner.count_table_rows(request.model_dump()) + + def insert_into_table( + self, request: InsertIntoTableRequest, request_data: bytes + ) -> InsertIntoTableResponse: + """Insert data into a table. + + Parameters + ---------- + request : InsertIntoTableRequest + Request with table id and write mode (append/overwrite) + request_data : bytes + Arrow IPC stream containing the data to insert + + Returns + ------- + InsertIntoTableResponse + Response with optional transaction_id + """ + response_dict = self._inner.insert_into_table( + request.model_dump(), request_data + ) + return InsertIntoTableResponse.from_dict(response_dict) + + def merge_insert_into_table( + self, request: MergeInsertIntoTableRequest, request_data: bytes + ) -> MergeInsertIntoTableResponse: + """Merge-insert data into a table (upsert operation). + + Parameters + ---------- + request : MergeInsertIntoTableRequest + Request with table id, merge keys, and merge behavior + request_data : bytes + Arrow IPC stream containing the data to merge + + Returns + ------- + MergeInsertIntoTableResponse + Response with optional transaction_id + """ + response_dict = self._inner.merge_insert_into_table( + request.model_dump(), request_data + ) + return MergeInsertIntoTableResponse.from_dict(response_dict) + + def update_table(self, request: UpdateTableRequest) -> UpdateTableResponse: + """Update rows in a table matching a filter. + + Parameters + ---------- + request : UpdateTableRequest + Request with table id, filter predicate, and column updates + + Returns + ------- + UpdateTableResponse + Response with optional transaction_id + """ + response_dict = self._inner.update_table(request.model_dump()) + return UpdateTableResponse.from_dict(response_dict) + + def delete_from_table( + self, request: DeleteFromTableRequest + ) -> DeleteFromTableResponse: + """Delete rows from a table matching a filter. + + Parameters + ---------- + request : DeleteFromTableRequest + Request with table id and filter predicate + + Returns + ------- + DeleteFromTableResponse + Response with optional transaction_id + """ + response_dict = self._inner.delete_from_table(request.model_dump()) + return DeleteFromTableResponse.from_dict(response_dict) + + def query_table(self, request) -> bytes: + """Query a table and return results as Arrow IPC. + + Parameters + ---------- + request : QueryTableRequest or dict + Query request with table id, optional filter, columns, vector search + parameters, limit/offset, etc. + + Returns + ------- + bytes + Arrow IPC file format containing the query results + """ + if hasattr(request, "model_dump"): + request = request.model_dump() + return self._inner.query_table(request) + + # Index operations + + def create_table_index( + self, request: CreateTableIndexRequest + ) -> CreateTableIndexResponse: + """Create an index on a table. + + Parameters + ---------- + request : CreateTableIndexRequest + Request with table id, index name, column, and index configuration + + Returns + ------- + CreateTableIndexResponse + Response with optional transaction_id + """ + response_dict = self._inner.create_table_index(request.model_dump()) + return CreateTableIndexResponse.from_dict(response_dict) + + def list_table_indices( + self, request: ListTableIndicesRequest + ) -> ListTableIndicesResponse: + """List all indices on a table. + + Parameters + ---------- + request : ListTableIndicesRequest + Request with table id + + Returns + ------- + ListTableIndicesResponse + Response with list of index metadata + """ + response_dict = self._inner.list_table_indices(request.model_dump()) + return ListTableIndicesResponse.from_dict(response_dict) + + def describe_table_index_stats( + self, request: DescribeTableIndexStatsRequest + ) -> DescribeTableIndexStatsResponse: + """Get statistics for a specific index. + + Parameters + ---------- + request : DescribeTableIndexStatsRequest + Request with table id and index name + + Returns + ------- + DescribeTableIndexStatsResponse + Response with index statistics + """ + response_dict = self._inner.describe_table_index_stats(request.model_dump()) + return DescribeTableIndexStatsResponse.from_dict(response_dict) + + # Transaction operations + + def describe_transaction( + self, request: DescribeTransactionRequest + ) -> DescribeTransactionResponse: + """Describe a transaction. + + Parameters + ---------- + request : DescribeTransactionRequest + Request with transaction id + + Returns + ------- + DescribeTransactionResponse + Response with transaction details + """ + response_dict = self._inner.describe_transaction(request.model_dump()) + return DescribeTransactionResponse.from_dict(response_dict) + + def alter_transaction( + self, request: AlterTransactionRequest + ) -> AlterTransactionResponse: + """Alter a transaction (commit or abort). + + Parameters + ---------- + request : AlterTransactionRequest + Request with transaction id and action + + Returns + ------- + AlterTransactionResponse + Response confirming the action + """ + response_dict = self._inner.alter_transaction(request.model_dump()) + return AlterTransactionResponse.from_dict(response_dict) + + # Additional index operations + + def create_table_scalar_index( + self, request: CreateTableIndexRequest + ) -> CreateTableIndexResponse: + """Create a scalar index on a table column.""" + response_dict = self._inner.create_table_scalar_index(request.model_dump()) + return CreateTableIndexResponse.from_dict(response_dict) + + def drop_table_index( + self, request: DropTableIndexRequest + ) -> DropTableIndexResponse: + """Drop an index from a table.""" + response_dict = self._inner.drop_table_index(request.model_dump()) + return DropTableIndexResponse.from_dict(response_dict) + + # Additional table operations + + def list_all_tables(self, request: ListTablesRequest) -> ListTablesResponse: + """List all tables recursively across all namespaces.""" + response_dict = self._inner.list_all_tables(request.model_dump()) + return ListTablesResponse.from_dict(response_dict) + + def restore_table(self, request: RestoreTableRequest) -> RestoreTableResponse: + """Restore a previously dropped table.""" + response_dict = self._inner.restore_table(request.model_dump()) + return RestoreTableResponse.from_dict(response_dict) + + def update_table_schema_metadata( + self, request: UpdateTableSchemaMetadataRequest + ) -> UpdateTableSchemaMetadataResponse: + """Update the schema metadata of a table.""" + response_dict = self._inner.update_table_schema_metadata(request.model_dump()) + return UpdateTableSchemaMetadataResponse.from_dict(response_dict) + + def get_table_stats(self, request: GetTableStatsRequest) -> GetTableStatsResponse: + """Get statistics for a table.""" + response_dict = self._inner.get_table_stats(request.model_dump()) + return GetTableStatsResponse.from_dict(response_dict) + + # Query plan operations + + def explain_table_query_plan(self, request: ExplainTableQueryPlanRequest) -> str: + """Explain the query plan for a table query.""" + return self._inner.explain_table_query_plan(request.model_dump()) + + def analyze_table_query_plan(self, request: AnalyzeTableQueryPlanRequest) -> str: + """Analyze the query plan for a table query with execution statistics.""" + return self._inner.analyze_table_query_plan(request.model_dump()) + + # Column alteration operations + + def alter_table_add_columns( + self, request: AlterTableAddColumnsRequest + ) -> AlterTableAddColumnsResponse: + """Add columns to a table.""" + response_dict = self._inner.alter_table_add_columns(request.model_dump()) + return AlterTableAddColumnsResponse.from_dict(response_dict) + + def alter_table_alter_columns( + self, request: AlterTableAlterColumnsRequest + ) -> AlterTableAlterColumnsResponse: + """Alter existing columns in a table.""" + response_dict = self._inner.alter_table_alter_columns(request.model_dump()) + return AlterTableAlterColumnsResponse.from_dict(response_dict) + + def alter_table_drop_columns( + self, request: AlterTableDropColumnsRequest + ) -> AlterTableDropColumnsResponse: + """Drop columns from a table.""" + response_dict = self._inner.alter_table_drop_columns(request.model_dump()) + return AlterTableDropColumnsResponse.from_dict(response_dict) + + # Table tag operations + + def list_table_tags(self, request: ListTableTagsRequest) -> ListTableTagsResponse: + """List all tags on a table.""" + response_dict = self._inner.list_table_tags(request.model_dump()) + return ListTableTagsResponse.from_dict(response_dict) + + def get_table_tag_version( + self, request: GetTableTagVersionRequest + ) -> GetTableTagVersionResponse: + """Get the version associated with a table tag.""" + response_dict = self._inner.get_table_tag_version(request.model_dump()) + return GetTableTagVersionResponse.from_dict(response_dict) + + def create_table_tag( + self, request: CreateTableTagRequest + ) -> CreateTableTagResponse: + """Create a new tag pointing to a table version.""" + response_dict = self._inner.create_table_tag(request.model_dump()) + return CreateTableTagResponse.from_dict(response_dict) + + def delete_table_tag( + self, request: DeleteTableTagRequest + ) -> DeleteTableTagResponse: + """Delete a tag from a table.""" + response_dict = self._inner.delete_table_tag(request.model_dump()) + return DeleteTableTagResponse.from_dict(response_dict) + + def update_table_tag( + self, request: UpdateTableTagRequest + ) -> UpdateTableTagResponse: + """Update a tag to point to a different version.""" + response_dict = self._inner.update_table_tag(request.model_dump()) + return UpdateTableTagResponse.from_dict(response_dict) + # Operation metrics methods def retrieve_ops_metrics(self) -> Dict[str, int]: @@ -693,6 +1057,321 @@ def batch_delete_table_versions(self, request: dict) -> dict: """ return self._inner.batch_delete_table_versions(request) + # Data manipulation operations + + def count_table_rows(self, request: CountTableRowsRequest) -> int: + """Count the number of rows in a table, optionally filtered by a predicate. + + Parameters + ---------- + request : CountTableRowsRequest + Request with table id, optional version, and optional predicate filter + + Returns + ------- + int + The number of rows matching the criteria + """ + return self._inner.count_table_rows(request.model_dump()) + + def insert_into_table( + self, request: InsertIntoTableRequest, request_data: bytes + ) -> InsertIntoTableResponse: + """Insert data into a table. + + Parameters + ---------- + request : InsertIntoTableRequest + Request with table id and write mode (append/overwrite) + request_data : bytes + Arrow IPC stream containing the data to insert + + Returns + ------- + InsertIntoTableResponse + Response with optional transaction_id + """ + response_dict = self._inner.insert_into_table( + request.model_dump(), request_data + ) + return InsertIntoTableResponse.from_dict(response_dict) + + def merge_insert_into_table( + self, request: MergeInsertIntoTableRequest, request_data: bytes + ) -> MergeInsertIntoTableResponse: + """Merge-insert data into a table (upsert operation). + + Parameters + ---------- + request : MergeInsertIntoTableRequest + Request with table id, merge keys, and merge behavior + request_data : bytes + Arrow IPC stream containing the data to merge + + Returns + ------- + MergeInsertIntoTableResponse + Response with optional transaction_id + """ + response_dict = self._inner.merge_insert_into_table( + request.model_dump(), request_data + ) + return MergeInsertIntoTableResponse.from_dict(response_dict) + + def update_table(self, request: UpdateTableRequest) -> UpdateTableResponse: + """Update rows in a table matching a filter. + + Parameters + ---------- + request : UpdateTableRequest + Request with table id, filter predicate, and column updates + + Returns + ------- + UpdateTableResponse + Response with optional transaction_id + """ + response_dict = self._inner.update_table(request.model_dump()) + return UpdateTableResponse.from_dict(response_dict) + + def delete_from_table( + self, request: DeleteFromTableRequest + ) -> DeleteFromTableResponse: + """Delete rows from a table matching a filter. + + Parameters + ---------- + request : DeleteFromTableRequest + Request with table id and filter predicate + + Returns + ------- + DeleteFromTableResponse + Response confirming the deletion + """ + response_dict = self._inner.delete_from_table(request.model_dump()) + return DeleteFromTableResponse.from_dict(response_dict) + + def query_table(self, request) -> bytes: + """Query a table and return results as Arrow IPC. + + Parameters + ---------- + request : QueryTableRequest or dict + Query request with table id, optional filter, columns, vector search + parameters, limit/offset, etc. + + Returns + ------- + bytes + Arrow IPC file format containing the query results + """ + if hasattr(request, "model_dump"): + request = request.model_dump() + return self._inner.query_table(request) + + # Index operations + + def create_table_index( + self, request: CreateTableIndexRequest + ) -> CreateTableIndexResponse: + """Create an index on a table. + + Parameters + ---------- + request : CreateTableIndexRequest + Request with table id, index name, column, and index configuration + + Returns + ------- + CreateTableIndexResponse + Response with optional transaction_id + """ + response_dict = self._inner.create_table_index(request.model_dump()) + return CreateTableIndexResponse.from_dict(response_dict) + + def list_table_indices( + self, request: ListTableIndicesRequest + ) -> ListTableIndicesResponse: + """List all indices on a table. + + Parameters + ---------- + request : ListTableIndicesRequest + Request with table id + + Returns + ------- + ListTableIndicesResponse + Response with list of index metadata + """ + response_dict = self._inner.list_table_indices(request.model_dump()) + return ListTableIndicesResponse.from_dict(response_dict) + + def describe_table_index_stats( + self, request: DescribeTableIndexStatsRequest + ) -> DescribeTableIndexStatsResponse: + """Get statistics for a specific index. + + Parameters + ---------- + request : DescribeTableIndexStatsRequest + Request with table id and index name + + Returns + ------- + DescribeTableIndexStatsResponse + Response with index statistics + """ + response_dict = self._inner.describe_table_index_stats(request.model_dump()) + return DescribeTableIndexStatsResponse.from_dict(response_dict) + + # Transaction operations + + def describe_transaction( + self, request: DescribeTransactionRequest + ) -> DescribeTransactionResponse: + """Describe a transaction. + + Parameters + ---------- + request : DescribeTransactionRequest + Request with transaction id + + Returns + ------- + DescribeTransactionResponse + Response with transaction details + """ + response_dict = self._inner.describe_transaction(request.model_dump()) + return DescribeTransactionResponse.from_dict(response_dict) + + def alter_transaction( + self, request: AlterTransactionRequest + ) -> AlterTransactionResponse: + """Alter a transaction (commit or abort). + + Parameters + ---------- + request : AlterTransactionRequest + Request with transaction id and action + + Returns + ------- + AlterTransactionResponse + Response confirming the action + """ + response_dict = self._inner.alter_transaction(request.model_dump()) + return AlterTransactionResponse.from_dict(response_dict) + + # Additional index operations + + def create_table_scalar_index( + self, request: CreateTableIndexRequest + ) -> CreateTableIndexResponse: + """Create a scalar index on a table column.""" + response_dict = self._inner.create_table_scalar_index(request.model_dump()) + return CreateTableIndexResponse.from_dict(response_dict) + + def drop_table_index( + self, request: DropTableIndexRequest + ) -> DropTableIndexResponse: + """Drop an index from a table.""" + response_dict = self._inner.drop_table_index(request.model_dump()) + return DropTableIndexResponse.from_dict(response_dict) + + # Additional table operations + + def list_all_tables(self, request: ListTablesRequest) -> ListTablesResponse: + """List all tables recursively across all namespaces.""" + response_dict = self._inner.list_all_tables(request.model_dump()) + return ListTablesResponse.from_dict(response_dict) + + def restore_table(self, request: RestoreTableRequest) -> RestoreTableResponse: + """Restore a previously dropped table.""" + response_dict = self._inner.restore_table(request.model_dump()) + return RestoreTableResponse.from_dict(response_dict) + + def update_table_schema_metadata( + self, request: UpdateTableSchemaMetadataRequest + ) -> UpdateTableSchemaMetadataResponse: + """Update the schema metadata of a table.""" + response_dict = self._inner.update_table_schema_metadata(request.model_dump()) + return UpdateTableSchemaMetadataResponse.from_dict(response_dict) + + def get_table_stats(self, request: GetTableStatsRequest) -> GetTableStatsResponse: + """Get statistics for a table.""" + response_dict = self._inner.get_table_stats(request.model_dump()) + return GetTableStatsResponse.from_dict(response_dict) + + # Query plan operations + + def explain_table_query_plan(self, request: ExplainTableQueryPlanRequest) -> str: + """Explain the query plan for a table query.""" + return self._inner.explain_table_query_plan(request.model_dump()) + + def analyze_table_query_plan(self, request: AnalyzeTableQueryPlanRequest) -> str: + """Analyze the query plan for a table query with execution statistics.""" + return self._inner.analyze_table_query_plan(request.model_dump()) + + # Column alteration operations + + def alter_table_add_columns( + self, request: AlterTableAddColumnsRequest + ) -> AlterTableAddColumnsResponse: + """Add columns to a table.""" + response_dict = self._inner.alter_table_add_columns(request.model_dump()) + return AlterTableAddColumnsResponse.from_dict(response_dict) + + def alter_table_alter_columns( + self, request: AlterTableAlterColumnsRequest + ) -> AlterTableAlterColumnsResponse: + """Alter existing columns in a table.""" + response_dict = self._inner.alter_table_alter_columns(request.model_dump()) + return AlterTableAlterColumnsResponse.from_dict(response_dict) + + def alter_table_drop_columns( + self, request: AlterTableDropColumnsRequest + ) -> AlterTableDropColumnsResponse: + """Drop columns from a table.""" + response_dict = self._inner.alter_table_drop_columns(request.model_dump()) + return AlterTableDropColumnsResponse.from_dict(response_dict) + + # Table tag operations + + def list_table_tags(self, request: ListTableTagsRequest) -> ListTableTagsResponse: + """List all tags on a table.""" + response_dict = self._inner.list_table_tags(request.model_dump()) + return ListTableTagsResponse.from_dict(response_dict) + + def get_table_tag_version( + self, request: GetTableTagVersionRequest + ) -> GetTableTagVersionResponse: + """Get the version associated with a table tag.""" + response_dict = self._inner.get_table_tag_version(request.model_dump()) + return GetTableTagVersionResponse.from_dict(response_dict) + + def create_table_tag( + self, request: CreateTableTagRequest + ) -> CreateTableTagResponse: + """Create a new tag pointing to a table version.""" + response_dict = self._inner.create_table_tag(request.model_dump()) + return CreateTableTagResponse.from_dict(response_dict) + + def delete_table_tag( + self, request: DeleteTableTagRequest + ) -> DeleteTableTagResponse: + """Delete a tag from a table.""" + response_dict = self._inner.delete_table_tag(request.model_dump()) + return DeleteTableTagResponse.from_dict(response_dict) + + def update_table_tag( + self, request: UpdateTableTagRequest + ) -> UpdateTableTagResponse: + """Update a tag to point to a different version.""" + response_dict = self._inner.update_table_tag(request.model_dump()) + return UpdateTableTagResponse.from_dict(response_dict) + # Operation metrics methods def retrieve_ops_metrics(self) -> Dict[str, int]: diff --git a/python/python/tests/test_namespace_dir.py b/python/python/tests/test_namespace_dir.py index 0e8e4dc8fb8..d7271e761b1 100644 --- a/python/python/tests/test_namespace_dir.py +++ b/python/python/tests/test_namespace_dir.py @@ -26,8 +26,11 @@ import pytest from lance.namespace import LanceNamespace from lance_namespace import ( + CountTableRowsRequest, CreateNamespaceRequest, CreateNamespaceResponse, + CreateTableIndexRequest, + CreateTableIndexResponse, CreateTableRequest, CreateTableResponse, CreateTableVersionRequest, @@ -38,6 +41,7 @@ DeregisterTableResponse, DescribeNamespaceRequest, DescribeNamespaceResponse, + DescribeTableIndexStatsRequest, DescribeTableRequest, DescribeTableResponse, DescribeTableVersionRequest, @@ -46,13 +50,18 @@ DropNamespaceResponse, DropTableRequest, DropTableResponse, + InsertIntoTableRequest, + InsertIntoTableResponse, ListNamespacesRequest, ListNamespacesResponse, + ListTableIndicesRequest, + ListTableIndicesResponse, ListTablesRequest, ListTablesResponse, ListTableVersionsRequest, ListTableVersionsResponse, NamespaceExistsRequest, + QueryTableRequest, RegisterTableRequest, RegisterTableResponse, TableExistsRequest, @@ -142,6 +151,30 @@ def create_table_version( ) -> CreateTableVersionResponse: return self._inner.create_table_version(request) + def create_table_index( + self, request: CreateTableIndexRequest + ) -> CreateTableIndexResponse: + return self._inner.create_table_index(request) + + def list_table_indices( + self, request: ListTableIndicesRequest + ) -> ListTableIndicesResponse: + return self._inner.list_table_indices(request) + + def count_table_rows(self, request: CountTableRowsRequest) -> int: + return self._inner.count_table_rows(request) + + def insert_into_table( + self, request: InsertIntoTableRequest, request_data: bytes + ) -> InsertIntoTableResponse: + return self._inner.insert_into_table(request, request_data) + + def query_table(self, request) -> bytes: + # Accept both QueryTableRequest and dict, like DirectoryNamespace does + if hasattr(request, "model_dump"): + request = request.model_dump() + return self._inner.query_table(request) + def retrieve_ops_metrics(self) -> Optional[Dict[str, int]]: return self._inner.retrieve_ops_metrics() @@ -1188,3 +1221,297 @@ def drop_table(table_index): list_req = ListTablesRequest(id=["test_ns"]) response = verify_ns_client.list_tables(list_req) assert len(response.tables) == 0, "All tables should be dropped" + + +class TestDataManipulation: + """Tests for data manipulation operations.""" + + def test_count_table_rows(self, temp_ns_client): + """Test counting rows in a table.""" + # Create namespace and table + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + temp_ns_client.create_namespace(create_ns_req) + + table_data = create_test_data() # 3 rows + ipc_data = table_to_ipc_bytes(table_data) + create_req = CreateTableRequest(id=["workspace", "test_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # Count rows + count_req = CountTableRowsRequest(id=["workspace", "test_table"]) + count = temp_ns_client.count_table_rows(count_req) + assert count == 3 + + def test_count_table_rows_with_filter(self, temp_ns_client): + """Test counting rows with a filter predicate.""" + # Create namespace and table + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + temp_ns_client.create_namespace(create_ns_req) + + table_data = create_test_data() # 3 rows with ages 30, 25, 35 + ipc_data = table_to_ipc_bytes(table_data) + create_req = CreateTableRequest(id=["workspace", "test_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # Count rows with predicate + count_req = CountTableRowsRequest( + id=["workspace", "test_table"], predicate="age > 28" + ) + count = temp_ns_client.count_table_rows(count_req) + assert count == 2 # Alice (30) and Charlie (35) + + def test_insert_into_table(self, temp_ns_client): + """Test inserting data into a table.""" + # Create namespace and table + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + temp_ns_client.create_namespace(create_ns_req) + + table_data = create_test_data() # 3 rows + ipc_data = table_to_ipc_bytes(table_data) + create_req = CreateTableRequest(id=["workspace", "test_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # Insert more data + new_data = pa.Table.from_pylist( + [ + {"id": 4, "name": "David", "age": 40}, + {"id": 5, "name": "Eve", "age": 22}, + ] + ) + new_ipc_data = table_to_ipc_bytes(new_data) + insert_req = InsertIntoTableRequest( + id=["workspace", "test_table"], mode="append" + ) + response = temp_ns_client.insert_into_table(insert_req, new_ipc_data) + assert response is not None + + # Verify row count increased + count_req = CountTableRowsRequest(id=["workspace", "test_table"]) + count = temp_ns_client.count_table_rows(count_req) + assert count == 5 + + def test_query_table(self, temp_ns_client): + """Test querying a table.""" + # Create namespace and table + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + temp_ns_client.create_namespace(create_ns_req) + + table_data = create_test_data() # 3 rows + ipc_data = table_to_ipc_bytes(table_data) + create_req = CreateTableRequest(id=["workspace", "test_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # Query table with empty vector (for non-vector queries) + query_req = QueryTableRequest(id=["workspace", "test_table"], k=10, vector={}) + result_bytes = temp_ns_client.query_table(query_req) + assert result_bytes is not None + assert len(result_bytes) > 0 + + # Parse the result + reader = pa.ipc.open_file(pa.BufferReader(result_bytes)) + result_table = reader.read_all() + assert result_table.num_rows == 3 + assert "id" in result_table.column_names + assert "name" in result_table.column_names + + def test_query_table_with_filter(self, temp_ns_client): + """Test querying a table with a filter.""" + # Create namespace and table + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + temp_ns_client.create_namespace(create_ns_req) + + table_data = create_test_data() # 3 rows + ipc_data = table_to_ipc_bytes(table_data) + create_req = CreateTableRequest(id=["workspace", "test_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # Query with filter and empty vector + query_req = QueryTableRequest( + id=["workspace", "test_table"], filter="age >= 30", k=10, vector={} + ) + result_bytes = temp_ns_client.query_table(query_req) + reader = pa.ipc.open_file(pa.BufferReader(result_bytes)) + result_table = reader.read_all() + assert result_table.num_rows == 2 # Alice and Charlie + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Table version listing not supported on Windows", +) +class TestTableVersions: + """Tests for table version operations.""" + + def test_list_table_versions(self, temp_ns_client): + """Test listing table versions.""" + # Create namespace and table + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + temp_ns_client.create_namespace(create_ns_req) + + table_data = create_test_data() + ipc_data = table_to_ipc_bytes(table_data) + create_req = CreateTableRequest(id=["workspace", "test_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # List versions + list_req = ListTableVersionsRequest(id=["workspace", "test_table"]) + response = temp_ns_client.list_table_versions(list_req) + assert response is not None + assert len(response.versions) >= 1 + + def test_describe_table_version(self, temp_ns_client): + """Test describing a specific table version.""" + # Create namespace and table + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + temp_ns_client.create_namespace(create_ns_req) + + table_data = create_test_data() + ipc_data = table_to_ipc_bytes(table_data) + create_req = CreateTableRequest(id=["workspace", "test_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # Describe version 1 + describe_req = DescribeTableVersionRequest( + id=["workspace", "test_table"], version=1 + ) + response = temp_ns_client.describe_table_version(describe_req.model_dump()) + assert response is not None + assert response.get("version") is not None + + def test_multiple_versions_via_insert(self, temp_ns_client): + """Test that inserts create new versions.""" + # Create namespace and table + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + temp_ns_client.create_namespace(create_ns_req) + + table_data = create_test_data() + ipc_data = table_to_ipc_bytes(table_data) + create_req = CreateTableRequest(id=["workspace", "test_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # Insert more data to create version 2 + new_data = pa.Table.from_pylist([{"id": 4, "name": "David", "age": 40}]) + new_ipc_data = table_to_ipc_bytes(new_data) + insert_req = InsertIntoTableRequest( + id=["workspace", "test_table"], mode="append" + ) + temp_ns_client.insert_into_table(insert_req, new_ipc_data) + + # List versions - should have at least 2 + list_req = ListTableVersionsRequest(id=["workspace", "test_table"]) + response = temp_ns_client.list_table_versions(list_req) + assert len(response.versions) >= 2 + + +class TestIndexOperations: + """Tests for index operations.""" + + def test_list_indices_empty(self, temp_ns_client): + """Test listing indices on a table with no indices.""" + # Create table with a vector column + import numpy as np + + vector_data = pa.Table.from_pydict( + { + "id": [1, 2, 3], + "vector": pa.FixedSizeListArray.from_arrays( + pa.array(np.random.rand(12).astype(np.float32)), 4 + ), + } + ) + ipc_data = table_to_ipc_bytes(vector_data) + create_req = CreateTableRequest(id=["vector_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # List indices (should be empty initially) + list_req = ListTableIndicesRequest(id=["vector_table"]) + response = temp_ns_client.list_table_indices(list_req) + assert response is not None + # Initially no indices + assert len(response.indexes) == 0 + + def test_describe_table_index_stats(self, memory_ns_client): + """Test describing index stats (even when no index exists).""" + # Create namespace and table + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + memory_ns_client.create_namespace(create_ns_req) + + table_data = create_test_data() + ipc_data = table_to_ipc_bytes(table_data) + create_req = CreateTableRequest(id=["workspace", "test_table"]) + memory_ns_client.create_table(create_req, ipc_data) + + # Describe index stats for non-existent index should return empty/null stats + describe_req = DescribeTableIndexStatsRequest( + id=["workspace", "test_table"], index_name="nonexistent" + ) + # This may raise an error or return empty stats depending on implementation + try: + response = memory_ns_client.describe_table_index_stats(describe_req) + # If it succeeds, verify response structure + assert response is not None + except Exception: + # Expected if index doesn't exist + pass + + def test_create_scalar_index(self, temp_ns_client): + """Test creating a scalar index.""" + # Create table at root level (without namespace) + table_data = create_test_data() + ipc_data = table_to_ipc_bytes(table_data) + create_req = CreateTableRequest(id=["test_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # Create scalar index on 'id' column + create_index_req = CreateTableIndexRequest( + id=["test_table"], + column="id", + index_type="BTREE", + name="id_idx", + ) + response = temp_ns_client.create_table_index(create_index_req) + assert response is not None + + # List indices to verify + list_req = ListTableIndicesRequest(id=["test_table"]) + list_response = temp_ns_client.list_table_indices(list_req) + assert len(list_response.indexes) == 1 + assert list_response.indexes[0].index_name == "id_idx" + assert list_response.indexes[0].columns == ["id"] + + def test_create_vector_index(self, temp_ns_client): + """Test creating a vector index.""" + import numpy as np + + # Create table with 256 rows of 8-dimensional vectors (enough for IVF) + num_rows = 256 + dim = 8 + vector_data = pa.Table.from_pydict( + { + "id": list(range(num_rows)), + "vector": pa.FixedSizeListArray.from_arrays( + pa.array(np.random.rand(num_rows * dim).astype(np.float32)), dim + ), + } + ) + ipc_data = table_to_ipc_bytes(vector_data) + create_req = CreateTableRequest(id=["vector_table"]) + temp_ns_client.create_table(create_req, ipc_data) + + # Create vector index using IVF_FLAT + create_index_req = CreateTableIndexRequest( + id=["vector_table"], + column="vector", + index_type="IVF_FLAT", + name="vector_idx", + distance_type="l2", + ) + response = temp_ns_client.create_table_index(create_index_req) + assert response is not None + + # List indices to verify + list_req = ListTableIndicesRequest(id=["vector_table"]) + list_response = temp_ns_client.list_table_indices(list_req) + assert len(list_response.indexes) == 1 + assert list_response.indexes[0].index_name == "vector_idx" + assert list_response.indexes[0].columns == ["vector"] diff --git a/python/src/namespace.rs b/python/src/namespace.rs index 40752b660dc..ee302d12303 100644 --- a/python/src/namespace.rs +++ b/python/src/namespace.rs @@ -10,9 +10,17 @@ use async_trait::async_trait; use bytes::Bytes; use lance_namespace::LanceNamespace as LanceNamespaceTrait; use lance_namespace::models::{ - CreateTableVersionRequest, CreateTableVersionResponse, DescribeTableRequest, - DescribeTableResponse, DescribeTableVersionRequest, DescribeTableVersionResponse, - ListTableVersionsRequest, ListTableVersionsResponse, + AlterTableAddColumnsRequest, AlterTableAlterColumnsRequest, AlterTableDropColumnsRequest, + AlterTransactionRequest, AnalyzeTableQueryPlanRequest, CountTableRowsRequest, + CreateTableIndexRequest, CreateTableTagRequest, CreateTableVersionRequest, + CreateTableVersionResponse, DeleteFromTableRequest, DeleteTableTagRequest, + DescribeTableIndexStatsRequest, DescribeTableRequest, DescribeTableResponse, + DescribeTableVersionRequest, DescribeTableVersionResponse, DescribeTransactionRequest, + DropTableIndexRequest, ExplainTableQueryPlanRequest, GetTableStatsRequest, + GetTableTagVersionRequest, InsertIntoTableRequest, ListTableIndicesRequest, + ListTableTagsRequest, ListTableVersionsRequest, ListTableVersionsResponse, ListTablesRequest, + MergeInsertIntoTableRequest, QueryTableRequest, RestoreTableRequest, UpdateTableRequest, + UpdateTableSchemaMetadataRequest, UpdateTableTagRequest, }; use lance_namespace_impls::RestNamespaceBuilder; use lance_namespace_impls::{ConnectBuilder, RestAdapter, RestAdapterConfig, RestAdapterHandle}; @@ -320,6 +328,18 @@ impl PyDirectoryNamespace { Ok(pythonize(py, &response)?.into()) } + fn rename_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.rename_table(request))? + .infer_error()?; + Ok(pythonize(py, &response)?.into()) + } + // Table version operations fn list_table_versions<'py>( @@ -370,6 +390,338 @@ impl PyDirectoryNamespace { pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } + // Data manipulation operations + + fn count_table_rows(&self, py: Python, request: &Bound<'_, PyAny>) -> PyResult { + let request: CountTableRowsRequest = depythonize(request)?; + let count = crate::rt() + .block_on(Some(py), self.inner.count_table_rows(request))? + .infer_error()?; + Ok(count) + } + + fn insert_into_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + request_data: &Bound<'_, PyBytes>, + ) -> PyResult> { + let request: InsertIntoTableRequest = depythonize(request)?; + let data = Bytes::copy_from_slice(request_data.as_bytes()); + let response = crate::rt() + .block_on(Some(py), self.inner.insert_into_table(request, data))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn merge_insert_into_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + request_data: &Bound<'_, PyBytes>, + ) -> PyResult> { + let request: MergeInsertIntoTableRequest = depythonize(request)?; + let data = Bytes::copy_from_slice(request_data.as_bytes()); + let response = crate::rt() + .block_on(Some(py), self.inner.merge_insert_into_table(request, data))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn update_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: UpdateTableRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.update_table(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn delete_from_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: DeleteFromTableRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.delete_from_table(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn query_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: QueryTableRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.query_table(request))? + .infer_error()?; + Ok(PyBytes::new(py, &response)) + } + + // Index operations + + fn create_table_index<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: CreateTableIndexRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_table_index(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn list_table_indices<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: ListTableIndicesRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_table_indices(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn describe_table_index_stats<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: DescribeTableIndexStatsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.describe_table_index_stats(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Transaction operations + + fn describe_transaction<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: DescribeTransactionRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.describe_transaction(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn alter_transaction<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: AlterTransactionRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.alter_transaction(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Additional index operations + + fn create_table_scalar_index<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: CreateTableIndexRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_table_scalar_index(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn drop_table_index<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: DropTableIndexRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.drop_table_index(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Additional table operations + + fn list_all_tables<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: ListTablesRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_all_tables(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn restore_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: RestoreTableRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.restore_table(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn update_table_schema_metadata<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: UpdateTableSchemaMetadataRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.update_table_schema_metadata(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn get_table_stats<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: GetTableStatsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.get_table_stats(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Query plan operations + + fn explain_table_query_plan(&self, py: Python, request: &Bound<'_, PyAny>) -> PyResult { + let request: ExplainTableQueryPlanRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.explain_table_query_plan(request))? + .infer_error()?; + Ok(response) + } + + fn analyze_table_query_plan(&self, py: Python, request: &Bound<'_, PyAny>) -> PyResult { + let request: AnalyzeTableQueryPlanRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.analyze_table_query_plan(request))? + .infer_error()?; + Ok(response) + } + + // Column alteration operations + + fn alter_table_add_columns<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: AlterTableAddColumnsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.alter_table_add_columns(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn alter_table_alter_columns<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: AlterTableAlterColumnsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.alter_table_alter_columns(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn alter_table_drop_columns<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: AlterTableDropColumnsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.alter_table_drop_columns(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Table tag operations + + fn list_table_tags<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: ListTableTagsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_table_tags(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn get_table_tag_version<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: GetTableTagVersionRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.get_table_tag_version(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn create_table_tag<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: CreateTableTagRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_table_tag(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn delete_table_tag<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: DeleteTableTagRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.delete_table_tag(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn update_table_tag<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: UpdateTableTagRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.update_table_tag(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + // Operation metrics methods /// Retrieve operation metrics as a dictionary. @@ -664,6 +1016,338 @@ impl PyRestNamespace { pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } + // Data manipulation operations + + fn count_table_rows(&self, py: Python, request: &Bound<'_, PyAny>) -> PyResult { + let request: CountTableRowsRequest = depythonize(request)?; + let count = crate::rt() + .block_on(Some(py), self.inner.count_table_rows(request))? + .infer_error()?; + Ok(count) + } + + fn insert_into_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + request_data: &Bound<'_, PyBytes>, + ) -> PyResult> { + let request: InsertIntoTableRequest = depythonize(request)?; + let data = Bytes::copy_from_slice(request_data.as_bytes()); + let response = crate::rt() + .block_on(Some(py), self.inner.insert_into_table(request, data))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn merge_insert_into_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + request_data: &Bound<'_, PyBytes>, + ) -> PyResult> { + let request: MergeInsertIntoTableRequest = depythonize(request)?; + let data = Bytes::copy_from_slice(request_data.as_bytes()); + let response = crate::rt() + .block_on(Some(py), self.inner.merge_insert_into_table(request, data))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn update_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: UpdateTableRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.update_table(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn delete_from_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: DeleteFromTableRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.delete_from_table(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn query_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: QueryTableRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.query_table(request))? + .infer_error()?; + Ok(PyBytes::new(py, &response)) + } + + // Index operations + + fn create_table_index<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: CreateTableIndexRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_table_index(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn list_table_indices<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: ListTableIndicesRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_table_indices(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn describe_table_index_stats<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: DescribeTableIndexStatsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.describe_table_index_stats(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Transaction operations + + fn describe_transaction<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: DescribeTransactionRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.describe_transaction(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn alter_transaction<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: AlterTransactionRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.alter_transaction(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Additional index operations + + fn create_table_scalar_index<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: CreateTableIndexRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_table_scalar_index(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn drop_table_index<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: DropTableIndexRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.drop_table_index(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Additional table operations + + fn list_all_tables<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: ListTablesRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_all_tables(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn restore_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: RestoreTableRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.restore_table(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn update_table_schema_metadata<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: UpdateTableSchemaMetadataRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.update_table_schema_metadata(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn get_table_stats<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: GetTableStatsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.get_table_stats(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Query plan operations + + fn explain_table_query_plan(&self, py: Python, request: &Bound<'_, PyAny>) -> PyResult { + let request: ExplainTableQueryPlanRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.explain_table_query_plan(request))? + .infer_error()?; + Ok(response) + } + + fn analyze_table_query_plan(&self, py: Python, request: &Bound<'_, PyAny>) -> PyResult { + let request: AnalyzeTableQueryPlanRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.analyze_table_query_plan(request))? + .infer_error()?; + Ok(response) + } + + // Column alteration operations + + fn alter_table_add_columns<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: AlterTableAddColumnsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.alter_table_add_columns(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn alter_table_alter_columns<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: AlterTableAlterColumnsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.alter_table_alter_columns(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn alter_table_drop_columns<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: AlterTableDropColumnsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.alter_table_drop_columns(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Table tag operations + + fn list_table_tags<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: ListTableTagsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_table_tags(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn get_table_tag_version<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: GetTableTagVersionRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.get_table_tag_version(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn create_table_tag<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: CreateTableTagRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_table_tag(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn delete_table_tag<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: DeleteTableTagRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.delete_table_tag(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn update_table_tag<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: UpdateTableTagRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.update_table_tag(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + // Operation metrics methods /// Retrieve operation metrics as a dictionary. diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index 44467838118..28194c68f49 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -38,16 +38,17 @@ use std::sync::{Arc, Mutex}; use crate::context::DynamicContextProvider; use lance_namespace::models::{ - BatchDeleteTableVersionsRequest, BatchDeleteTableVersionsResponse, CreateNamespaceRequest, - CreateNamespaceResponse, CreateTableIndexRequest, CreateTableIndexResponse, CreateTableRequest, - CreateTableResponse, CreateTableScalarIndexResponse, CreateTableVersionRequest, - CreateTableVersionResponse, DeclareTableRequest, DeclareTableResponse, - DescribeNamespaceRequest, DescribeNamespaceResponse, DescribeTableIndexStatsRequest, - DescribeTableIndexStatsResponse, DescribeTableRequest, DescribeTableResponse, - DescribeTableVersionRequest, DescribeTableVersionResponse, DescribeTransactionRequest, - DescribeTransactionResponse, DropNamespaceRequest, DropNamespaceResponse, - DropTableIndexRequest, DropTableIndexResponse, DropTableRequest, DropTableResponse, Identity, - IndexContent, ListNamespacesRequest, ListNamespacesResponse, ListTableIndicesRequest, + BatchDeleteTableVersionsRequest, BatchDeleteTableVersionsResponse, CountTableRowsRequest, + CreateNamespaceRequest, CreateNamespaceResponse, CreateTableIndexRequest, + CreateTableIndexResponse, CreateTableRequest, CreateTableResponse, + CreateTableScalarIndexResponse, CreateTableVersionRequest, CreateTableVersionResponse, + DeclareTableRequest, DeclareTableResponse, DescribeNamespaceRequest, DescribeNamespaceResponse, + DescribeTableIndexStatsRequest, DescribeTableIndexStatsResponse, DescribeTableRequest, + DescribeTableResponse, DescribeTableVersionRequest, DescribeTableVersionResponse, + DescribeTransactionRequest, DescribeTransactionResponse, DropNamespaceRequest, + DropNamespaceResponse, DropTableIndexRequest, DropTableIndexResponse, DropTableRequest, + DropTableResponse, Identity, IndexContent, InsertIntoTableRequest, InsertIntoTableResponse, + ListNamespacesRequest, ListNamespacesResponse, ListTableIndicesRequest, ListTableIndicesResponse, ListTableVersionsRequest, ListTableVersionsResponse, ListTablesRequest, ListTablesResponse, NamespaceExistsRequest, QueryTableRequest, TableExistsRequest, TableVersion, @@ -2945,6 +2946,103 @@ impl LanceNamespace for DirectoryNamespace { Ok(DropTableIndexResponse { transaction_id }) } + async fn count_table_rows(&self, request: CountTableRowsRequest) -> Result { + self.record_op("count_table_rows"); + let table_uri = self.resolve_table_location(&request.id).await?; + let dataset = self + .load_dataset(&table_uri, request.version, "count_table_rows") + .await?; + + let count = + dataset + .count_rows(request.predicate) + .await + .map_err(|e| NamespaceError::Internal { + message: format!("Failed to count rows for table at '{}': {}", table_uri, e), + })?; + + Ok(count as i64) + } + + async fn insert_into_table( + &self, + request: InsertIntoTableRequest, + request_data: Bytes, + ) -> Result { + self.record_op("insert_into_table"); + let table_uri = self.resolve_table_location(&request.id).await?; + + if request_data.is_empty() { + return Err(NamespaceError::InvalidInput { + message: "Request data (Arrow IPC stream) is required for insert_into_table" + .to_string(), + } + .into()); + } + + let cursor = Cursor::new(request_data.as_ref()); + let stream_reader = + StreamReader::try_new(cursor, None).map_err(|e| NamespaceError::InvalidInput { + message: format!("Invalid Arrow IPC stream: {}", e), + })?; + let arrow_schema = stream_reader.schema(); + + let mut batches = Vec::new(); + for batch_result in stream_reader { + batches.push(batch_result.map_err(|e| NamespaceError::Internal { + message: format!("Failed to read batch from IPC stream: {}", e), + })?); + } + + let reader = if batches.is_empty() { + let batch = arrow::record_batch::RecordBatch::new_empty(arrow_schema.clone()); + let batches = vec![Ok(batch)]; + RecordBatchIterator::new(batches, arrow_schema.clone()) + } else { + let batch_results: Vec<_> = batches.into_iter().map(Ok).collect(); + RecordBatchIterator::new(batch_results, arrow_schema) + }; + + let mode = match request.mode.as_deref() { + Some(m) if m.eq_ignore_ascii_case("overwrite") => WriteMode::Overwrite, + Some(m) if m.eq_ignore_ascii_case("append") => WriteMode::Append, + None => WriteMode::Append, + Some(m) => { + return Err(lance_namespace::error::NamespaceError::InvalidInput { + message: format!( + "Unsupported write mode '{}'. Supported modes are: 'append', 'overwrite'", + m + ), + } + .into()); + } + }; + + let store_params = self.storage_options.as_ref().map(|opts| ObjectStoreParams { + storage_options_accessor: Some(Arc::new( + lance_io::object_store::StorageOptionsAccessor::with_static_options(opts.clone()), + )), + ..Default::default() + }); + + let write_params = WriteParams { + mode, + store_params, + session: self.session.clone(), + ..Default::default() + }; + + Dataset::write(reader, &table_uri, Some(write_params)) + .await + .map_err(|e| NamespaceError::Internal { + message: format!("Failed to insert into table at '{}': {}", table_uri, e), + })?; + + Ok(InsertIntoTableResponse { + transaction_id: None, + }) + } + async fn query_table(&self, request: QueryTableRequest) -> Result { use arrow::ipc::writer::FileWriter; @@ -3187,7 +3285,7 @@ impl LanceNamespace for DirectoryNamespace { #[cfg(test)] mod tests { use super::*; - use arrow_ipc::reader::StreamReader; + use arrow_ipc::reader::{FileReader, StreamReader}; use lance::dataset::Dataset; use lance::index::DatasetIndexExt; use lance_core::utils::tempfile::{TempStdDir, TempStrDir}; @@ -6611,6 +6709,794 @@ mod tests { "Version 2 manifest should exist in versions directory" ); } + + /// Helper: create a namespace and a table with some rows, returning (namespace, table_id) + async fn create_ns_with_table() -> (DirectoryNamespace, TempStdDir, Vec) { + use arrow::array::{Int32Array, StringArray}; + use arrow::ipc::writer::StreamWriter; + + let (namespace, temp_dir) = create_test_namespace().await; + + let schema = create_test_schema(); + let arrow_schema = convert_json_arrow_schema(&schema).unwrap(); + let arrow_schema = Arc::new(arrow_schema); + + let id_array = Int32Array::from(vec![1, 2, 3]); + let name_array = StringArray::from(vec!["Alice", "Bob", "Charlie"]); + let batch = arrow::record_batch::RecordBatch::try_new( + arrow_schema.clone(), + vec![Arc::new(id_array), Arc::new(name_array)], + ) + .unwrap(); + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &arrow_schema).unwrap(); + writer.write(&batch).unwrap(); + writer.finish().unwrap(); + } + + let mut request = CreateTableRequest::new(); + let table_id = vec!["test_ops_table".to_string()]; + request.id = Some(table_id.clone()); + + namespace + .create_table(request, Bytes::from(buffer)) + .await + .unwrap(); + + (namespace, temp_dir, table_id) + } + + #[tokio::test] + async fn test_count_table_rows_basic() { + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + let request = CountTableRowsRequest { + id: Some(table_id), + version: None, + predicate: None, + ..Default::default() + }; + + let count = namespace.count_table_rows(request).await.unwrap(); + assert_eq!(count, 3); + } + + #[tokio::test] + async fn test_count_table_rows_with_predicate() { + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + let request = CountTableRowsRequest { + id: Some(table_id), + version: None, + predicate: Some("id > 1".to_string()), + ..Default::default() + }; + + let count = namespace.count_table_rows(request).await.unwrap(); + assert_eq!(count, 2); + } + + #[tokio::test] + async fn test_query_table_invalid_distance_type() { + let (namespace, _temp_dir, table_id) = create_ns_with_vector_table().await; + + let vector = Box::new(lance_namespace::models::QueryTableRequestVector { + single_vector: Some(vec![1.0, 0.0, 0.0, 0.0]), + multi_vector: None, + }); + + let request = QueryTableRequest { + id: Some(table_id), + k: 2, + vector, + vector_column: Some("vector".to_string()), + distance_type: Some("invalid_metric".to_string()), + filter: None, + offset: None, + version: None, + ..Default::default() + }; + + let result = namespace.query_table(request).await; + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("Unknown distance type"), + "Expected error about unknown distance type, got: {}", + err_msg + ); + } + + #[tokio::test] + async fn test_insert_into_table_append() { + use arrow::array::{Int32Array, StringArray}; + use arrow::ipc::writer::StreamWriter; + + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + // Prepare new data to insert + let schema = create_test_schema(); + let arrow_schema = convert_json_arrow_schema(&schema).unwrap(); + let arrow_schema = Arc::new(arrow_schema); + + let id_array = Int32Array::from(vec![4, 5]); + let name_array = StringArray::from(vec!["Dave", "Eve"]); + let batch = arrow::record_batch::RecordBatch::try_new( + arrow_schema.clone(), + vec![Arc::new(id_array), Arc::new(name_array)], + ) + .unwrap(); + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &arrow_schema).unwrap(); + writer.write(&batch).unwrap(); + writer.finish().unwrap(); + } + + let request = InsertIntoTableRequest { + id: Some(table_id.clone()), + mode: Some("append".to_string()), + ..Default::default() + }; + + let response = namespace + .insert_into_table(request, Bytes::from(buffer)) + .await + .unwrap(); + assert!(response.transaction_id.is_none()); + + // Verify total rows + let count_req = CountTableRowsRequest { + id: Some(table_id), + version: None, + predicate: None, + ..Default::default() + }; + let count = namespace.count_table_rows(count_req).await.unwrap(); + assert_eq!(count, 5); + } + + #[tokio::test] + async fn test_insert_into_table_overwrite() { + use arrow::array::{Int32Array, StringArray}; + use arrow::ipc::writer::StreamWriter; + + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + let schema = create_test_schema(); + let arrow_schema = convert_json_arrow_schema(&schema).unwrap(); + let arrow_schema = Arc::new(arrow_schema); + + let id_array = Int32Array::from(vec![10, 20]); + let name_array = StringArray::from(vec!["X", "Y"]); + let batch = arrow::record_batch::RecordBatch::try_new( + arrow_schema.clone(), + vec![Arc::new(id_array), Arc::new(name_array)], + ) + .unwrap(); + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &arrow_schema).unwrap(); + writer.write(&batch).unwrap(); + writer.finish().unwrap(); + } + + let request = InsertIntoTableRequest { + id: Some(table_id.clone()), + mode: Some("overwrite".to_string()), + ..Default::default() + }; + + namespace + .insert_into_table(request, Bytes::from(buffer)) + .await + .unwrap(); + + // Verify overwrite: only 2 rows remain + let count_req = CountTableRowsRequest { + id: Some(table_id), + version: None, + predicate: None, + ..Default::default() + }; + let count = namespace.count_table_rows(count_req).await.unwrap(); + assert_eq!(count, 2); + } + + #[tokio::test] + async fn test_insert_into_table_empty_data() { + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + let request = InsertIntoTableRequest { + id: Some(table_id), + mode: None, + ..Default::default() + }; + + let result = namespace.insert_into_table(request, Bytes::new()).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Arrow IPC stream) is required") + ); + } + + #[tokio::test] + async fn test_insert_into_table_with_storage_options() { + use arrow::array::{Int32Array, StringArray}; + use arrow::ipc::writer::StreamWriter; + + let temp_dir = TempStdDir::default(); + + // Build namespace with a (no-op) storage option so self.storage_options is Some + let namespace = DirectoryNamespaceBuilder::new(temp_dir.to_str().unwrap()) + .storage_option("allow_http", "true") + .build() + .await + .unwrap(); + + // Create a table first + let schema = create_test_schema(); + let ipc_data = create_test_ipc_data(&schema); + let mut create_req = CreateTableRequest::new(); + let table_id = vec!["so_table".to_string()]; + create_req.id = Some(table_id.clone()); + namespace + .create_table(create_req, Bytes::from(ipc_data)) + .await + .unwrap(); + + // Insert with storage_options present — covers store_params closure + let arrow_schema = convert_json_arrow_schema(&schema).unwrap(); + let arrow_schema = Arc::new(arrow_schema); + + let id_array = Int32Array::from(vec![10, 20]); + let name_array = StringArray::from(vec!["X", "Y"]); + let batch = arrow::record_batch::RecordBatch::try_new( + arrow_schema.clone(), + vec![Arc::new(id_array), Arc::new(name_array)], + ) + .unwrap(); + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &arrow_schema).unwrap(); + writer.write(&batch).unwrap(); + writer.finish().unwrap(); + } + + let request = InsertIntoTableRequest { + id: Some(table_id.clone()), + mode: Some("append".to_string()), + ..Default::default() + }; + + let response = namespace + .insert_into_table(request, Bytes::from(buffer)) + .await + .unwrap(); + assert!(response.transaction_id.is_none()); + + // Verify rows were inserted + let count_req = CountTableRowsRequest { + id: Some(table_id), + version: None, + predicate: None, + ..Default::default() + }; + let count = namespace.count_table_rows(count_req).await.unwrap(); + assert_eq!(count, 2); + } + + #[tokio::test] + async fn test_query_table_basic() { + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + let request = QueryTableRequest { + id: Some(table_id), + k: 10, + filter: None, + offset: None, + version: None, + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + + // Decode IPC and verify + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 3); + } + + #[tokio::test] + async fn test_query_table_with_filter() { + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + let request = QueryTableRequest { + id: Some(table_id), + k: 10, + filter: Some("id <= 2".to_string()), + offset: None, + version: None, + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 2); + } + + #[tokio::test] + async fn test_query_table_with_limit_and_offset() { + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + let request = QueryTableRequest { + id: Some(table_id), + k: 2, + filter: None, + offset: Some(1), + version: None, + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 2); + } + + #[tokio::test] + async fn test_query_table_no_limit() { + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + // k=0 means no limit + let request = QueryTableRequest { + id: Some(table_id), + k: 0, + filter: None, + offset: None, + version: None, + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 3); + } + + #[tokio::test] + async fn test_query_table_with_columns() { + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + let columns = Box::new(lance_namespace::models::QueryTableRequestColumns { + column_names: Some(vec!["id".to_string()]), + column_aliases: None, + }); + + let request = QueryTableRequest { + id: Some(table_id), + k: 10, + filter: None, + offset: None, + version: None, + columns: Some(columns), + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let schema = reader.schema(); + assert_eq!(schema.fields().len(), 1); + assert_eq!(schema.field(0).name(), "id"); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 3); + } + + #[tokio::test] + async fn test_count_table_rows_with_version() { + use arrow::array::{Int32Array, StringArray}; + use arrow::ipc::writer::StreamWriter; + + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + // Insert more data to create version 2 + let schema = create_test_schema(); + let arrow_schema = convert_json_arrow_schema(&schema).unwrap(); + let arrow_schema = Arc::new(arrow_schema); + + let id_array = Int32Array::from(vec![4, 5]); + let name_array = StringArray::from(vec!["Dave", "Eve"]); + let batch = arrow::record_batch::RecordBatch::try_new( + arrow_schema.clone(), + vec![Arc::new(id_array), Arc::new(name_array)], + ) + .unwrap(); + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &arrow_schema).unwrap(); + writer.write(&batch).unwrap(); + writer.finish().unwrap(); + } + + let request = InsertIntoTableRequest { + id: Some(table_id.clone()), + mode: None, + ..Default::default() + }; + namespace + .insert_into_table(request, Bytes::from(buffer)) + .await + .unwrap(); + + // Version 1 should have 3 rows + let count_req = CountTableRowsRequest { + id: Some(table_id.clone()), + version: Some(1), + predicate: None, + ..Default::default() + }; + let count = namespace.count_table_rows(count_req).await.unwrap(); + assert_eq!(count, 3); + + // Latest version should have 5 rows + let count_req = CountTableRowsRequest { + id: Some(table_id), + version: None, + predicate: None, + ..Default::default() + }; + let count = namespace.count_table_rows(count_req).await.unwrap(); + assert_eq!(count, 5); + } + + #[tokio::test] + async fn test_query_table_with_version() { + use arrow::array::{Int32Array, StringArray}; + use arrow::ipc::writer::StreamWriter; + + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + // Insert more data to create version 2 + let schema = create_test_schema(); + let arrow_schema = convert_json_arrow_schema(&schema).unwrap(); + let arrow_schema = Arc::new(arrow_schema); + + let id_array = Int32Array::from(vec![4, 5]); + let name_array = StringArray::from(vec!["Dave", "Eve"]); + let batch = arrow::record_batch::RecordBatch::try_new( + arrow_schema.clone(), + vec![Arc::new(id_array), Arc::new(name_array)], + ) + .unwrap(); + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &arrow_schema).unwrap(); + writer.write(&batch).unwrap(); + writer.finish().unwrap(); + } + + let request = InsertIntoTableRequest { + id: Some(table_id.clone()), + mode: None, + ..Default::default() + }; + namespace + .insert_into_table(request, Bytes::from(buffer)) + .await + .unwrap(); + + // Query version 1 should return 3 rows + let request = QueryTableRequest { + id: Some(table_id.clone()), + k: 100, + filter: None, + offset: None, + version: Some(1), + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 3); + + // Query latest version should return 5 rows + let request = QueryTableRequest { + id: Some(table_id), + k: 100, + filter: None, + offset: None, + version: None, + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 5); + } + + /// Helper to create a namespace with a table that has a vector column for + /// vector search tests. + async fn create_ns_with_vector_table() -> (DirectoryNamespace, TempStdDir, Vec) { + use arrow::array::{FixedSizeListArray, Float32Array, Int32Array}; + use arrow::ipc::writer::StreamWriter; + + let (namespace, temp_dir) = create_test_namespace().await; + + // Build schema: id (int32), vector (fixed_size_list[4]) + let arrow_schema = Arc::new(arrow::datatypes::Schema::new(vec![ + arrow::datatypes::Field::new("id", arrow::datatypes::DataType::Int32, false), + arrow::datatypes::Field::new( + "vector", + arrow::datatypes::DataType::FixedSizeList( + Arc::new(arrow::datatypes::Field::new( + "item", + arrow::datatypes::DataType::Float32, + true, + )), + 4, + ), + true, + ), + ])); + + let id_array = Int32Array::from(vec![1, 2, 3]); + let values = Float32Array::from(vec![ + 1.0, 0.0, 0.0, 0.0, // vector for id=1 + 0.0, 1.0, 0.0, 0.0, // vector for id=2 + 0.0, 0.0, 1.0, 0.0, // vector for id=3 + ]); + let vector_array = FixedSizeListArray::try_new( + Arc::new(arrow::datatypes::Field::new( + "item", + arrow::datatypes::DataType::Float32, + true, + )), + 4, + Arc::new(values), + None, + ) + .unwrap(); + + let batch = arrow::record_batch::RecordBatch::try_new( + arrow_schema.clone(), + vec![Arc::new(id_array), Arc::new(vector_array)], + ) + .unwrap(); + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &arrow_schema).unwrap(); + writer.write(&batch).unwrap(); + writer.finish().unwrap(); + } + + // Write as a Lance dataset directly + let table_name = "vector_table"; + let table_uri = format!("{}/{}.lance", temp_dir.to_str().unwrap(), table_name); + let reader = arrow::record_batch::RecordBatchIterator::new( + vec![Ok(batch)], + arrow_schema.clone(), + ); + Dataset::write(reader, &table_uri, None).await.unwrap(); + + let table_id = vec![table_name.to_string()]; + (namespace, temp_dir, table_id) + } + + #[tokio::test] + async fn test_query_table_vector_search() { + let (namespace, _temp_dir, table_id) = create_ns_with_vector_table().await; + + let vector = Box::new(lance_namespace::models::QueryTableRequestVector { + single_vector: Some(vec![1.0, 0.0, 0.0, 0.0]), + multi_vector: None, + }); + + let request = QueryTableRequest { + id: Some(table_id), + k: 2, + vector, + filter: None, + offset: None, + version: None, + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 2); + } + + #[tokio::test] + async fn test_query_table_vector_search_with_distance_type() { + let (namespace, _temp_dir, table_id) = create_ns_with_vector_table().await; + + let vector = Box::new(lance_namespace::models::QueryTableRequestVector { + single_vector: Some(vec![1.0, 0.0, 0.0, 0.0]), + multi_vector: None, + }); + + let request = QueryTableRequest { + id: Some(table_id), + k: 3, + vector, + filter: None, + offset: None, + version: None, + distance_type: Some("cosine".to_string()), + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 3); + } + + #[tokio::test] + async fn test_query_table_vector_search_with_filter() { + let (namespace, _temp_dir, table_id) = create_ns_with_vector_table().await; + + let vector = Box::new(lance_namespace::models::QueryTableRequestVector { + single_vector: Some(vec![1.0, 0.0, 0.0, 0.0]), + multi_vector: None, + }); + + let request = QueryTableRequest { + id: Some(table_id), + k: 10, + vector, + filter: Some("id <= 2".to_string()), + offset: None, + version: None, + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert!(total_rows <= 2); + } + + #[tokio::test] + async fn test_query_table_vector_search_with_nprobes_and_refine() { + let (namespace, _temp_dir, table_id) = create_ns_with_vector_table().await; + + let vector = Box::new(lance_namespace::models::QueryTableRequestVector { + single_vector: Some(vec![0.0, 1.0, 0.0, 0.0]), + multi_vector: None, + }); + + let request = QueryTableRequest { + id: Some(table_id), + k: 2, + vector, + filter: None, + offset: None, + version: None, + nprobes: Some(1), + refine_factor: Some(1), + prefilter: Some(true), + ..Default::default() + }; + + let bytes = namespace.query_table(request).await.unwrap(); + + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 2); + } + + #[tokio::test] + async fn test_namespace_id() { + let (namespace, _temp_dir) = create_test_namespace().await; + let id = namespace.namespace_id(); + assert!(id.contains("DirectoryNamespace")); + assert!(id.contains("root")); + } + + #[tokio::test] + async fn test_query_table_empty_table() { + let (namespace, _temp_dir) = create_test_namespace().await; + + // Create table with empty IPC data (schema only, no rows) + let schema = create_test_schema(); + let ipc_data = create_test_ipc_data(&schema); + let mut create_request = CreateTableRequest::new(); + create_request.id = Some(vec!["empty_table".to_string()]); + namespace + .create_table(create_request, bytes::Bytes::from(ipc_data)) + .await + .unwrap(); + + // Query the empty table — should hit the "no batches" else branch + let vector = Box::new(lance_namespace::models::QueryTableRequestVector { + single_vector: None, + multi_vector: None, + }); + let request = QueryTableRequest { + id: Some(vec!["empty_table".to_string()]), + k: 10, + vector, + ..Default::default() + }; + let bytes = namespace.query_table(request).await.unwrap(); + + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.collect::, _>>().unwrap(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_rows, 0, "empty table should yield no rows"); + } + + #[tokio::test] + async fn test_query_table_with_plain_filter_no_vector() { + let (namespace, _temp_dir, table_id) = create_ns_with_table().await; + + // Query with filter but no vector (plain scan path + filter) + let vector = Box::new(lance_namespace::models::QueryTableRequestVector { + single_vector: None, + multi_vector: None, + }); + let request = QueryTableRequest { + id: Some(table_id), + k: 0, + vector, + filter: Some("id > 1".to_string()), + ..Default::default() + }; + let bytes = namespace.query_table(request).await.unwrap(); + + let cursor = Cursor::new(bytes.to_vec()); + let reader = FileReader::try_new(cursor, None).unwrap(); + let batches: Vec<_> = reader.into_iter().map(|b| b.unwrap()).collect(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert!(total_rows > 0); + assert!(total_rows < 3); + } } /// Tests for multi-table transaction support via table_version_storage_enabled. diff --git a/rust/lance-namespace-impls/src/rest_adapter.rs b/rust/lance-namespace-impls/src/rest_adapter.rs index c126b219f54..94a09d87cfb 100644 --- a/rust/lance-namespace-impls/src/rest_adapter.rs +++ b/rust/lance-namespace-impls/src/rest_adapter.rs @@ -12,12 +12,12 @@ use std::sync::Arc; use axum::{ Json, Router, ServiceExt, body::Bytes, - extract::{Path, Query, Request, State}, + extract::{FromRequest, Path, Query, Request, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, }; -use serde::Deserialize; +use serde::{Deserialize, de::DeserializeOwned}; use tokio::sync::watch; use tower::Layer; use tower_http::normalize_path::NormalizePathLayer; @@ -102,7 +102,7 @@ impl RestAdapter { "/v1/table/:id/create_scalar_index", post(create_table_scalar_index), ) - .route("/v1/table/:id/index/list", get(list_table_indices)) + .route("/v1/table/:id/index/list", post(list_table_indices)) .route( "/v1/table/:id/index/:index_name/stats", get(describe_table_index_stats), @@ -232,9 +232,51 @@ impl RestAdapterHandle { } // ============================================================================ -// Query Parameters +// Query Parameters and Extractors // ============================================================================ +/// Optional JSON body extractor that allows empty request bodies. +/// Similar to sophon's MaybeJson - returns None if body is empty. +struct MaybeJson(Option); + +impl FromRequest for MaybeJson +where + S: Send + Sync, + T: DeserializeOwned + Send + 'static, +{ + type Rejection = Response; + + fn from_request<'life0, 'async_trait>( + req: Request, + state: &'life0 S, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'async_trait, + >, + > + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + let bytes = Bytes::from_request(req, state) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()).into_response())?; + + if bytes.is_empty() { + return Ok(Self(None)); + } + + match serde_json::from_slice(&bytes) { + Ok(value) => Ok(Self(Some(value))), + Err(e) => Err((StatusCode::BAD_REQUEST, e.to_string()).into_response()), + } + }) + } +} + #[derive(Debug, Deserialize)] struct DelimiterQuery { delimiter: Option, @@ -910,15 +952,11 @@ async fn list_table_indices( headers: HeaderMap, Path(id): Path, Query(params): Query, + MaybeJson(body): MaybeJson, ) -> Response { - let request = ListTableIndicesRequest { - id: Some(parse_id(&id, params.delimiter.as_deref())), - version: None, - page_token: None, - limit: None, - identity: extract_identity(&headers), - ..Default::default() - }; + let mut request = body.unwrap_or_default(); + request.id = Some(parse_id(&id, params.delimiter.as_deref())); + request.identity = extract_identity(&headers); match backend.list_table_indices(request).await { Ok(response) => (StatusCode::OK, Json(response)).into_response(), @@ -1396,6 +1434,55 @@ mod tests { Bytes::from(buffer) } + /// Helper to create Arrow IPC data with vector column for testing vector index + fn create_test_vector_data(num_rows: usize, dim: i32) -> Bytes { + use arrow::array::{FixedSizeListArray, Float32Array, Int32Array}; + use arrow::datatypes::{DataType, Field, Schema}; + use arrow::ipc::writer::StreamWriter; + use arrow::record_batch::RecordBatch; + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + dim, + ), + true, + ), + ])); + + let ids: Vec = (0..num_rows as i32).collect(); + let vector_values: Vec = (0..(num_rows * dim as usize)) + .map(|i| (i as f32) * 0.01) + .collect(); + + let vector_field = Arc::new(Field::new("item", DataType::Float32, true)); + let vectors = FixedSizeListArray::try_new( + vector_field, + dim, + Arc::new(Float32Array::from(vector_values)), + None, + ) + .unwrap(); + + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(ids)), Arc::new(vectors)], + ) + .unwrap(); + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &schema).unwrap(); + writer.write(&batch).unwrap(); + writer.finish().unwrap(); + } + + Bytes::from(buffer) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_trailing_slash_handling() { let fixture = RestServerFixture::new().await; @@ -3070,5 +3157,144 @@ mod tests { "Latest version should be 1" ); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_create_and_list_table_index() { + let fixture = RestServerFixture::new().await; + let table_data = create_test_arrow_data(); + + // Create namespace + let create_ns_req = CreateNamespaceRequest { + id: Some(vec!["index_test_ns".to_string()]), + ..Default::default() + }; + fixture + .namespace + .create_namespace(create_ns_req) + .await + .unwrap(); + + // Create table + let create_table_req = CreateTableRequest { + id: Some(vec![ + "index_test_ns".to_string(), + "index_test_table".to_string(), + ]), + mode: Some("Create".to_string()), + ..Default::default() + }; + fixture + .namespace + .create_table(create_table_req, table_data) + .await + .unwrap(); + + // Create scalar index on 'id' column + let create_index_req = CreateTableIndexRequest { + id: Some(vec![ + "index_test_ns".to_string(), + "index_test_table".to_string(), + ]), + column: "id".to_string(), + index_type: "BTREE".to_string(), + name: Some("id_idx".to_string()), + ..Default::default() + }; + let result = fixture.namespace.create_table_index(create_index_req).await; + assert!(result.is_ok(), "Failed to create index: {:?}", result.err()); + + // List indices + let list_indices_req = ListTableIndicesRequest { + id: Some(vec![ + "index_test_ns".to_string(), + "index_test_table".to_string(), + ]), + ..Default::default() + }; + let result = fixture.namespace.list_table_indices(list_indices_req).await; + assert!(result.is_ok(), "Failed to list indices: {:?}", result.err()); + let indices = result.unwrap(); + assert_eq!(indices.indexes.len(), 1, "Should have exactly one index"); + assert_eq!( + indices.indexes[0].index_name, "id_idx", + "Index name should match" + ); + assert_eq!( + indices.indexes[0].columns, + vec!["id"], + "Index column should be 'id'" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_create_vector_index() { + let fixture = RestServerFixture::new().await; + // Create 256 rows with 8-dimensional vectors for vector index + let table_data = create_test_vector_data(256, 8); + + // Create namespace + let create_ns_req = CreateNamespaceRequest { + id: Some(vec!["vector_index_ns".to_string()]), + ..Default::default() + }; + fixture + .namespace + .create_namespace(create_ns_req) + .await + .unwrap(); + + // Create table with vector data + let create_table_req = CreateTableRequest { + id: Some(vec![ + "vector_index_ns".to_string(), + "vector_table".to_string(), + ]), + mode: Some("Create".to_string()), + ..Default::default() + }; + fixture + .namespace + .create_table(create_table_req, table_data) + .await + .unwrap(); + + // Create vector index on 'vector' column using IVF_FLAT + let mut create_index_req = + CreateTableIndexRequest::new("vector".to_string(), "IVF_FLAT".to_string()); + create_index_req.id = Some(vec![ + "vector_index_ns".to_string(), + "vector_table".to_string(), + ]); + create_index_req.name = Some("vector_idx".to_string()); + create_index_req.distance_type = Some("l2".to_string()); + let result = fixture.namespace.create_table_index(create_index_req).await; + assert!( + result.is_ok(), + "Failed to create vector index: {:?}", + result.err() + ); + + // List indices to verify + let list_indices_req = ListTableIndicesRequest { + id: Some(vec![ + "vector_index_ns".to_string(), + "vector_table".to_string(), + ]), + ..Default::default() + }; + let result = fixture.namespace.list_table_indices(list_indices_req).await; + assert!(result.is_ok(), "Failed to list indices: {:?}", result.err()); + let indices = result.unwrap(); + assert_eq!(indices.indexes.len(), 1, "Should have exactly one index"); + assert_eq!( + indices.indexes[0].index_name, "vector_idx", + "Index name should match" + ); + assert_eq!( + indices.indexes[0].columns, + vec!["vector"], + "Index column should be 'vector'" + ); + } } } From d630106da5a238b3adfb8c5dea3b3921f3519945 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Sat, 4 Apr 2026 07:28:39 +0000 Subject: [PATCH 143/206] chore: release beta version 5.0.0-beta.5 --- .bumpversion.toml | 2 +- Cargo.lock | 62 +++++++++++++++++++-------------------- Cargo.toml | 40 ++++++++++++------------- java/lance-jni/Cargo.lock | 54 +++++++++++++++++----------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 58 ++++++++++++++++++------------------ python/Cargo.toml | 2 +- 8 files changed, 111 insertions(+), 111 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 2ca3c888102..f87a7794fc2 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "5.0.0-beta.4" +current_version = "5.0.0-beta.5" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 8c641036c03..c16b1158854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1495,9 +1495,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "jobserver", @@ -3348,7 +3348,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "lance-datagen", @@ -4281,9 +4281,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -4580,7 +4580,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "all_asserts", "approx", @@ -4676,7 +4676,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "arrow-buffer", @@ -4725,7 +4725,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrayref", "paste", @@ -4734,7 +4734,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "arrow-buffer", @@ -4774,7 +4774,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -4808,7 +4808,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -4828,7 +4828,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-arith", "arrow-array", @@ -4876,7 +4876,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "all_asserts", "arrow", @@ -4902,7 +4902,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-arith", "arrow-array", @@ -4945,7 +4945,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "datafusion", "geo-traits", @@ -4959,7 +4959,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "approx", "arrow", @@ -5040,7 +5040,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-arith", @@ -5091,7 +5091,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "approx", "arrow-array", @@ -5112,7 +5112,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "async-trait", @@ -5127,7 +5127,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -5145,7 +5145,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-ipc", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -5250,7 +5250,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "proc-macro2", "quote", @@ -5259,7 +5259,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "arrow-schema", @@ -5270,7 +5270,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "clap", "lance-core", @@ -7798,9 +7798,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -8776,9 +8776,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -8793,9 +8793,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index b057ed60205..fe97eb3f5a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ resolver = "3" [workspace.package] -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -53,24 +53,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=5.0.0-beta.4", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=5.0.0-beta.4", path = "./rust/lance-arrow" } -lance-core = { version = "=5.0.0-beta.4", path = "./rust/lance-core" } -lance-datafusion = { version = "=5.0.0-beta.4", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=5.0.0-beta.4", path = "./rust/lance-datagen" } -lance-encoding = { version = "=5.0.0-beta.4", path = "./rust/lance-encoding" } -lance-file = { version = "=5.0.0-beta.4", path = "./rust/lance-file" } -lance-geo = { version = "=5.0.0-beta.4", path = "./rust/lance-geo" } -lance-index = { version = "=5.0.0-beta.4", path = "./rust/lance-index" } -lance-io = { version = "=5.0.0-beta.4", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=5.0.0-beta.4", path = "./rust/lance-linalg" } -lance-namespace = { version = "=5.0.0-beta.4", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=5.0.0-beta.4", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=5.0.0-beta.4", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=5.0.0-beta.5", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=5.0.0-beta.5", path = "./rust/lance-arrow" } +lance-core = { version = "=5.0.0-beta.5", path = "./rust/lance-core" } +lance-datafusion = { version = "=5.0.0-beta.5", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=5.0.0-beta.5", path = "./rust/lance-datagen" } +lance-encoding = { version = "=5.0.0-beta.5", path = "./rust/lance-encoding" } +lance-file = { version = "=5.0.0-beta.5", path = "./rust/lance-file" } +lance-geo = { version = "=5.0.0-beta.5", path = "./rust/lance-geo" } +lance-index = { version = "=5.0.0-beta.5", path = "./rust/lance-index" } +lance-io = { version = "=5.0.0-beta.5", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=5.0.0-beta.5", path = "./rust/lance-linalg" } +lance-namespace = { version = "=5.0.0-beta.5", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=5.0.0-beta.5", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=5.0.0-beta.5", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=5.0.0-beta.4", path = "./rust/lance-table" } -lance-test-macros = { version = "=5.0.0-beta.4", path = "./rust/lance-test-macros" } -lance-testing = { version = "=5.0.0-beta.4", path = "./rust/lance-testing" } +lance-table = { version = "=5.0.0-beta.5", path = "./rust/lance-table" } +lance-test-macros = { version = "=5.0.0-beta.5", path = "./rust/lance-test-macros" } +lance-testing = { version = "=5.0.0-beta.5", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -97,7 +97,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=5.0.0-beta.4", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=5.0.0-beta.5", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -138,7 +138,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=5.0.0-beta.4", path = "./rust/compression/fsst" } +fsst = { version = "=5.0.0-beta.5", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 04ae7e95373..09143a6adf0 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -1085,9 +1085,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "jobserver", @@ -2398,7 +2398,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3161,9 +3161,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -3397,7 +3397,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-arith", @@ -3463,7 +3463,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "arrow-buffer", @@ -3483,7 +3483,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrayref", "paste", @@ -3492,7 +3492,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "arrow-buffer", @@ -3529,7 +3529,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -3562,7 +3562,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -3580,7 +3580,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-arith", "arrow-array", @@ -3617,7 +3617,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-arith", "arrow-array", @@ -3649,7 +3649,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "datafusion", "geo-traits", @@ -3663,7 +3663,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-arith", @@ -3731,7 +3731,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-arith", @@ -3775,7 +3775,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-schema", @@ -3809,7 +3809,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "arrow-buffer", @@ -3825,7 +3825,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "async-trait", @@ -3838,7 +3838,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-ipc", @@ -3883,7 +3883,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -5614,9 +5614,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -6361,9 +6361,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -6378,9 +6378,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 1520e59c3e3..08accd072ec 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 08e3ca90a64..cf04d17547e 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 5.0.0-beta.4 + 5.0.0-beta.5 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index 40eecaaae8f..805c61d69bc 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -1244,9 +1244,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "jobserver", @@ -2827,7 +2827,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3632,9 +3632,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -3865,7 +3865,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-arith", @@ -3932,7 +3932,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "arrow-buffer", @@ -3952,7 +3952,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrayref", "paste", @@ -3961,7 +3961,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "arrow-buffer", @@ -3998,7 +3998,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -4031,7 +4031,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-arith", "arrow-array", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-arith", "arrow-array", @@ -4118,7 +4118,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "datafusion", "geo-traits", @@ -4132,7 +4132,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-arith", @@ -4203,7 +4203,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-arith", @@ -4247,7 +4247,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow-array", "arrow-buffer", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "async-trait", @@ -4276,7 +4276,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-ipc", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -5685,7 +5685,7 @@ dependencies = [ [[package]] name = "pylance" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" dependencies = [ "arrow", "arrow-array", @@ -6520,9 +6520,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -7319,9 +7319,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -7336,9 +7336,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -7594,9 +7594,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "typewit" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06fee3a8df48c50c55ad646a4e03b00a370da6fe1850ebf467a8d0165dfcafae" +checksum = "bc19094686c694eb41b3b99dcc2f2975d4b078512fa22ae6c63f7ca318bdcff7" [[package]] name = "typify" diff --git a/python/Cargo.toml b/python/Cargo.toml index 843cd68acab..0c6fe92b045 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "5.0.0-beta.4" +version = "5.0.0-beta.5" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From efc937450e58eb4525160a2e36402ff0b95ba5d6 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Mon, 6 Apr 2026 11:49:46 -0700 Subject: [PATCH 144/206] feat: validate DataFile column_indices at commit time (#6414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `check_column_indices()` validation in `rust/lance/src/io/commit.rs` that rejects non-leaf fields (structs, lists) with real column indices in v2.1+ data files at commit time, preventing cryptic read-time errors - Exempts packed structs and blob fields which legitimately have column indices in v2.1+ - Wired into both `commit_transaction` and `do_commit_detached_transaction` paths Closes https://github.com/lance-format/lance/issues/6412 ## Test plan - [x] `test_check_column_indices_rejects_struct_with_column` — struct with column_index=0 in v2.1 → error - [x] `test_check_column_indices_rejects_list_with_column` — list with column_index=0 in v2.1 → error - [x] `test_check_column_indices_allows_correct_v21` — correct indices (non-leaf=-1, leaf>=0) → ok - [x] `test_check_column_indices_allows_packed_struct` — packed struct with real column_index → ok - [x] `test_check_column_indices_skips_v20` — non-leaf with column_index>=0 in v2.0 → ok (no validation) - [x] `cargo clippy -p lance --tests -- -D warnings` passes - [x] `cargo fmt --all` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 --- rust/lance/src/io/commit.rs | 275 ++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) diff --git a/rust/lance/src/io/commit.rs b/rust/lance/src/io/commit.rs index 14e92216b3c..7c35957fd75 100644 --- a/rust/lance/src/io/commit.rs +++ b/rust/lance/src/io/commit.rs @@ -362,6 +362,67 @@ fn check_storage_version(manifest: &mut Manifest) -> Result<()> { Ok(()) } +fn check_column_indices(manifest: &Manifest) -> Result<()> { + let data_storage_version = manifest.data_storage_format.lance_file_version()?; + if data_storage_version < LanceFileVersion::V2_1 { + return Ok(()); + } + + for fragment in manifest.fragments.iter() { + for data_file in &fragment.files { + if data_file.is_legacy_file() || data_file.column_indices.is_empty() { + continue; + } + if data_file.fields.len() != data_file.column_indices.len() { + return Err(Error::invalid_input(format!( + "Data file '{}' (fragment {}) has {} field ids but {} column indices. \ + These must be the same length.", + data_file.path, + fragment.id, + data_file.fields.len(), + data_file.column_indices.len() + ))); + } + let file_version = LanceFileVersion::try_from_major_minor( + data_file.file_major_version, + data_file.file_minor_version, + )?; + if file_version < LanceFileVersion::V2_1 { + continue; + } + for (field_id, column_index) in + data_file.fields.iter().zip(data_file.column_indices.iter()) + { + // Field ids may not exist in the current schema after schema + // evolution (e.g. cast/drop column). Skip those. + let Some(field) = manifest.schema.field_by_id(*field_id) else { + continue; + }; + let needs_column = field.is_leaf() || field.is_packed_struct() || field.is_blob(); + if needs_column && *column_index == -1 { + return Err(Error::invalid_input(format!( + "Field '{}' (id={}) in data file '{}' (fragment {}) \ + has column_index=-1, but leaf fields, packed structs, \ + and blob fields must have a valid column index in \ + file format 2.1+.", + field.name, field_id, data_file.path, fragment.id + ))); + } + if !needs_column && *column_index != -1 { + return Err(Error::invalid_input(format!( + "Non-leaf field '{}' (id={}) in data file '{}' (fragment {}) \ + has column_index={}, but non-leaf fields should have \ + column_index=-1 in file format 2.1+. Only leaf fields, \ + packed structs, and blob fields should have column indices.", + field.name, field_id, data_file.path, fragment.id, column_index + ))); + } + } + } + } + Ok(()) +} + /// Fix schema in case of duplicate field ids. /// /// See test dataset v0.10.5/corrupt_schema @@ -720,6 +781,7 @@ pub(crate) async fn do_commit_detached_transaction( // fix_schema and check_storage_version are just for sanity-checking and consistency fix_schema(&mut manifest)?; check_storage_version(&mut manifest)?; + check_column_indices(&manifest)?; migrate_indices(dataset, &mut indices).await?; // Try to commit the manifest @@ -917,6 +979,7 @@ pub(crate) async fn commit_transaction( fix_schema(&mut manifest)?; check_storage_version(&mut manifest)?; + check_column_indices(&manifest)?; migrate_indices(&dataset, &mut indices).await?; @@ -1636,4 +1699,216 @@ mod tests { ]; assert_eq!(manifest.fragments.as_ref(), &expected_fragments); } + + /// Helper to build a simple manifest for check_column_indices tests. + fn make_manifest_with_file( + schema: Schema, + data_file: DataFile, + data_storage_version: LanceFileVersion, + ) -> Manifest { + let fragment = Fragment { + id: 0, + files: vec![data_file], + deletion_file: None, + row_id_meta: None, + physical_rows: Some(100), + last_updated_at_version_meta: None, + created_at_version_meta: None, + }; + Manifest::new( + schema, + Arc::new(vec![fragment]), + DataStorageFormat::new(data_storage_version), + HashMap::new(), + ) + } + + #[test] + fn test_check_column_indices_rejects_struct_with_column() { + // Struct (non-leaf) field with column_index=0 in v2.1 should be rejected. + let mut struct_field = Field::try_from(ArrowField::new( + "s", + DataType::Struct(vec![ArrowField::new("x", DataType::Int32, false)].into()), + false, + )) + .unwrap(); + struct_field.set_id(-1, &mut 0); + + let schema = Schema { + fields: vec![struct_field], + metadata: Default::default(), + }; + + // field ids: struct=0, leaf=1; give struct a real column_index (wrong) + let data_file = DataFile::new("data.lance", vec![0, 1], vec![0, 1], 2, 1, None, None); + let manifest = make_manifest_with_file(schema, data_file, LanceFileVersion::V2_1); + let result = check_column_indices(&manifest); + assert!( + result.is_err(), + "Expected error for struct with column_index=0" + ); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("Non-leaf field"), "{msg}"); + } + + #[test] + fn test_check_column_indices_rejects_list_with_column() { + // List (non-leaf) field with column_index=0 in v2.1 should be rejected. + let mut list_field = Field::try_from(ArrowField::new( + "l", + DataType::List(Arc::new(ArrowField::new("item", DataType::Int32, true))), + false, + )) + .unwrap(); + list_field.set_id(-1, &mut 0); + + let schema = Schema { + fields: vec![list_field], + metadata: Default::default(), + }; + + // field ids: list=0, item=1; give list a real column_index (wrong) + let data_file = DataFile::new("data.lance", vec![0, 1], vec![0, 1], 2, 1, None, None); + let manifest = make_manifest_with_file(schema, data_file, LanceFileVersion::V2_1); + let result = check_column_indices(&manifest); + assert!( + result.is_err(), + "Expected error for list with column_index=0" + ); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("Non-leaf field"), "{msg}"); + } + + #[test] + fn test_check_column_indices_allows_correct_v21() { + // Non-leaf with column_index=-1 and leaf with column_index>=0 should pass. + let mut struct_field = Field::try_from(ArrowField::new( + "s", + DataType::Struct(vec![ArrowField::new("x", DataType::Int32, false)].into()), + false, + )) + .unwrap(); + struct_field.set_id(-1, &mut 0); + + let schema = Schema { + fields: vec![struct_field], + metadata: Default::default(), + }; + + // struct=-1 (correct), leaf=0 (correct) + let data_file = DataFile::new("data.lance", vec![0, 1], vec![-1, 0], 2, 1, None, None); + let manifest = make_manifest_with_file(schema, data_file, LanceFileVersion::V2_1); + assert!(check_column_indices(&manifest).is_ok()); + } + + #[test] + fn test_check_column_indices_allows_packed_struct() { + // Packed struct with a real column_index in v2.1 should be allowed. + let mut struct_field = Field::try_from(ArrowField::new( + "s", + DataType::Struct(vec![ArrowField::new("x", DataType::Int32, false)].into()), + false, + )) + .unwrap(); + struct_field.set_id(-1, &mut 0); + struct_field + .metadata + .insert("lance-encoding:packed".to_string(), "true".to_string()); + + let schema = Schema { + fields: vec![struct_field], + metadata: Default::default(), + }; + + // packed struct=0 (allowed), leaf=1 + let data_file = DataFile::new("data.lance", vec![0, 1], vec![0, 1], 2, 1, None, None); + let manifest = make_manifest_with_file(schema, data_file, LanceFileVersion::V2_1); + assert!(check_column_indices(&manifest).is_ok()); + } + + #[test] + fn test_check_column_indices_skips_v20() { + // Non-leaf with column_index>=0 in v2.0 should be allowed (no validation). + let mut struct_field = Field::try_from(ArrowField::new( + "s", + DataType::Struct(vec![ArrowField::new("x", DataType::Int32, false)].into()), + false, + )) + .unwrap(); + struct_field.set_id(-1, &mut 0); + + let schema = Schema { + fields: vec![struct_field], + metadata: Default::default(), + }; + + let data_file = DataFile::new("data.lance", vec![0, 1], vec![0, 1], 2, 0, None, None); + let manifest = make_manifest_with_file(schema, data_file, LanceFileVersion::V2_0); + assert!(check_column_indices(&manifest).is_ok()); + } + + #[test] + fn test_check_column_indices_rejects_mismatched_lengths() { + // fields and column_indices must have the same length. + let mut leaf_field = Field::try_from(ArrowField::new("x", DataType::Int32, false)).unwrap(); + leaf_field.set_id(-1, &mut 0); + + let schema = Schema { + fields: vec![leaf_field], + metadata: Default::default(), + }; + + // 1 field id but 2 column indices + let data_file = DataFile::new("data.lance", vec![0], vec![0, 1], 2, 1, None, None); + let manifest = make_manifest_with_file(schema, data_file, LanceFileVersion::V2_1); + let result = check_column_indices(&manifest); + assert!(result.is_err(), "Expected error for mismatched lengths"); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("1 field ids but 2 column indices"), "{msg}"); + } + + #[test] + fn test_check_column_indices_skips_unknown_field_id() { + // A field id not present in the schema is skipped (schema evolution). + let mut leaf_field = Field::try_from(ArrowField::new("x", DataType::Int32, false)).unwrap(); + leaf_field.set_id(-1, &mut 0); + + let schema = Schema { + fields: vec![leaf_field], + metadata: Default::default(), + }; + + // field id 99 does not exist in the schema — should be skipped + let data_file = DataFile::new("data.lance", vec![0, 99], vec![0, 1], 2, 1, None, None); + let manifest = make_manifest_with_file(schema, data_file, LanceFileVersion::V2_1); + assert!(check_column_indices(&manifest).is_ok()); + } + + #[test] + fn test_check_column_indices_rejects_leaf_with_negative_one() { + // A leaf field with column_index=-1 in v2.1 should be rejected. + let mut struct_field = Field::try_from(ArrowField::new( + "s", + DataType::Struct(vec![ArrowField::new("x", DataType::Int32, false)].into()), + false, + )) + .unwrap(); + struct_field.set_id(-1, &mut 0); + + let schema = Schema { + fields: vec![struct_field], + metadata: Default::default(), + }; + + // struct=-1 (correct), but leaf=-1 (wrong — leaf must have a real column) + let data_file = DataFile::new("data.lance", vec![0, 1], vec![-1, -1], 2, 1, None, None); + let manifest = make_manifest_with_file(schema, data_file, LanceFileVersion::V2_1); + let result = check_column_indices(&manifest); + assert!( + result.is_err(), + "Expected error for leaf with column_index=-1" + ); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("must have a valid column index"), "{msg}"); + } } From 6199e6c7fb5f3fdcdd832428e97050e1cc41102c Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Mon, 6 Apr 2026 11:50:03 -0700 Subject: [PATCH 145/206] docs: document DataFile column_indices changes in 2.1 format (#6416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add a 5.0.0 section to the migration guide documenting how `DataFile.column_indices` changed with data storage version 2.1: non-leaf fields (structs, lists) now get `-1` instead of sequential column indices - Add an admonition to the table format spec's Data Files section noting the version difference - Includes a concrete before/after example and opt-out instructions Closes #6411 ## Test plan - [x] Docs build successfully with `mkdocs build` - [ ] Verify rendered migration guide section and table format admonition look correct 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 --- docs/src/format/table/index.md | 11 +++++++++++ docs/src/guide/migration.md | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/docs/src/format/table/index.md b/docs/src/format/table/index.md index 45484e7b8b8..d430061451f 100644 --- a/docs/src/format/table/index.md +++ b/docs/src/format/table/index.md @@ -121,6 +121,17 @@ or independently of column indices due to variable encoding widths (for Lance fi +!!! note "Field-to-column mapping differs between data storage versions" + + In **2.0**, all fields (including non-leaf fields like struct and list containers) are assigned + sequential column indices in `column_indices`. + + In **2.1+**, non-leaf fields (unpacked structs, list containers) are assigned `-1` in + `column_indices` because their validity information is folded into repetition/definition + levels. Only leaf fields and packed structs have column indices. + + See the [5.0.0 migration guide](../../guide/migration.md#500) for a detailed example. + ## Deletion Files Deletion files (a.k.a. deletion vectors) track deleted rows without rewriting data files. diff --git a/docs/src/guide/migration.md b/docs/src/guide/migration.md index 9b7471ed07c..23cab4e9745 100644 --- a/docs/src/guide/migration.md +++ b/docs/src/guide/migration.md @@ -6,6 +6,41 @@ stable and breaking changes should generally be communicated (via warnings) for give users a chance to migrate. This page documents the breaking changes between releases and gives advice on how to migrate. +## 5.0.0 + +* The default data storage version changed from 2.0 to 2.1. This affects the `column_indices` + field in the `DataFile` protobuf message. In 2.0, every field (including non-leaf fields like + struct containers and list containers) was assigned a sequential column index. In 2.1, non-leaf + fields (unpacked structs, list containers) are assigned `-1` instead since their validity + information is now folded into repetition/definition levels. Only leaf fields and packed structs + are assigned column indices. + + For example, given the schema: + + ``` + x: i32, y: [f32], z: { a: i32 } + ``` + + The fields (in depth-first order) are: + + | Field ID | Field | + |----------|---------------| + | 0 | `x` (i32) | + | 1 | `y` (list) | + | 2 | `y.item` (f32)| + | 3 | `z` (struct) | + | 4 | `z.a` (i32) | + + In **2.0**, `column_indices` = `[0, 1, 2, 3, 4]` — every field gets a column. + + In **2.1**, `column_indices` = `[0, -1, 1, -1, 2]` — non-leaf fields (`y` and `z`) get `-1`. + +* This change only affects advanced users who construct `DataFile` messages directly, for example + when building operations by hand for `Dataset.commit`. Normal read and write paths are + unaffected. + +* To opt back to 2.0 format, set `data_storage_version="2.0"` when creating a dataset. + ## 1.0.0 * The `SearchResult` returned by scalar indices must now output information about null values. From a43e725f6ea0c9d3cb8055528af0532d72183a78 Mon Sep 17 00:00:00 2001 From: Peng Wang Date: Mon, 6 Apr 2026 19:15:46 -0400 Subject: [PATCH 146/206] fix: implement RowIdMeta.asdict() and from_dict() for stable row ID serialization (#6405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #6403 `FragmentMetadata.to_json()` raised `NotImplementedError` when `row_id_meta` was present (`enable_stable_row_ids=True`), because `PyRowIdMeta.asdict()` was stubbed out. This also broke `FragmentMetadata.from_json()` for the round-trip, since `RowIdMeta(**dict)` doesn't work on a PyO3 class. ### Repro ```python import lance, pyarrow as pa uri = "/tmp/repro.lance" ds = lance.write_dataset(pa.table({"x": [1, 2, 3]}), uri, enable_stable_row_ids=True) ds.get_fragments()[0].metadata.to_json() # NotImplementedError: PyRowIdMeta.asdict is not yet supported. ``` ### Fix - Implement `PyRowIdMeta.asdict()` via `pythonize` (Rust struct → Python dict) - Add `PyRowIdMeta.from_dict()` via `depythonize` (Python dict → Rust struct) - Update `FragmentMetadata.from_json()` to use `RowIdMeta.from_dict()` instead of `RowIdMeta(**dict)` - Add JSON round-trip test to the existing `test_fragment_metadata_pickle` parametrized test --- python/python/lance/fragment.py | 2 +- python/python/tests/test_fragment.py | 9 ++++++++- python/src/fragment.rs | 15 +++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/python/python/lance/fragment.py b/python/python/lance/fragment.py index ffdfacf443c..150e4db7ab7 100644 --- a/python/python/lance/fragment.py +++ b/python/python/lance/fragment.py @@ -143,7 +143,7 @@ def from_json(json_data: str) -> FragmentMetadata: row_id_meta = json_data.get("row_id_meta") if row_id_meta is not None: - row_id_meta = RowIdMeta(**row_id_meta) + row_id_meta = RowIdMeta.from_dict(row_id_meta) created_at_version_meta = json_data.get("created_at_version_meta") if created_at_version_meta is not None: diff --git a/python/python/tests/test_fragment.py b/python/python/tests/test_fragment.py index 40e2a69df22..c6cee711047 100644 --- a/python/python/tests/test_fragment.py +++ b/python/python/tests/test_fragment.py @@ -472,9 +472,16 @@ def test_fragment_metadata_pickle(tmp_path: Path, enable_stable_row_ids: bool): # Pickle and unpickle the fragment metadata round_trip = pickle.loads(pickle.dumps(frag_meta)) - assert frag_meta == round_trip + # JSON round-trip + json_data = frag_meta.to_json() + json_round_trip = FragmentMetadata.from_json(json.dumps(json_data)) + assert frag_meta.id == json_round_trip.id + assert frag_meta.physical_rows == json_round_trip.physical_rows + if enable_stable_row_ids: + assert json_round_trip.row_id_meta is not None + def test_deletion_file_with_base_id_serialization(): """Test that DeletionFile with base_id serializes correctly.""" diff --git a/python/src/fragment.rs b/python/src/fragment.rs index aec4bfac58b..66817c6b322 100644 --- a/python/src/fragment.rs +++ b/python/src/fragment.rs @@ -627,10 +627,10 @@ pub struct PyRowDatasetVersionMeta(pub RowDatasetVersionMeta); #[pymethods] impl PyRowIdMeta { - fn asdict(&self) -> PyResult> { - Err(PyNotImplementedError::new_err( - "PyRowIdMeta.asdict is not yet supported.s", - )) + fn asdict(&self, py: Python<'_>) -> PyResult> { + pythonize::pythonize(py, &self.0) + .map(|b| b.unbind()) + .map_err(|err| PyValueError::new_err(format!("Could not convert RowIdMeta: {}", err))) } pub fn json(&self) -> PyResult { @@ -650,6 +650,13 @@ impl PyRowIdMeta { Ok(Self(row_id_meta)) } + #[staticmethod] + pub fn from_dict(dict: &Bound<'_, PyAny>) -> PyResult { + let row_id_meta: RowIdMeta = pythonize::depythonize(dict) + .map_err(|err| PyValueError::new_err(format!("Could not load RowIdMeta: {}", err)))?; + Ok(Self(row_id_meta)) + } + fn __reduce__(&self, py: Python<'_>) -> PyResult<(Py, Py)> { let state = self.json()?; let state = PyTuple::new(py, vec![state])?.extract()?; From 4f6e3c12816303746472dd1dfa4722f0733d75b1 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Tue, 7 Apr 2026 15:31:29 +0800 Subject: [PATCH 147/206] fix: avoid corrupting sub-schema merge_insert pages on v2.2 (#6420) This canonicalizes all-valid validity bitmaps into the same rep/def state as no-null arrays, so sub-schema `merge_insert` updates on `data_storage_version=2.2` stop emitting inconsistent control-word metadata and corrupting variable-width pages. This PR picks up the proposed fix for #6338 and adds regression coverage for both the end-to-end binary `merge_insert` failure and the underlying repdef canonicalization invariant. --------- Co-authored-by: Eran Dagan --- rust/lance-encoding/src/repdef.rs | 24 ++++ .../src/dataset/tests/dataset_merge_update.rs | 129 ++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/rust/lance-encoding/src/repdef.rs b/rust/lance-encoding/src/repdef.rs index 939e20f2050..fc11e25f931 100644 --- a/rust/lance-encoding/src/repdef.rs +++ b/rust/lance-encoding/src/repdef.rs @@ -817,6 +817,10 @@ impl RepDefBuilder { /// Registers a nullable validity bitmap pub fn add_validity_bitmap(&mut self, validity: NullBuffer) { self.check_validity_len(validity.len()); + if validity.null_count() == 0 { + self.add_no_null(validity.len()); + return; + } self.repdefs.push(RawRepDef::Validity(ValidityDesc { num_values: validity.len(), validity: Some(validity.into_inner()), @@ -2833,6 +2837,26 @@ mod tests { assert_eq!([0, 0, 0, 3, 1, 1, 2, 1, 0, 0, 1], *def); } + #[test] + fn test_all_valid_validity_bitmap_serializes_as_no_null() { + let mut from_bitmap = RepDefBuilder::default(); + from_bitmap.add_validity_bitmap(validity(&[true, true, true, true])); + + let mut from_no_null = RepDefBuilder::default(); + from_no_null.add_no_null(4); + + let from_bitmap = RepDefBuilder::serialize(vec![from_bitmap]); + let from_no_null = RepDefBuilder::serialize(vec![from_no_null]); + + assert!(from_bitmap.repetition_levels.is_none()); + assert!(from_bitmap.definition_levels.is_none()); + assert_eq!(from_bitmap.def_meaning, from_no_null.def_meaning); + assert_eq!( + from_bitmap.max_visible_level, + from_no_null.max_visible_level + ); + } + #[test] fn test_slicer() { let mut builder = RepDefBuilder::default(); diff --git a/rust/lance/src/dataset/tests/dataset_merge_update.rs b/rust/lance/src/dataset/tests/dataset_merge_update.rs index c859db25389..d5fbd30ae93 100644 --- a/rust/lance/src/dataset/tests/dataset_merge_update.rs +++ b/rust/lance/src/dataset/tests/dataset_merge_update.rs @@ -2072,3 +2072,132 @@ async fn test_fts_index_incremental_reindex_after_in_place_update() { results.num_rows() ); } + +/// Regression test for https://github.com/lance-format/lance/issues/6338 +/// Sub-schema merge_insert with binary columns on v2.2 causes data corruption +/// when the binary values are >= 256 bytes. +#[tokio::test] +async fn test_sub_schema_merge_insert_binary_v2_2() { + use crate::dataset::write::merge_insert::WhenMatched; + use arrow_array::BinaryArray; + + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int64, false), + ArrowField::new("a", DataType::Binary, true), + ArrowField::new("b", DataType::Utf8, true), + ])); + + let test_uri = TempStrDir::default(); + + // Initial write: 2 rows with null binary values + let initial_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(arrow_array::Int64Array::from(vec![0, 1])), + Arc::new(BinaryArray::from(vec![None::<&[u8]>, None])), + Arc::new(StringArray::from(vec![None::<&str>, None])), + ], + ) + .unwrap(); + + let write_params = WriteParams { + data_storage_version: Some(LanceFileVersion::V2_2), + ..Default::default() + }; + let batches = RecordBatchIterator::new(vec![initial_batch].into_iter().map(Ok), schema.clone()); + Dataset::write(batches, &test_uri, Some(write_params)) + .await + .unwrap(); + + let sub_schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int64, false), + ArrowField::new("a", DataType::Binary, true), + ])); + + // Sub-schema merge_insert for row 0 (binary value >= 256 bytes) + let data_a: Vec = (0..256).map(|i| (i % 251) as u8).collect(); + { + let update_batch = RecordBatch::try_new( + sub_schema.clone(), + vec![ + Arc::new(arrow_array::Int64Array::from(vec![0])), + Arc::new(BinaryArray::from(vec![Some(data_a.as_slice())])), + ], + ) + .unwrap(); + let dataset = Dataset::open(&test_uri).await.unwrap(); + let source = Box::new(RecordBatchIterator::new( + vec![update_batch].into_iter().map(Ok), + sub_schema.clone(), + )); + MergeInsertBuilder::try_new(dataset.into(), vec!["id".into()]) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .try_build() + .unwrap() + .execute_reader(source) + .await + .unwrap(); + } + + // Read back and verify first merge worked + let dataset = Dataset::open(&test_uri).await.unwrap(); + let table = dataset + .scan() + .project(&["id", "a"]) + .unwrap() + .try_into_stream() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + let table = concat_batches(&table[0].schema(), &table).unwrap(); + assert_eq!(table.num_rows(), 2); + + // Sub-schema merge_insert for row 1 (binary value >= 256 bytes) + let data_b: Vec = (0..256).map(|i| ((i + 100) % 251) as u8).collect(); + { + let update_batch = RecordBatch::try_new( + sub_schema.clone(), + vec![ + Arc::new(arrow_array::Int64Array::from(vec![1])), + Arc::new(BinaryArray::from(vec![Some(data_b.as_slice())])), + ], + ) + .unwrap(); + let dataset = Dataset::open(&test_uri).await.unwrap(); + let source = Box::new(RecordBatchIterator::new( + vec![update_batch].into_iter().map(Ok), + sub_schema.clone(), + )); + MergeInsertBuilder::try_new(dataset.into(), vec!["id".into()]) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .try_build() + .unwrap() + .execute_reader(source) + .await + .unwrap(); + } + + // Read back and verify - this is where the bug manifests + let dataset = Dataset::open(&test_uri).await.unwrap(); + let table = dataset + .scan() + .project(&["id", "a"]) + .unwrap() + .try_into_stream() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + let table = concat_batches(&table[0].schema(), &table).unwrap(); + assert_eq!(table.num_rows(), 2); + + let a_col = table.column_by_name("a").unwrap(); + let binary_arr = a_col.as_any().downcast_ref::().unwrap(); + assert_eq!(binary_arr.value(0), data_a.as_slice()); + assert_eq!(binary_arr.value(1), data_b.as_slice()); +} From 54196a27e80b8763d1fb9610a2cd7aea8876f93f Mon Sep 17 00:00:00 2001 From: Beinan Date: Tue, 7 Apr 2026 00:51:51 -0700 Subject: [PATCH 148/206] feat(java): add allowExternalBlobOutsideBases to WriteParams (#6330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add support for writing blob v2 columns with external URI references that are outside registered base paths. This enables use cases like INSERT INTO SELECT across Lance tables where the target table stores external blob references pointing to the source table's blob files instead of copying the actual blob bytes. ## Changes - **WriteParams.java**: Add `allowExternalBlobOutsideBases` Optional field, getter, and builder method - **Fragment.java**: Pass the new field through `createWithFfiArray` and `createWithFfiStream` native methods - **fragment.rs (JNI)**: Thread the new `Optional` parameter through all fragment creation functions to `extract_write_params` - **utils.rs (JNI)**: Parse the new parameter and set `allow_external_blob_outside_bases` on Rust `WriteParams` - **blocking_dataset.rs (JNI)**: Pass `JObject::null()` for the new param in `Dataset.write()` path (not needed there) ## Context This is a prerequisite for lance-spark blob JOIN support (lance-format/lance-spark#355). When blob data flows through Spark's shuffle during JOIN + INSERT INTO, the target table needs to write external blob references pointing to the source table's physical blob files. The Rust `BlobPreprocessor` already supports this via `allow_external_blob_outside_bases`, but the Java SDK had no way to set it. Ref: #6321, #6322 ## Test plan - [x] Rust JNI code compiles cleanly (no errors in changed files) - [ ] Java unit tests (CI) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- java/lance-jni/src/blocking_dataset.rs | 1 + java/lance-jni/src/fragment.rs | 100 ++++++++++-------- java/lance-jni/src/utils.rs | 9 +- java/src/main/java/org/lance/Fragment.java | 12 ++- java/src/main/java/org/lance/WriteParams.java | 36 ++++++- 5 files changed, 105 insertions(+), 53 deletions(-) diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index ee8de347ad7..aa56d54de95 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -631,6 +631,7 @@ fn create_dataset<'local>( &storage_options_obj, &initial_bases, &target_bases, + &JObject::null(), // allow_external_blob_outside_bases not used for Dataset.write() )?; // Set up namespace commit handler and storage options provider if namespace is provided diff --git a/java/lance-jni/src/fragment.rs b/java/lance-jni/src/fragment.rs index 05b72c58a51..3b2a7ba6b22 100644 --- a/java/lance-jni/src/fragment.rs +++ b/java/lance-jni/src/fragment.rs @@ -89,15 +89,16 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiArray<'local>( dataset_uri: JString, arrow_array_addr: jlong, arrow_schema_addr: jlong, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - storage_options_obj: JObject, // Map - namespace_obj: JObject, // LanceNamespace (can be null) - table_id_obj: JObject, // List (can be null) + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + storage_options_obj: JObject, // Map + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) + allow_external_blob_outside_bases: JObject, // Optional ) -> JObject<'local> { ok_or_throw_with_return!( env, @@ -115,6 +116,7 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiArray<'local>( storage_options_obj, namespace_obj, table_id_obj, + allow_external_blob_outside_bases, ), JObject::default() ) @@ -126,15 +128,16 @@ fn inner_create_with_ffi_array<'local>( dataset_uri: JString, arrow_array_addr: jlong, arrow_schema_addr: jlong, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - storage_options_obj: JObject, // Map - namespace_obj: JObject, // LanceNamespace (can be null) - table_id_obj: JObject, // List (can be null) + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + storage_options_obj: JObject, // Map + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) + allow_external_blob_outside_bases: JObject, // Optional ) -> Result> { let c_array_ptr = arrow_array_addr as *mut FFI_ArrowArray; let c_schema_ptr = arrow_schema_addr as *mut FFI_ArrowSchema; @@ -161,6 +164,7 @@ fn inner_create_with_ffi_array<'local>( storage_options_obj, namespace_obj, table_id_obj, + allow_external_blob_outside_bases, reader, ) } @@ -171,15 +175,16 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiStream<'a>( _obj: JObject, dataset_uri: JString, arrow_array_stream_addr: jlong, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - storage_options_obj: JObject, // Map - namespace_obj: JObject, // LanceNamespace (can be null) - table_id_obj: JObject, // List (can be null) + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + storage_options_obj: JObject, // Map + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) + allow_external_blob_outside_bases: JObject, // Optional ) -> JObject<'a> { ok_or_throw_with_return!( env, @@ -196,6 +201,7 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiStream<'a>( storage_options_obj, namespace_obj, table_id_obj, + allow_external_blob_outside_bases, ), JObject::null() ) @@ -206,15 +212,16 @@ fn inner_create_with_ffi_stream<'local>( env: &mut JNIEnv<'local>, dataset_uri: JString, arrow_array_stream_addr: jlong, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - storage_options_obj: JObject, // Map - namespace_obj: JObject, // LanceNamespace (can be null) - table_id_obj: JObject, // List (can be null) + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + storage_options_obj: JObject, // Map + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) + allow_external_blob_outside_bases: JObject, // Optional ) -> Result> { let stream_ptr = arrow_array_stream_addr as *mut FFI_ArrowArrayStream; let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; @@ -231,6 +238,7 @@ fn inner_create_with_ffi_stream<'local>( storage_options_obj, namespace_obj, table_id_obj, + allow_external_blob_outside_bases, reader, ) } @@ -239,15 +247,16 @@ fn inner_create_with_ffi_stream<'local>( fn create_fragment<'a>( env: &mut JNIEnv<'a>, dataset_uri: JString, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - storage_options_obj: JObject, // Map - namespace_obj: JObject, // LanceNamespace (can be null) - table_id_obj: JObject, // List (can be null) + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + storage_options_obj: JObject, // Map + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) + allow_external_blob_outside_bases: JObject, // Optional source: impl StreamingWriteSource, ) -> Result> { let path_str = dataset_uri.extract(env)?; @@ -264,6 +273,7 @@ fn create_fragment<'a>( &storage_options_obj, &JObject::null(), // not used when creating fragments &JObject::null(), // not used when creating fragments + &allow_external_blob_outside_bases, )?; // Set up storage options provider if namespace is provided diff --git a/java/lance-jni/src/utils.rs b/java/lance-jni/src/utils.rs index 113769dd17f..3d7ef2ebdaf 100644 --- a/java/lance-jni/src/utils.rs +++ b/java/lance-jni/src/utils.rs @@ -49,8 +49,9 @@ pub fn extract_write_params( data_storage_version: &JObject, enable_v2_manifest_paths: Option<&JObject>, storage_options_obj: &JObject, - initial_bases: &JObject, // Optional - target_bases: &JObject, // Optional + initial_bases: &JObject, // Optional + target_bases: &JObject, // Optional + allow_external_blob_outside_bases: &JObject, // Optional ) -> Result { let mut write_params = WriteParams::default(); @@ -97,6 +98,10 @@ pub fn extract_write_params( write_params.target_base_names_or_paths = Some(names); } + if let Some(allow) = env.get_boolean_opt(allow_external_blob_outside_bases)? { + write_params.allow_external_blob_outside_bases = allow; + } + // Create storage options accessor from static storage_options let accessor = if storage_options.is_empty() { None diff --git a/java/src/main/java/org/lance/Fragment.java b/java/src/main/java/org/lance/Fragment.java index 367e03b58d4..89a61560561 100644 --- a/java/src/main/java/org/lance/Fragment.java +++ b/java/src/main/java/org/lance/Fragment.java @@ -279,7 +279,8 @@ static List create( params.getDataStorageVersion(), params.getStorageOptions(), namespaceClient, - tableId); + tableId, + params.getAllowExternalBlobOutsideBases()); } } @@ -304,7 +305,8 @@ static List create( params.getDataStorageVersion(), params.getStorageOptions(), namespaceClient, - tableId); + tableId, + params.getAllowExternalBlobOutsideBases()); } /** Create a fragment from the given arrow array and schema. */ @@ -320,7 +322,8 @@ private static native List createWithFfiArray( Optional dataStorageVersion, Map storageOptions, LanceNamespace namespaceClient, - List tableId); + List tableId, + Optional allowExternalBlobOutsideBases); /** Create a fragment from the given arrow stream. */ private static native List createWithFfiStream( @@ -334,5 +337,6 @@ private static native List createWithFfiStream( Optional dataStorageVersion, Map storageOptions, LanceNamespace namespaceClient, - List tableId); + List tableId, + Optional allowExternalBlobOutsideBases); } diff --git a/java/src/main/java/org/lance/WriteParams.java b/java/src/main/java/org/lance/WriteParams.java index 8a04a5d5b96..6f5d4545ca9 100644 --- a/java/src/main/java/org/lance/WriteParams.java +++ b/java/src/main/java/org/lance/WriteParams.java @@ -40,6 +40,7 @@ public enum WriteMode { private Map storageOptions = new HashMap<>(); private final Optional> initialBases; private final Optional> targetBases; + private final Optional allowExternalBlobOutsideBases; private WriteParams( Optional maxRowsPerFile, @@ -51,7 +52,8 @@ private WriteParams( Optional enableV2ManifestPaths, Map storageOptions, Optional> initialBases, - Optional> targetBases) { + Optional> targetBases, + Optional allowExternalBlobOutsideBases) { this.maxRowsPerFile = maxRowsPerFile; this.maxRowsPerGroup = maxRowsPerGroup; this.maxBytesPerFile = maxBytesPerFile; @@ -62,6 +64,7 @@ private WriteParams( this.storageOptions = storageOptions; this.initialBases = initialBases; this.targetBases = targetBases; + this.allowExternalBlobOutsideBases = allowExternalBlobOutsideBases; } public Optional getMaxRowsPerFile() { @@ -109,6 +112,18 @@ public Optional> getTargetBases() { return targetBases; } + /** + * Get whether external blob URIs outside registered bases are allowed. + * + *

    When true, blob v2 columns can reference external URIs that are not under any registered + * base path. The URI is stored as an absolute external reference with base_id=0. + * + * @return Optional containing the setting, or empty if not set + */ + public Optional getAllowExternalBlobOutsideBases() { + return allowExternalBlobOutsideBases; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -132,6 +147,7 @@ public static class Builder { private Map storageOptions = new HashMap<>(); private Optional> initialBases = Optional.empty(); private Optional> targetBases = Optional.empty(); + private Optional allowExternalBlobOutsideBases = Optional.empty(); public Builder withMaxRowsPerFile(int maxRowsPerFile) { this.maxRowsPerFile = Optional.of(maxRowsPerFile); @@ -183,6 +199,21 @@ public Builder withTargetBases(List targetBases) { return this; } + /** + * Allow external blob URIs outside registered bases. + * + *

    When true, blob v2 columns can reference external URIs (e.g. pointing to blob files in + * another Lance dataset) that are not under any registered base path. The URI is stored as an + * absolute external reference with base_id=0. + * + * @param allow true to allow external blob URIs outside bases + * @return this builder + */ + public Builder withAllowExternalBlobOutsideBases(boolean allow) { + this.allowExternalBlobOutsideBases = Optional.of(allow); + return this; + } + public WriteParams build() { return new WriteParams( maxRowsPerFile, @@ -194,7 +225,8 @@ public WriteParams build() { enableV2ManifestPaths, storageOptions, initialBases, - targetBases); + targetBases, + allowExternalBlobOutsideBases); } } } From 4e61245acdd3b411f51ea1fa651801cc8eb76bf6 Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Tue, 7 Apr 2026 10:13:13 -0500 Subject: [PATCH 149/206] fix: skip legacy validation for Overwrite operations (#6418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes #6417: Overwrite validation incorrectly used the old manifest's storage format, causing strict legacy checks to reject valid STABLE-format fragments that omit struct parent fields - Pass `None` instead of `Some(manifest)` for Overwrite validation since all fragments are replaced and the old format is irrelevant - Added regression test for LEGACY→STABLE overwrite with struct fields ## Test plan - [x] `test_overwrite_legacy_to_stable_with_struct_fields` — verifies that overwriting a LEGACY dataset with STABLE fragments containing struct fields succeeds - [ ] Re-enable `replaceTableChangesStorageVersion` test in lance-spark after this lands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance/src/dataset/transaction.rs | 72 ++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/rust/lance/src/dataset/transaction.rs b/rust/lance/src/dataset/transaction.rs index 574955b216b..b5f2e05ff0d 100644 --- a/rust/lance/src/dataset/transaction.rs +++ b/rust/lance/src/dataset/transaction.rs @@ -3361,7 +3361,12 @@ pub fn validate_operation(manifest: Option<&Manifest>, operation: &Operation) -> schema, config_upsert_values: None, initial_bases: _, - } => schema_fragments_valid(Some(manifest), schema, fragments), + } => { + // Pass None for manifest because Overwrite replaces all fragments. + // The old manifest's storage format is irrelevant for validating + // the new fragments (e.g., LEGACY→STABLE transitions). + schema_fragments_valid(None, schema, fragments) + } Operation::Update { updated_fragments, new_fragments, @@ -4457,4 +4462,69 @@ mod tests { assert!(result.is_ok()); assert!(indices.is_empty()); } + + /// Regression test for https://github.com/lance-format/lance/issues/6417 + /// + /// When overwriting a LEGACY dataset with STABLE-format fragments, the + /// validation should not use the old manifest's format. STABLE fragments + /// omit struct parent fields, which the strict legacy check rejects. + #[test] + fn test_overwrite_legacy_to_stable_with_struct_fields() { + use arrow_schema::Fields; + + // Schema: id (field 0), name (field 1), address (field 2, struct parent), + // city (field 3), country (field 4) + let arrow_schema = ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, false), + ArrowField::new("name", DataType::Utf8, false), + ArrowField::new( + "address", + DataType::Struct(Fields::from(vec![ + ArrowField::new("city", DataType::Utf8, false), + ArrowField::new("country", DataType::Utf8, false), + ])), + false, + ), + ]); + let schema = LanceSchema::try_from(&arrow_schema).unwrap(); + + // Old manifest is LEGACY format + let legacy_manifest = Manifest::new( + schema.clone(), + Arc::new(vec![Fragment::new(0)]), + DataStorageFormat::new(LanceFileVersion::Legacy), + HashMap::new(), + ); + + // New fragments in STABLE format omit struct parent field (id=2), + // only including leaf fields: id=0, name=1, city=3, country=4 + let stable_fragment = Fragment { + id: 0, + files: vec![DataFile::new( + "data.lance", + vec![0, 1, 3, 4], // no field 2 (struct parent) + vec![0, 1, 2, 3], + lance_file::format::MAJOR_VERSION as u32, + lance_file::format::MINOR_VERSION as u32, + None, + None, + )], + physical_rows: Some(10), + deletion_file: None, + row_id_meta: None, + last_updated_at_version_meta: None, + created_at_version_meta: None, + }; + + let operation = Operation::Overwrite { + fragments: vec![stable_fragment], + schema, + config_upsert_values: None, + initial_bases: None, + }; + + // This should succeed — the old manifest's LEGACY format should not + // cause strict validation of the new STABLE fragments. + validate_operation(Some(&legacy_manifest), &operation).unwrap(); + } } From 774d1bd94856a9a304f420122ef07e6de9264682 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Tue, 7 Apr 2026 15:12:36 -0700 Subject: [PATCH 150/206] feat: introduce `CacheCodec` for serializing index cache entries (#6223) Introduce a `codec: CacheCodec` parameter to `CacheBackend` methods, providing the ability to serialize and deserialize entries, if desired. This struct is just a pair of function pointers: `serialize` and `deserialize`. So it's very cheap if not used. To provide this for a type, implement the `CacheKey::codec()` method to return a `CacheCodec`. By default, this trait method returns `None`. We only implement serialization for some types right now, but the eventual goal is to implement it for all types. To keep this PR focused, I only implement this for vector indexes, but the same approach could be used for FTS and other indexes. To make it easy to implement, we provide a `CacheCodecImpl` trait: ```rust pub trait CacheCodecImpl: Send + Sync { fn serialize(&self, writer: &mut dyn std::io::Write) -> Result<()>; fn deserialize(reader: &mut dyn std::io::Read) -> Result where Self: Sized; } ``` The implementors of `CacheKey` can then write: ```rust fn codec() -> Option { Some(CacheCodec::from_impl::()) } ``` The implementation of serialization / deserialization is designed to have very low overhead. We use serde-derived strings for small metadata, and use Arrow IPC for Arrow data. When deserializing, we use zero-copy deserialization to re-use the buffers read in. One important change is we no longer cache `Arc` for V2+ vector indices. This is because `IVFIndex` (which is the V3 implementation of `VectorIndex`) contains non-serializable data structures like `FileReader`. Instead we cache `IvfIndexState`, which is a serializable representation of the vector index. Importantly, this does not require any IO to reconstruct, which is validated by the pre-existing test `test_prewarm_ivf_pq`. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + python/Cargo.lock | 1 + rust/lance-arrow/Cargo.toml | 1 + rust/lance-arrow/src/ipc.rs | 406 +++++++ rust/lance-arrow/src/lib.rs | 1 + rust/lance-core/src/cache/backend.rs | 32 +- rust/lance-core/src/cache/codec.rs | 124 ++ rust/lance-core/src/cache/mod.rs | 90 +- rust/lance-core/src/cache/moka.rs | 12 +- rust/lance-file/src/reader.rs | 12 +- rust/lance-index/src/vector/flat/index.rs | 2 +- rust/lance-index/src/vector/pq/storage.rs | 18 +- rust/lance-index/src/vector/quantizer.rs | 5 +- rust/lance-index/src/vector/storage.rs | 26 + rust/lance-index/src/vector/v3/subindex.rs | 1 + rust/lance-io/src/utils.rs | 4 + rust/lance-table/src/format.rs | 2 +- rust/lance-table/src/format/index.rs | 114 ++ rust/lance/src/index.rs | 213 ++-- rust/lance/src/index/vector.rs | 153 ++- rust/lance/src/index/vector/builder.rs | 6 + rust/lance/src/index/vector/ivf.rs | 32 +- .../src/index/vector/ivf/partition_serde.rs | 1067 +++++++++++++++++ rust/lance/src/index/vector/ivf/v2.rs | 958 +++++++++++++-- rust/lance/src/session/caches.rs | 6 - rust/lance/src/session/index_caches.rs | 4 + 26 files changed, 3000 insertions(+), 291 deletions(-) create mode 100644 rust/lance-arrow/src/ipc.rs create mode 100644 rust/lance-core/src/cache/codec.rs create mode 100644 rust/lance/src/index/vector/ivf/partition_serde.rs diff --git a/Cargo.lock b/Cargo.lock index c16b1158854..fb4260f4d0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4682,6 +4682,7 @@ dependencies = [ "arrow-buffer", "arrow-cast", "arrow-data", + "arrow-ipc", "arrow-ord", "arrow-schema", "arrow-select", diff --git a/python/Cargo.lock b/python/Cargo.lock index 805c61d69bc..7b60e0645e8 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -3938,6 +3938,7 @@ dependencies = [ "arrow-buffer", "arrow-cast", "arrow-data", + "arrow-ipc", "arrow-ord", "arrow-schema", "arrow-select", diff --git a/rust/lance-arrow/Cargo.toml b/rust/lance-arrow/Cargo.toml index a9f03cfcb28..5ec6904617f 100644 --- a/rust/lance-arrow/Cargo.toml +++ b/rust/lance-arrow/Cargo.toml @@ -18,6 +18,7 @@ arrow-array = { workspace = true } arrow-buffer = { workspace = true } arrow-data = { workspace = true } arrow-cast = { workspace = true } +arrow-ipc = { workspace = true } arrow-ord = { workspace = true } arrow-schema = { workspace = true } arrow-select = { workspace = true } diff --git a/rust/lance-arrow/src/ipc.rs b/rust/lance-arrow/src/ipc.rs new file mode 100644 index 00000000000..1c6364c4525 --- /dev/null +++ b/rust/lance-arrow/src/ipc.rs @@ -0,0 +1,406 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Zero-copy Arrow IPC stream read/write utilities. +//! +//! Provides helpers for serializing and deserializing [`RecordBatch`]es as +//! self-delimiting Arrow IPC streams using synchronous [`Read`]/[`Write`] I/O. +//! +//! These are designed for embedding IPC streams inside larger binary formats +//! (e.g. a cache entry that contains multiple IPC sections). Each stream is +//! self-delimiting (schema + batches + EOS marker) and can be read back +//! independently. +//! +//! # Zero-copy reads +//! +//! [`read_ipc_stream`] and [`read_ipc_stream_single`] take `&Bytes` and use +//! [`Bytes::slice`] to produce each message buffer. Because `Bytes::slice` +//! increments a reference count rather than copying, the resulting +//! [`Buffer`]s — and the array data decoded from them by [`FileDecoder`] — +//! are all backed by the same allocation as the input. + +use std::io::{Read, Write}; +use std::sync::Arc; + +use arrow_array::RecordBatch; +use arrow_buffer::Buffer; +use arrow_ipc::convert::fb_to_schema; +use arrow_ipc::reader::FileDecoder; +use arrow_ipc::root_as_message; +use arrow_ipc::writer::StreamWriter; +use arrow_schema::ArrowError; +use bytes::Bytes; + +// --------------------------------------------------------------------------- +// Length-prefixed byte utilities +// --------------------------------------------------------------------------- + +/// Write `data` prefixed by its length as a little-endian `u64`. +/// +/// Paired with [`read_len_prefixed_bytes`]. +pub fn write_len_prefixed_bytes(writer: &mut dyn Write, data: &[u8]) -> Result<(), ArrowError> { + writer + .write_all(&(data.len() as u64).to_le_bytes()) + .map_err(|e| ArrowError::IoError(e.to_string(), e))?; + writer + .write_all(data) + .map_err(|e| ArrowError::IoError(e.to_string(), e)) +} + +/// Read a byte slice written by [`write_len_prefixed_bytes`]. +/// +/// Reads an 8-byte little-endian length then exactly that many bytes. +pub fn read_len_prefixed_bytes(reader: &mut dyn Read) -> Result, ArrowError> { + let mut len_buf = [0u8; 8]; + reader + .read_exact(&mut len_buf) + .map_err(|e| ArrowError::IoError(e.to_string(), e))?; + let len = u64::from_le_bytes(len_buf) as usize; + let mut buf = vec![0u8; len]; + reader + .read_exact(&mut buf) + .map_err(|e| ArrowError::IoError(e.to_string(), e))?; + Ok(buf) +} + +// --------------------------------------------------------------------------- +// IPC stream utilities +// --------------------------------------------------------------------------- + +// 4-byte continuation marker used by modern Arrow IPC streams. +const IPC_CONTINUATION: [u8; 4] = [0xff; 4]; + +/// Write `batch` as a single-batch Arrow IPC stream to `writer`. +pub fn write_ipc_stream(batch: &RecordBatch, writer: &mut dyn Write) -> Result<(), ArrowError> { + let mut sw = StreamWriter::try_new(&mut *writer, batch.schema_ref())?; + sw.write(batch)?; + sw.finish() +} + +/// Write all batches from `iter` as a single Arrow IPC stream to `writer`. +/// +/// `iter` must yield at least one batch; the schema is inferred from the first +/// batch. Returns `ArrowError::InvalidArgumentError` if the iterator is empty. +/// If you need to write an empty stream (schema only, no rows), construct a +/// `StreamWriter` directly. +pub fn write_ipc_stream_batches(iter: I, writer: &mut dyn Write) -> Result<(), ArrowError> +where + I: IntoIterator, +{ + let mut iter = iter.into_iter(); + let first = iter + .next() + .ok_or_else(|| ArrowError::InvalidArgumentError("no batches to serialize".into()))?; + let mut sw = StreamWriter::try_new(&mut *writer, first.schema_ref())?; + sw.write(&first)?; + for batch in iter { + sw.write(&batch)?; + } + sw.finish() +} + +/// Read one complete Arrow IPC stream message from `data` as a zero-copy [`Buffer`]. +/// +/// Parses the first message starting at byte 0 of `data`. Returns `None` on +/// EOS (size field == 0) or empty input. The returned [`Buffer`] is backed by +/// `data`'s allocation — no bytes are copied. +/// +/// The caller should advance its position by `buf.len()` after each call. +fn read_one_ipc_message(data: &Bytes) -> Result, ArrowError> { + let bytes = data.as_ref(); + + if bytes.is_empty() { + return Ok(None); + } + if bytes.len() < 4 { + return Err(ArrowError::IoError( + "IPC: truncated header".into(), + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated IPC header"), + )); + } + + let has_continuation = bytes[..4] == IPC_CONTINUATION; + let (size_bytes, prefix_len): ([u8; 4], usize) = if has_continuation { + if bytes.len() < 8 { + return Err(ArrowError::IoError( + "IPC: truncated header after continuation".into(), + std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "truncated after continuation", + ), + )); + } + (bytes[4..8].try_into().unwrap(), 8) + } else { + (bytes[..4].try_into().unwrap(), 4) + }; + + let meta_size = u32::from_le_bytes(size_bytes) as usize; + if meta_size == 0 { + return Ok(None); // EOS + } + + let meta_end = prefix_len + meta_size; + if bytes.len() < meta_end { + return Err(ArrowError::IoError( + "IPC: truncated metadata".into(), + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated IPC metadata"), + )); + } + + let msg = root_as_message(&bytes[prefix_len..meta_end]) + .map_err(|e| ArrowError::ParseError(format!("IPC message parse error: {e}")))?; + let body_len = msg.bodyLength() as usize; + + let total = meta_end + body_len; + if bytes.len() < total { + return Err(ArrowError::IoError( + "IPC: truncated body".into(), + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated IPC body"), + )); + } + + // Zero-copy: Bytes::slice shares the backing allocation; Buffer::from + // wraps it without copying. + Ok(Some(Buffer::from(data.slice(0..total)))) +} + +/// Read a length-prefixed byte slice at `offset` in `data`, advancing `offset`. +/// +/// Reads an 8-byte little-endian length, then slices exactly that many bytes +/// from `data`. The returned [`Bytes`] is zero-copy (shares `data`'s allocation). +pub fn read_len_prefixed_bytes_at(data: &Bytes, offset: &mut usize) -> Result { + let bytes = data.as_ref(); + let len_end = offset + .checked_add(8) + .filter(|&e| e <= bytes.len()) + .ok_or_else(|| { + ArrowError::IoError( + "length-prefixed bytes: truncated length field".into(), + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated length"), + ) + })?; + let len = u64::from_le_bytes(bytes[*offset..len_end].try_into().unwrap()) as usize; + *offset = len_end; + let data_end = offset + .checked_add(len) + .filter(|&e| e <= bytes.len()) + .ok_or_else(|| { + ArrowError::IoError( + "length-prefixed bytes: truncated data".into(), + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated data"), + ) + })?; + let result = data.slice(*offset..data_end); + *offset = data_end; + Ok(result) +} + +/// Read all [`RecordBatch`]es from one Arrow IPC stream starting at `offset`, +/// advancing `offset` past the stream (including the EOS marker). +/// +/// Zero-copy: array buffers borrow from `data`'s allocation. +pub fn read_ipc_stream_at( + data: &Bytes, + offset: &mut usize, +) -> Result, ArrowError> { + let batches = read_ipc_stream(&data.slice(*offset..))?; + + // Recompute how many bytes were consumed by re-parsing message sizes. + // We can't get this from read_ipc_stream directly, so we re-walk the + // message headers (metadata only, no body re-read) to sum up lengths. + let slice = &data.as_ref()[*offset..]; + let mut consumed = 0usize; + loop { + let rem = &slice[consumed..]; + if rem.is_empty() { + break; + } + let has_cont = rem.len() >= 4 && rem[..4] == IPC_CONTINUATION; + let (size_bytes, prefix_len): ([u8; 4], usize) = if has_cont { + if rem.len() < 8 { + break; + } + (rem[4..8].try_into().unwrap(), 8) + } else { + if rem.len() < 4 { + break; + } + (rem[..4].try_into().unwrap(), 4) + }; + let meta_size = u32::from_le_bytes(size_bytes) as usize; + if meta_size == 0 { + // EOS — consume it and stop. + consumed += prefix_len; + break; + } + let meta_end = prefix_len + meta_size; + if rem.len() < meta_end { + break; + } + let msg = root_as_message(&rem[prefix_len..meta_end]) + .map_err(|e| ArrowError::ParseError(format!("IPC message parse error: {e}")))?; + let body_len = msg.bodyLength() as usize; + consumed += meta_end + body_len; + } + *offset += consumed; + + Ok(batches) +} + +/// Read exactly one [`RecordBatch`] from one Arrow IPC stream starting at `offset`, +/// advancing `offset` past the stream (including the EOS marker). +/// +/// Zero-copy: array buffers borrow from `data`'s allocation. +pub fn read_ipc_stream_single_at( + data: &Bytes, + offset: &mut usize, +) -> Result { + let mut batches = read_ipc_stream_at(data, offset)?; + match batches.len() { + 1 => Ok(batches.remove(0)), + n => Err(ArrowError::ParseError(format!( + "expected exactly 1 IPC record batch, got {n}" + ))), + } +} + +/// Extract the prefix length and metadata size from a raw IPC message buffer. +/// +/// Modern IPC streams have an 8-byte prefix `[continuation: 4][size: 4]`. +/// Legacy streams have a 4-byte prefix `[size: 4]`. Returns `(prefix_len, meta_size)`. +fn parse_ipc_message_prefix(buf: &Buffer) -> Result<(usize, usize), ArrowError> { + let has_continuation = buf.len() >= 4 && buf[..4] == [0xff; 4]; + if has_continuation { + if buf.len() < 8 { + return Err(ArrowError::ParseError( + "IPC message buffer too short".into(), + )); + } + let meta_size = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + Ok((8, meta_size)) + } else { + if buf.len() < 4 { + return Err(ArrowError::ParseError( + "IPC message buffer too short".into(), + )); + } + let meta_size = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize; + Ok((4, meta_size)) + } +} + +/// Read all [`RecordBatch`]es from one Arrow IPC stream. +/// +/// Zero-copy: each batch's array data buffers are borrowed from the input +/// message buffer(s) and not copied during decoding. +/// +/// Uses [`FileDecoder`] directly (rather than `StreamDecoder`) to avoid a +/// known edge case where `StreamDecoder` does not produce a batch for messages +/// with a zero-length body when the message exactly fills the decode buffer. +pub fn read_ipc_stream(data: &Bytes) -> Result, ArrowError> { + let mut offset = 0usize; + + let schema_buf = read_one_ipc_message(&data.slice(offset..))?.ok_or_else(|| { + ArrowError::ParseError("IPC stream: expected schema message, got EOS".into()) + })?; + offset += schema_buf.len(); + + let (prefix_len, meta_size) = parse_ipc_message_prefix(&schema_buf)?; + let schema_msg = root_as_message(&schema_buf[prefix_len..prefix_len + meta_size]) + .map_err(|e| ArrowError::ParseError(format!("IPC schema parse error: {e}")))?; + let schema = Arc::new(fb_to_schema(schema_msg.header_as_schema().ok_or_else( + || ArrowError::ParseError("IPC stream: first message is not a schema".into()), + )?)); + let mut decoder = FileDecoder::new(schema, schema_msg.version()); + + let mut batches = Vec::new(); + + loop { + let Some(buf) = read_one_ipc_message(&data.slice(offset..))? else { + break; + }; + offset += buf.len(); + + let (prefix_len, meta_size) = parse_ipc_message_prefix(&buf)?; + let msg = root_as_message(&buf[prefix_len..prefix_len + meta_size]) + .map_err(|e| ArrowError::ParseError(format!("IPC message parse error: {e}")))?; + let body_len = msg.bodyLength() as usize; + + // Block offset = 0 since the buffer starts at the message boundary. + // metaDataLength = prefix_len + meta_size (prefix + flatbuf + padding). + let block = arrow_ipc::Block::new(0, (prefix_len + meta_size) as i32, body_len as i64); + + match msg.header_type() { + arrow_ipc::MessageHeader::RecordBatch => { + if let Some(batch) = decoder.read_record_batch(&block, &buf)? { + batches.push(batch); + } + } + arrow_ipc::MessageHeader::DictionaryBatch => { + decoder.read_dictionary(&block, &buf)?; + } + _ => break, + } + } + + Ok(batches) +} + +/// Read exactly one [`RecordBatch`] from one Arrow IPC stream. +pub fn read_ipc_stream_single(data: &Bytes) -> Result { + let mut batches = read_ipc_stream(data)?; + match batches.len() { + 1 => Ok(batches.remove(0)), + n => Err(ArrowError::ParseError(format!( + "expected exactly 1 IPC record batch, got {n}" + ))), + } +} + +#[cfg(test)] +mod tests { + use arrow_array::{ArrayRef, record_batch}; + + use super::*; + + #[test] + fn test_ipc_roundtrip() { + let batch1 = record_batch!( + ("int", Int32, [1, 2, 3]), + ("str", Utf8, ["foo", "bar", "baz"]) + ) + .unwrap(); + let batch2 = record_batch!(("int", Int32, [4, 5]), ("str", Utf8, ["qux", "quux"])).unwrap(); + let batches = vec![batch1.clone(), batch2.clone()]; + + let mut buf = Vec::new(); + write_ipc_stream_batches(batches, &mut buf).unwrap(); + + let data = Bytes::from(buf); + + let batches = read_ipc_stream(&data).unwrap(); + assert_eq!(batches.len(), 2); + assert_eq!(batches[0], batch1); + assert_eq!(batches[1], batch2); + + let data_base = data.as_ptr() as usize; + let data_end = data_base + data.len(); + let assert_col_zero_copy = |array: &ArrayRef| { + for buffer in array.to_data().buffers() { + let ptr = buffer.as_ptr() as usize; + assert!( + ptr >= data_base && ptr < data_end, + "buffer at {ptr:#x} is not backed by the input Bytes allocation \ + [{data_base:#x}..{data_end:#x})" + ); + } + }; + + for batch in &batches { + assert_eq!(batch.schema(), batch1.schema()); + assert_col_zero_copy(batch.column(0)); + assert_col_zero_copy(batch.column(1)); + } + } +} diff --git a/rust/lance-arrow/src/lib.rs b/rust/lance-arrow/src/lib.rs index a3570b53de6..f936338e5ba 100644 --- a/rust/lance-arrow/src/lib.rs +++ b/rust/lance-arrow/src/lib.rs @@ -31,6 +31,7 @@ use crate::list::ListArrayExt; pub use floats::*; pub mod cast; +pub mod ipc; pub mod json; pub mod list; pub mod memory; diff --git a/rust/lance-core/src/cache/backend.rs b/rust/lance-core/src/cache/backend.rs index 54b24944ab0..237254c464f 100644 --- a/rust/lance-core/src/cache/backend.rs +++ b/rust/lance-core/src/cache/backend.rs @@ -17,20 +17,19 @@ use futures::Future; use crate::Result; +use super::CacheCodec; + /// A type-erased cache entry. pub type CacheEntry = Arc; /// Structured cache key passed to [`CacheBackend`] methods. /// -/// Composed of three parts: +/// CacheBackend impls receive these ready-made from [`LanceCache`](super::LanceCache) +/// — you do not construct them yourself. Composed of three parts: /// - **prefix**: scopes the key to a dataset or index (e.g. `"s3://bucket/dataset/"`) /// - **key**: identifies the specific entry (e.g. `"42"` for a version number) /// - **type_name**: distinguishes different value types stored under the same /// user key (e.g. `"Vec"`) -/// -/// [`LanceCache`](super::LanceCache) constructs these automatically from -/// [`CacheKey`](super::CacheKey) values; backend authors receive them -/// ready-made. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct InternalCacheKey { prefix: Arc, @@ -74,10 +73,26 @@ impl InternalCacheKey { #[async_trait] pub trait CacheBackend: Send + Sync + std::fmt::Debug { /// Look up an entry by its key. - async fn get(&self, key: &InternalCacheKey) -> Option; + /// + /// `codec` is provided so that persistent backends can deserialize the + /// entry from storage. In-memory backends can ignore it. When `codec` + /// is `None`, the entry type does not support serialization yet and + /// must be stored in-memory. + /// + /// The goal is for all cache entry types to eventually have codecs, + /// at which point the `Option` will be removed. + async fn get(&self, key: &InternalCacheKey, codec: Option) -> Option; /// Store an entry. `size_bytes` is used for eviction accounting. - async fn insert(&self, key: &InternalCacheKey, entry: CacheEntry, size_bytes: usize); + /// + /// See [`get`](Self::get) for codec semantics. + async fn insert( + &self, + key: &InternalCacheKey, + entry: CacheEntry, + size_bytes: usize, + codec: Option, + ); /// Get an existing entry or compute it from `loader`. /// @@ -86,10 +101,13 @@ pub trait CacheBackend: Send + Sync + std::fmt::Debug { /// /// Returns `(entry, was_cached)` where `was_cached` is `true` if the entry /// was already present in the cache (the loader was not invoked). + /// + /// See [`get`](Self::get) for codec semantics. async fn get_or_insert<'a>( &self, key: &InternalCacheKey, loader: Pin> + Send + 'a>>, + codec: Option, ) -> Result<(CacheEntry, bool)>; /// Remove all entries whose prefix starts with the given string. diff --git a/rust/lance-core/src/cache/codec.rs b/rust/lance-core/src/cache/codec.rs new file mode 100644 index 00000000000..34e5264bb28 --- /dev/null +++ b/rust/lance-core/src/cache/codec.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Serialization codecs for cache entries. +//! +//! Implement [`CacheCodecImpl`] on concrete types, then use +//! [`CacheCodec::from_impl`] to produce a type-erased codec for the cache. + +use std::sync::Arc; + +use bytes::Bytes; + +use crate::Result; + +// --------------------------------------------------------------------------- +// CacheCodecImpl — trait for serializable cache entry types +// --------------------------------------------------------------------------- + +/// Serialization trait for cache entries. +/// +/// **Experimental**: the serialized format is not stable and may change +/// between releases without notice. +/// +/// Implement this on concrete types that need to survive serialization +/// through a persistent cache backend. Then wire it into a [`CacheKey`](super::CacheKey) +/// via [`CacheCodec::from_impl`]: +/// +/// ```ignore +/// impl CacheCodecImpl for MyData { +/// fn serialize(&self, w: &mut dyn Write) -> Result<()> { /* ... */ } +/// fn deserialize(data: &Bytes) -> Result { /* ... */ } +/// } +/// +/// impl CacheKey for MyDataKey { +/// type ValueType = MyData; +/// fn codec() -> Option { +/// Some(CacheCodec::from_impl::()) +/// } +/// // ... +/// } +/// ``` +pub trait CacheCodecImpl: Send + Sync { + fn serialize(&self, writer: &mut dyn std::io::Write) -> Result<()>; + + fn deserialize(data: &Bytes) -> Result + where + Self: Sized; +} + +// --------------------------------------------------------------------------- +// CacheCodec — type-erased codec passed to backends +// --------------------------------------------------------------------------- + +pub(crate) type ArcAny = Arc; + +/// Type-erased codec for serializing and deserializing cache entries. +/// +/// `CacheCodec` is two plain function pointers — it is `Copy` and has no +/// heap allocation. Construct one via [`CacheCodec::from_impl`] for types +/// that implement [`CacheCodecImpl`], or [`CacheCodec::new`] for custom +/// cases (e.g. when the orphan rule prevents a direct impl). +#[derive(Copy, Clone)] +pub struct CacheCodec { + pub(crate) serialize: fn(&ArcAny, &mut dyn std::io::Write) -> Result<()>, + pub(crate) deserialize: fn(&Bytes) -> Result, +} + +impl std::fmt::Debug for CacheCodec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CacheCodec").finish_non_exhaustive() + } +} + +fn serialize_via_impl( + any: &ArcAny, + writer: &mut dyn std::io::Write, +) -> Result<()> { + let val = any + .downcast_ref::() + .expect("CacheCodec::serialize called with wrong type (this is a bug in the cache layer)"); + val.serialize(writer) +} + +fn deserialize_via_impl(data: &Bytes) -> Result { + let val = T::deserialize(data)?; + Ok(Arc::new(val) as ArcAny) +} + +impl CacheCodec { + /// Create a `CacheCodec` from plain function pointers. + /// + /// Prefer [`from_impl`](Self::from_impl) when the value type implements + /// [`CacheCodecImpl`]. Use this for types where a direct impl isn't + /// possible (e.g. orphan rule prevents it). + pub fn new( + serialize: fn(&ArcAny, &mut dyn std::io::Write) -> Result<()>, + deserialize: fn(&Bytes) -> Result, + ) -> Self { + Self { + serialize, + deserialize, + } + } + + /// Create a `CacheCodec` from a [`CacheCodecImpl`] implementation. + /// + /// For **sized** types stored directly in the cache. The codec + /// downcasts `&dyn Any` to `&T` for serialization and returns `Arc` + /// from deserialization. + pub fn from_impl() -> Self { + Self { + serialize: serialize_via_impl::, + deserialize: deserialize_via_impl::, + } + } + + pub fn serialize(&self, value: &ArcAny, writer: &mut dyn std::io::Write) -> Result<()> { + (self.serialize)(value, writer) + } + + pub fn deserialize(&self, data: &Bytes) -> Result { + (self.deserialize)(data) + } +} diff --git a/rust/lance-core/src/cache/mod.rs b/rust/lance-core/src/cache/mod.rs index 43bb233df72..ee6a728ef73 100644 --- a/rust/lance-core/src/cache/mod.rs +++ b/rust/lance-core/src/cache/mod.rs @@ -9,17 +9,48 @@ //! values. Define a [`CacheKey`] (or [`UnsizedCacheKey`] for trait objects) to //! describe what you're caching and its type. //! +//! To make a value type serializable (so persistent backends can store it), +//! implement [`CacheCodecImpl`] on the type, then override [`CacheKey::codec`]: +//! +//! ```ignore +//! impl CacheCodecImpl for MyData { +//! fn serialize(&self, w: &mut dyn Write) -> Result<()> { /* ... */ } +//! fn deserialize(data: &Bytes) -> Result { /* ... */ } +//! } +//! +//! impl CacheKey for MyDataKey { +//! type ValueType = MyData; +//! fn key(&self) -> Cow<'_, str> { /* ... */ } +//! fn type_name() -> &'static str { "MyData" } +//! fn codec() -> Option { +//! Some(CacheCodec::from_impl::()) +//! } +//! } +//! ``` +//! //! ## For backend implementors //! //! Implement [`CacheBackend`] to provide a custom storage layer (disk, Redis, //! etc.). Backends receive [`InternalCacheKey`] keys and type-erased //! [`CacheEntry`] values — the typed wrapping is handled by [`LanceCache`]. //! See the [`backend`] module for details. +//! +//! ## Serialization flow +//! +//! When a [`CacheKey`] provides a codec via [`CacheKey::codec`]: +//! +//! 1. [`LanceCache`] wraps the [`CacheCodec`] and passes it to the backend +//! alongside the entry on `insert` and `get` calls. +//! 2. In-memory backends (like [`MokaCacheBackend`]) ignore the codec. +//! 3. Persistent backends use `codec.serialize(entry, writer)` on insert and +//! `codec.deserialize(reader)` on get to persist entries across restarts. pub mod backend; +pub mod codec; mod moka; pub use backend::{CacheBackend, CacheEntry, InternalCacheKey}; +pub use codec::{CacheCodec, CacheCodecImpl}; pub use moka::MokaCacheBackend; use std::borrow::Cow; @@ -70,12 +101,27 @@ pub trait CacheKey { /// `std::any::type_name` — the latter is not guaranteed stable across /// compiler versions or build configurations. fn type_name() -> &'static str; + + /// Optional codec for serializing/deserializing this key's value type. + /// + /// Returns `None` by default. Cache backends that support persistence + /// (e.g. disk-backed caches) use this to serialize entries on insert and + /// deserialize on get. Types without a codec will only be stored in-memory. + /// + /// [`CacheCodec`] is `Copy` (two plain function pointers), so returning it + /// by value is cheap — no allocation needed. + fn codec() -> Option { + None + } } /// Like [`CacheKey`] but for unsized value types (e.g. `dyn Trait`). /// /// The cache wraps values in an extra `Arc` layer internally; callers pass /// and receive `Arc` where `T: ?Sized`. +/// +/// Unsized cache entries are always in-memory only (no serialization codec). +/// For serializable entries, use a sized [`CacheKey`] instead. pub trait UnsizedCacheKey { type ValueType: 'static + ?Sized; @@ -205,20 +251,22 @@ impl LanceCache { &self, key: &str, type_name: &'static str, + codec: Option, metadata: Arc, ) { let size = cache_entry_size(&*metadata); let cache_key = build_key(&self.prefix, key, type_name); - self.cache.insert(&cache_key, metadata, size).await; + self.cache.insert(&cache_key, metadata, size, codec).await; } async fn get_with_id( &self, key: &str, type_name: &'static str, + codec: Option, ) -> Option> { let cache_key = build_key(&self.prefix, key, type_name); - if let Some(entry) = self.cache.get(&cache_key).await { + if let Some(entry) = self.cache.get(&cache_key, codec).await { match entry.downcast::() { Ok(val) => { self.hits.fetch_add(1, Ordering::Relaxed); @@ -262,7 +310,7 @@ impl LanceCache { K: CacheKey, K::ValueType: DeepSizeOf + Send + Sync + 'static, { - self.insert_with_id(&cache_key.key(), K::type_name(), metadata) + self.insert_with_id(&cache_key.key(), K::type_name(), K::codec(), metadata) .boxed() .await } @@ -272,7 +320,7 @@ impl LanceCache { K: CacheKey, K::ValueType: DeepSizeOf + Send + Sync + 'static, { - self.get_with_id::(&cache_key.key(), K::type_name()) + self.get_with_id::(&cache_key.key(), K::type_name(), K::codec()) .boxed() .await } @@ -297,7 +345,10 @@ impl LanceCache { Ok((arc as CacheEntry, size)) }); - let (entry, was_cached) = self.cache.get_or_insert(&key, typed_loader).await?; + let (entry, was_cached) = self + .cache + .get_or_insert(&key, typed_loader, K::codec()) + .await?; if was_cached { self.hits.fetch_add(1, Ordering::Relaxed); @@ -313,7 +364,7 @@ impl LanceCache { K: UnsizedCacheKey, K::ValueType: DeepSizeOf + Send + Sync + 'static, { - self.insert_with_id(&cache_key.key(), K::type_name(), Arc::new(metadata)) + self.insert_with_id(&cache_key.key(), K::type_name(), None, Arc::new(metadata)) .boxed() .await } @@ -324,7 +375,7 @@ impl LanceCache { K::ValueType: DeepSizeOf + Send + Sync + 'static, { let outer = self - .get_with_id::>(&cache_key.key(), K::type_name()) + .get_with_id::>(&cache_key.key(), K::type_name(), None) .boxed() .await?; Some(outer.as_ref().clone()) @@ -376,7 +427,7 @@ impl WeakLanceCache { { let cache = self.inner.upgrade()?; let key = build_key(&self.prefix, &cache_key.key(), K::type_name()); - if let Some(entry) = cache.get(&key).await { + if let Some(entry) = cache.get(&key, K::codec()).await { self.hits.fetch_add(1, Ordering::Relaxed); Some(entry.downcast::().unwrap()) } else { @@ -393,7 +444,7 @@ impl WeakLanceCache { if let Some(cache) = self.inner.upgrade() { let size = cache_entry_size(&*value); let key = build_key(&self.prefix, &cache_key.key(), K::type_name()); - cache.insert(&key, value, size).await; + cache.insert(&key, value, size, K::codec()).await; true } else { log::warn!("WeakLanceCache: cache no longer available, unable to insert item"); @@ -423,7 +474,7 @@ impl WeakLanceCache { let size = cache_entry_size(&*arc); Ok((arc as CacheEntry, size)) }); - let (entry, was_cached) = cache.get_or_insert(&key, typed_loader).await?; + let (entry, was_cached) = cache.get_or_insert(&key, typed_loader, K::codec()).await?; if was_cached { self.hits.fetch_add(1, Ordering::Relaxed); } else { @@ -443,7 +494,7 @@ impl WeakLanceCache { { let cache = self.inner.upgrade()?; let key = build_key(&self.prefix, &cache_key.key(), K::type_name()); - if let Some(entry) = cache.get(&key).await { + if let Some(entry) = cache.get(&key, None).await { entry .downcast::>() .ok() @@ -462,7 +513,7 @@ impl WeakLanceCache { let wrapper = Arc::new(value); let size = cache_entry_size(&*wrapper); let key = build_key(&self.prefix, &cache_key.key(), K::type_name()); - cache.insert(&key, wrapper, size).await; + cache.insert(&key, wrapper, size, None).await; } else { log::warn!("WeakLanceCache: cache no longer available, unable to insert unsized item"); } @@ -712,10 +763,20 @@ mod tests { #[async_trait] impl CacheBackend for HashMapBackend { - async fn get(&self, key: &InternalCacheKey) -> Option { + async fn get( + &self, + key: &InternalCacheKey, + _codec: Option, + ) -> Option { self.map.lock().await.get(key).map(|(e, _)| e.clone()) } - async fn insert(&self, key: &InternalCacheKey, entry: CacheEntry, size_bytes: usize) { + async fn insert( + &self, + key: &InternalCacheKey, + entry: CacheEntry, + size_bytes: usize, + _codec: Option, + ) { self.map .lock() .await @@ -727,6 +788,7 @@ mod tests { loader: std::pin::Pin< Box> + Send + 'a>, >, + _codec: Option, ) -> Result<(CacheEntry, bool)> { if let Some((entry, _)) = self.map.lock().await.get(key) { Ok((entry.clone(), true)) diff --git a/rust/lance-core/src/cache/moka.rs b/rust/lance-core/src/cache/moka.rs index 05cb1e5909f..6be7760458a 100644 --- a/rust/lance-core/src/cache/moka.rs +++ b/rust/lance-core/src/cache/moka.rs @@ -10,6 +10,7 @@ use futures::Future; use crate::Result; +use super::CacheCodec; use super::backend::{CacheBackend, CacheEntry, InternalCacheKey}; /// Internal record stored in the moka cache. @@ -54,11 +55,17 @@ impl MokaCacheBackend { #[async_trait] impl CacheBackend for MokaCacheBackend { - async fn get(&self, key: &InternalCacheKey) -> Option { + async fn get(&self, key: &InternalCacheKey, _codec: Option) -> Option { self.cache.get(key).await.map(|r| r.entry) } - async fn insert(&self, key: &InternalCacheKey, entry: CacheEntry, size_bytes: usize) { + async fn insert( + &self, + key: &InternalCacheKey, + entry: CacheEntry, + size_bytes: usize, + _codec: Option, + ) { self.cache .insert(key.clone(), MokaCacheEntry { entry, size_bytes }) .await; @@ -68,6 +75,7 @@ impl CacheBackend for MokaCacheBackend { &self, key: &InternalCacheKey, loader: Pin> + Send + 'a>>, + _codec: Option, ) -> Result<(CacheEntry, bool)> { // Use moka's built-in dedup: optionally_get_with runs the init future // at most once per key, even under concurrent access. diff --git a/rust/lance-file/src/reader.rs b/rust/lance-file/src/reader.rs index 36a4dad06ff..15f1d1d466d 100644 --- a/rust/lance-file/src/reader.rs +++ b/rust/lance-file/src/reader.rs @@ -103,6 +103,15 @@ pub struct CachedFileMetadata { pub num_footer_bytes: u64, pub major_version: u16, pub minor_version: u16, + /// The actual total file size in bytes, as reported by the object store. + pub file_size_bytes: u64, +} + +impl CachedFileMetadata { + /// Total file size in bytes. + pub fn file_size(&self) -> u64 { + self.file_size_bytes + } } impl DeepSizeOf for CachedFileMetadata { @@ -344,7 +353,7 @@ impl Default for FileReaderOptions { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct FileReader { scheduler: Arc, // The default projection to be applied to all reads @@ -653,6 +662,7 @@ impl FileReader { file_buffers: gbo_table, major_version: footer.major_version, minor_version: footer.minor_version, + file_size_bytes: file_len, }) } diff --git a/rust/lance-index/src/vector/flat/index.rs b/rust/lance-index/src/vector/flat/index.rs index 35cf9382996..bb75ca97fd7 100644 --- a/rust/lance-index/src/vector/flat/index.rs +++ b/rust/lance-index/src/vector/flat/index.rs @@ -343,7 +343,7 @@ impl Quantization for FlatBinQuantizer { } fn quantization_type() -> QuantizationType { - QuantizationType::Flat + QuantizationType::FlatBin } fn quantize(&self, vectors: &dyn Array) -> Result { diff --git a/rust/lance-index/src/vector/pq/storage.rs b/rust/lance-index/src/vector/pq/storage.rs index 841936b8ed8..14233abc0c0 100644 --- a/rust/lance-index/src/vector/pq/storage.rs +++ b/rust/lance-index/src/vector/pq/storage.rs @@ -107,11 +107,19 @@ impl QuantizerMetadata for ProductQuantizationMetadata { } fn extra_metadata(&self) -> Result> { - debug_assert!(self.codebook.is_some()); - let codebook_tensor: pb::Tensor = pb::Tensor::try_from(self.codebook.as_ref().unwrap())?; - let mut bytes = BytesMut::new(); - codebook_tensor.encode(&mut bytes)?; - Ok(Some(bytes.freeze())) + if let Some(codebook) = &self.codebook { + let codebook_tensor: pb::Tensor = pb::Tensor::try_from(codebook)?; + let mut bytes = BytesMut::new(); + codebook_tensor.encode(&mut bytes)?; + Ok(Some(bytes.freeze())) + } else if !self.codebook_tensor.is_empty() { + // Legacy format: codebook is stored inline in the metadata JSON. + // Return it as-is; it's already a protobuf-encoded Tensor that + // parse_buffer() can handle. + Ok(Some(Bytes::from(self.codebook_tensor.clone()))) + } else { + Ok(None) + } } async fn load(reader: &PreviousFileReader) -> Result { diff --git a/rust/lance-index/src/vector/quantizer.rs b/rust/lance-index/src/vector/quantizer.rs index 65e83ecce13..8e2dd333606 100644 --- a/rust/lance-index/src/vector/quantizer.rs +++ b/rust/lance-index/src/vector/quantizer.rs @@ -65,6 +65,7 @@ pub trait Quantization: #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum QuantizationType { Flat, + FlatBin, Product, Scalar, Rabit, @@ -76,6 +77,7 @@ impl FromStr for QuantizationType { fn from_str(s: &str) -> std::result::Result { match s { "FLAT" => Ok(Self::Flat), + "FLATBIN" => Ok(Self::FlatBin), "PQ" => Ok(Self::Product), "SQ" => Ok(Self::Scalar), "RABIT" => Ok(Self::Rabit), @@ -88,6 +90,7 @@ impl std::fmt::Display for QuantizationType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Flat => write!(f, "FLAT"), + Self::FlatBin => write!(f, "FLATBIN"), Self::Product => write!(f, "PQ"), Self::Scalar => write!(f, "SQ"), Self::Rabit => write!(f, "RQ"), @@ -156,7 +159,7 @@ impl Quantizer { pub fn quantization_type(&self) -> QuantizationType { match self { Self::Flat(_) => QuantizationType::Flat, - Self::FlatBin(_) => QuantizationType::Flat, + Self::FlatBin(_) => QuantizationType::FlatBin, Self::Product(_) => QuantizationType::Product, Self::Scalar(_) => QuantizationType::Scalar, Self::Rabit(_) => QuantizationType::Rabit, diff --git a/rust/lance-index/src/vector/storage.rs b/rust/lance-index/src/vector/storage.rs index 5a1c0e7e6f5..5ad20f0ccd7 100644 --- a/rust/lance-index/src/vector/storage.rs +++ b/rust/lance-index/src/vector/storage.rs @@ -239,6 +239,32 @@ impl IvfQuantizationStorage { }) } + /// Construct from pre-parsed metadata, skipping global buffer reads. + /// Used when reconstructing from a disk cache. + pub fn from_cached( + reader: FileReader, + ivf: IvfModel, + metadata: Q::Metadata, + distance_type: DistanceType, + frag_reuse_index: Option>, + ) -> Self { + Self { + reader, + distance_type, + metadata, + ivf, + frag_reuse_index, + } + } + + pub fn reader(&self) -> &FileReader { + &self.reader + } + + pub fn ivf(&self) -> &IvfModel { + &self.ivf + } + pub fn num_rows(&self) -> u64 { self.reader.num_rows() } diff --git a/rust/lance-index/src/vector/v3/subindex.rs b/rust/lance-index/src/vector/v3/subindex.rs index af0bb337352..dd5d2b078a9 100644 --- a/rust/lance-index/src/vector/v3/subindex.rs +++ b/rust/lance-index/src/vector/v3/subindex.rs @@ -59,6 +59,7 @@ pub trait IvfSubIndex: Send + Sync + Debug + DeepSizeOf { fn to_batch(&self) -> Result; } +#[derive(Debug, Clone, Copy)] pub enum SubIndexType { Flat, Hnsw, diff --git a/rust/lance-io/src/utils.rs b/rust/lance-io/src/utils.rs index 83ea2177a0f..48d28526a5e 100644 --- a/rust/lance-io/src/utils.rs +++ b/rust/lance-io/src/utils.rs @@ -233,6 +233,10 @@ impl PartialEq for CachedFileSize { impl Eq for CachedFileSize {} impl CachedFileSize { + /// Create a `CachedFileSize` from a raw byte count. + /// + /// Passing `0` is equivalent to calling [`unknown`](Self::unknown): the + /// type interprets zero as "size not yet known". pub fn new(size: u64) -> Self { Self(AtomicU64::new(size)) } diff --git a/rust/lance-table/src/format.rs b/rust/lance-table/src/format.rs index 9eab63023b0..842c76f1e58 100644 --- a/rust/lance-table/src/format.rs +++ b/rust/lance-table/src/format.rs @@ -13,7 +13,7 @@ pub use crate::rowids::version::{ RowDatasetVersionMeta, RowDatasetVersionRun, RowDatasetVersionSequence, }; pub use fragment::*; -pub use index::{IndexFile, IndexMetadata, list_index_files_with_sizes}; +pub use index::{IndexFile, IndexMetadata, index_metadata_codec, list_index_files_with_sizes}; pub use manifest::{ BasePath, DETACHED_VERSION_MASK, DataStorageFormat, Manifest, SelfDescribingFileReader, diff --git a/rust/lance-table/src/format/index.rs b/rust/lance-table/src/format/index.rs index a8f4f3f0fd5..945d8364123 100644 --- a/rust/lance-table/src/format/index.rs +++ b/rust/lance-table/src/format/index.rs @@ -226,6 +226,45 @@ impl From<&IndexMetadata> for pb::IndexMetadata { } } +/// Returns a [`CacheCodec`](lance_core::cache::CacheCodec) for `Vec`. +/// +/// Uses `pb::IndexSection` (which wraps `repeated IndexMetadata`) as the wire +/// format, reusing the existing `TryFrom`/`From` conversions. +/// +/// Uses [`CacheCodec::new`](lance_core::cache::CacheCodec::new) because the +/// orphan rule prevents `impl CacheCodecImpl for Vec`. +type ArcAny = Arc; + +fn serialize_index_metadata( + any: &ArcAny, + writer: &mut dyn std::io::Write, +) -> lance_core::Result<()> { + use prost::Message; + let vec = any + .downcast_ref::>() + .expect("index_metadata_codec: wrong type (this is a bug in the cache layer)"); + let section = pb::IndexSection { + indices: vec.iter().map(pb::IndexMetadata::from).collect(), + }; + writer.write_all(§ion.encode_to_vec())?; + Ok(()) +} + +fn deserialize_index_metadata(data: &bytes::Bytes) -> lance_core::Result { + use prost::Message; + let section = pb::IndexSection::decode(data.as_ref())?; + let indices: Vec = section + .indices + .into_iter() + .map(IndexMetadata::try_from) + .collect::>()?; + Ok(Arc::new(indices)) +} + +pub fn index_metadata_codec() -> lance_core::cache::CacheCodec { + lance_core::cache::CacheCodec::new(serialize_index_metadata, deserialize_index_metadata) +} + /// List all files in an index directory with their sizes. /// /// Returns a list of `IndexFile` structs containing relative paths and sizes. @@ -252,3 +291,78 @@ pub async fn list_index_files_with_sizes( } Ok(files) } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// Demonstrates the pattern a disk-backed cache backend would use: + /// serialize entries to bytes, store in a key-value map, then + /// deserialize on retrieval. + #[test] + fn test_index_metadata_codec_roundtrip() { + let codec = index_metadata_codec(); + + let original = vec![ + IndexMetadata { + uuid: Uuid::new_v4(), + name: "my_index".to_string(), + fields: vec![0, 1], + dataset_version: 42, + fragment_bitmap: Some(RoaringBitmap::from_iter([1, 2, 3])), + index_details: None, + index_version: 1, + created_at: None, + base_id: None, + files: Some(vec![IndexFile { + path: "index.idx".to_string(), + size_bytes: 1024, + }]), + }, + IndexMetadata { + uuid: Uuid::new_v4(), + name: "second_index".to_string(), + fields: vec![2], + dataset_version: 43, + fragment_bitmap: None, + index_details: None, + index_version: 2, + created_at: None, + base_id: Some(7), + files: None, + }, + ]; + + // Simulate a disk-backed store: HashMap> + let mut store: HashMap> = HashMap::new(); + + // Serialize into the store + let key = "dataset/v42/Vec".to_string(); + let mut buf = Vec::new(); + let entry: Arc = Arc::new(original.clone()); + codec.serialize(&entry, &mut buf).unwrap(); + store.insert(key.clone(), buf); + + // Deserialize from the store + let bytes = store.get(&key).unwrap(); + let recovered = codec + .deserialize(&bytes::Bytes::copy_from_slice(bytes)) + .unwrap(); + let recovered = recovered + .downcast::>() + .expect("downcast should succeed"); + + assert_eq!(original.len(), recovered.len()); + for (orig, rec) in original.iter().zip(recovered.iter()) { + assert_eq!(orig.uuid, rec.uuid); + assert_eq!(orig.name, rec.name); + assert_eq!(orig.fields, rec.fields); + assert_eq!(orig.dataset_version, rec.dataset_version); + assert_eq!(orig.fragment_bitmap, rec.fragment_bitmap); + assert_eq!(orig.index_version, rec.index_version); + assert_eq!(orig.base_id, rec.base_id); + assert_eq!(orig.files, rec.files); + } + } +} diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index aa9d88b71c4..400a1be9244 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -41,7 +41,9 @@ use lance_index::vector::bq::builder::RabitQuantizer; use lance_index::vector::flat::index::{FlatBinQuantizer, FlatIndex, FlatQuantizer}; use lance_index::vector::hnsw::HNSW; use lance_index::vector::pq::ProductQuantizer; +use lance_index::vector::quantizer::Quantization; use lance_index::vector::sq::ScalarQuantizer; +use lance_index::vector::v3::subindex::IvfSubIndex; use lance_index::{INDEX_FILE_NAME, Index, IndexType, pb, vector::VectorIndex}; use lance_index::{ IndexCriteria, is_system_index, @@ -61,7 +63,7 @@ use scalar::index_matches_criteria; use serde_json::json; use tracing::{info, instrument}; use uuid::Uuid; -use vector::ivf::v2::IVFIndex; +use vector::ivf::v2::{IVFIndex, IvfStateEntryBox}; use vector::utils::get_vector_type; mod api; @@ -154,19 +156,19 @@ impl UnsizedCacheKey for ScalarIndexCacheKey<'_> { } #[derive(Debug, Clone)] -pub struct VectorIndexCacheKey<'a> { - pub uuid: &'a str, - pub fri_uuid: Option<&'a Uuid>, +pub(crate) struct LegacyVectorIndexCacheKey<'a> { + uuid: &'a str, + fri_uuid: Option<&'a Uuid>, } -impl<'a> VectorIndexCacheKey<'a> { - pub fn new(uuid: &'a str, fri_uuid: Option<&'a Uuid>) -> Self { +impl<'a> LegacyVectorIndexCacheKey<'a> { + fn new(uuid: &'a str, fri_uuid: Option<&'a Uuid>) -> Self { Self { uuid, fri_uuid } } } -impl UnsizedCacheKey for VectorIndexCacheKey<'_> { - type ValueType = dyn VectorIndex; +impl CacheKey for LegacyVectorIndexCacheKey<'_> { + type ValueType = CachedLegacyVectorIndex; fn key(&self) -> std::borrow::Cow<'_, str> { if let Some(fri_uuid) = self.fri_uuid { @@ -177,10 +179,52 @@ impl UnsizedCacheKey for VectorIndexCacheKey<'_> { } fn type_name() -> &'static str { - "VectorIndex" + "LegacyVectorIndex" } } +/// Sized cache key for `IvfIndexState`. +/// +/// Used for v0.3+ indices that support serialization. This key has a codec, +/// so custom cache backends can serialize the state to disk/Redis/etc. +/// Legacy indices use `LegacyVectorIndexCacheKey` instead (in-memory only). +#[derive(Debug, Clone)] +pub(crate) struct IvfIndexStateCacheKey<'a> { + uuid: &'a str, + fri_uuid: Option<&'a Uuid>, +} + +impl<'a> IvfIndexStateCacheKey<'a> { + fn new(uuid: &'a str, fri_uuid: Option<&'a Uuid>) -> Self { + Self { uuid, fri_uuid } + } +} + +impl CacheKey for IvfIndexStateCacheKey<'_> { + type ValueType = IvfStateEntryBox; + + fn type_name() -> &'static str { + "IvfIndexState" + } + + fn key(&self) -> std::borrow::Cow<'_, str> { + if let Some(fri_uuid) = self.fri_uuid { + format!("{}-{}", self.uuid, fri_uuid).into() + } else { + self.uuid.into() + } + } + + fn codec() -> Option { + Some(lance_core::cache::CacheCodec::from_impl::()) + } +} + +/// Wrapper that stores a live VectorIndex in the cache. +/// Used for v0.1/v0.2 indices that don't support serializable caching. +#[derive(Debug, deepsize::DeepSizeOf)] +pub(crate) struct CachedLegacyVectorIndex(Arc); + #[derive(Debug, Clone)] pub struct FragReuseIndexCacheKey<'a> { pub uuid: &'a str, @@ -1396,15 +1440,20 @@ impl DatasetIndexInternalExt for Dataset { return Ok(index.as_index()); } - let vector_cache_key = VectorIndexCacheKey::new(uuid, frag_reuse_uuid.as_ref()); - if let Some(index) = self - .index_cache - .get_unsized_with_key(&vector_cache_key) - .await - { + // Check sized cache for IvfIndexState (v2+ indices). + let state_key = IvfIndexStateCacheKey::new(uuid, frag_reuse_uuid.as_ref()); + if self.index_cache.get_with_key(&state_key).await.is_some() { + // Reconstruct via open_vector_index which will hit the same sized key. + let index = self.open_vector_index(column, uuid, metrics).await?; return Ok(index.as_index()); } + // Fallback: in-memory cache for legacy indices. + let vector_cache_key = LegacyVectorIndexCacheKey::new(uuid, frag_reuse_uuid.as_ref()); + if let Some(cached) = self.index_cache.get_with_key(&vector_cache_key).await { + return Ok(cached.0.clone().as_index()); + } + let frag_reuse_cache_key = FragReuseIndexCacheKey::new(uuid, frag_reuse_uuid.as_ref()); if let Some(index) = self.index_cache.get_with_key(&frag_reuse_cache_key).await { return Ok(index.as_index()); @@ -1477,11 +1526,26 @@ impl DatasetIndexInternalExt for Dataset { metrics: &dyn MetricsCollector, ) -> Result> { let frag_reuse_uuid = self.frag_reuse_index_uuid().await; - let cache_key = VectorIndexCacheKey::new(uuid, frag_reuse_uuid.as_ref()); - if let Some(index) = self.index_cache.get_unsized_with_key(&cache_key).await { - log::debug!("Found vector index in cache uuid: {}", uuid); - return Ok(index); + // Check sized cache first (v2+ indices with serializable state). + let state_key = IvfIndexStateCacheKey::new(uuid, frag_reuse_uuid.as_ref()); + if let Some(entry) = self.index_cache.get_with_key(&state_key).await { + log::debug!("Found IvfIndexState in cache uuid: {}", uuid); + let partition_cache = self.index_cache.with_key_prefix(&state_key.key()); + return entry + .0 + .reconstruct( + self.object_store.clone(), + self.metadata_cache.as_ref(), + partition_cache, + ) + .await; + } + + // Fallback: in-memory cache for legacy indices. + let cache_key = LegacyVectorIndexCacheKey::new(uuid, frag_reuse_uuid.as_ref()); + if let Some(cached) = self.index_cache.get_with_key(&cache_key).await { + return Ok(cached.0.clone()); } let frag_reuse_index = self.open_frag_reuse_index(metrics).await?; @@ -1499,23 +1563,35 @@ impl DatasetIndexInternalExt for Dataset { // Namespace the index cache by the UUID of the index. let index_cache = self.index_cache.with_key_prefix(&cache_key.key()); + // Extract the cacheable state before type-erasing to Arc. + fn wrap_ivf( + ivf: IVFIndex, + ) -> (Arc, Option) { + let entry = ivf.to_state_entry(); + (Arc::new(ivf), Some(entry)) + } + // the index file is in lance format since version (0,2) // TODO: we need to change the legacy IVF_PQ to be in lance format - let index = match (major_version, minor_version) { + let result: Result<(Arc, Option)> = match ( + major_version, + minor_version, + ) { (0, 1) | (0, 0) => { info!(target: TRACE_IO_EVENTS, index_uuid=uuid, r#type=IO_TYPE_OPEN_VECTOR, version="0.1", index_type="IVF_PQ"); let proto = open_index_proto(reader.as_ref()).await?; match &proto.implementation { Some(Implementation::VectorIndex(vector_index)) => { let dataset = Arc::new(self.clone()); - vector::open_vector_index( + let idx = vector::open_vector_index( dataset, uuid, vector_index, reader, frag_reuse_index, ) - .await + .await?; + Ok((idx, None::)) } None => Err(Error::internal( "Index proto was missing implementation field", @@ -1530,14 +1606,15 @@ impl DatasetIndexInternalExt for Dataset { Some(&self.metadata_cache.file_metadata_cache(&index_file)), ) .await?; - vector::open_vector_index_v2( + let idx = vector::open_vector_index_v2( Arc::new(self.clone()), column, uuid, reader, frag_reuse_index, ) - .await + .await?; + Ok((idx, None::)) } (0, 3) | (2, _) => { @@ -1587,7 +1664,7 @@ impl DatasetIndexInternalExt for Dataset { file_sizes, ) .await?; - Ok(Arc::new(ivf) as Arc) + Ok(wrap_ivf(ivf)) } DataType::UInt8 => { let ivf = IVFIndex::::try_new( @@ -1600,7 +1677,7 @@ impl DatasetIndexInternalExt for Dataset { file_sizes, ) .await?; - Ok(Arc::new(ivf) as Arc) + Ok(wrap_ivf(ivf)) } _ => Err(Error::index(format!( "the field type {} is not supported for FLAT index", @@ -1619,7 +1696,7 @@ impl DatasetIndexInternalExt for Dataset { file_sizes, ) .await?; - Ok(Arc::new(ivf) as Arc) + Ok(wrap_ivf(ivf)) } "IVF_SQ" => { @@ -1633,7 +1710,7 @@ impl DatasetIndexInternalExt for Dataset { file_sizes, ) .await?; - Ok(Arc::new(ivf) as Arc) + Ok(wrap_ivf(ivf)) } "IVF_RQ" => { @@ -1647,42 +1724,37 @@ impl DatasetIndexInternalExt for Dataset { file_sizes, ) .await?; - Ok(Arc::new(ivf) as Arc) + Ok(wrap_ivf(ivf)) } - "IVF_HNSW_FLAT" => { - let uri = index_dir.child(uuid).child("index.pb"); - let file_metadata_cache = - self.session.metadata_cache.file_metadata_cache(&uri); - match element_type { - DataType::UInt8 => { - let ivf = IVFIndex::::try_new( - self.object_store.clone(), - index_dir, - uuid.to_owned(), - frag_reuse_index, - &file_metadata_cache, - index_cache, - file_sizes, - ) - .await?; - Ok(Arc::new(ivf) as Arc) - } - _ => { - let ivf = IVFIndex::::try_new( - self.object_store.clone(), - index_dir, - uuid.to_owned(), - frag_reuse_index, - &file_metadata_cache, - index_cache, - file_sizes, - ) - .await?; - Ok(Arc::new(ivf) as Arc) - } + "IVF_HNSW_FLAT" => match element_type { + DataType::UInt8 => { + let ivf = IVFIndex::::try_new( + self.object_store.clone(), + index_dir, + uuid.to_owned(), + frag_reuse_index, + self.metadata_cache.as_ref(), + index_cache, + file_sizes, + ) + .await?; + Ok(wrap_ivf(ivf)) } - } + _ => { + let ivf = IVFIndex::::try_new( + self.object_store.clone(), + index_dir, + uuid.to_owned(), + frag_reuse_index, + self.metadata_cache.as_ref(), + index_cache, + file_sizes, + ) + .await?; + Ok(wrap_ivf(ivf)) + } + }, "IVF_HNSW_SQ" => { let ivf = IVFIndex::::try_new( @@ -1695,7 +1767,7 @@ impl DatasetIndexInternalExt for Dataset { file_sizes, ) .await?; - Ok(Arc::new(ivf) as Arc) + Ok(wrap_ivf(ivf)) } "IVF_HNSW_PQ" => { @@ -1709,7 +1781,7 @@ impl DatasetIndexInternalExt for Dataset { file_sizes, ) .await?; - Ok(Arc::new(ivf) as Arc) + Ok(wrap_ivf(ivf)) } _ => Err(Error::index(format!( @@ -1723,11 +1795,18 @@ impl DatasetIndexInternalExt for Dataset { "unsupported index version (maybe need to upgrade your lance version)".to_owned(), )), }; - let index = index?; + let (index, ivf_entry) = result?; metrics.record_index_load(); - self.index_cache - .insert_unsized_with_key(&cache_key, index.clone()) - .await; + if let Some(ivf_entry) = ivf_entry { + let state_key = IvfIndexStateCacheKey::new(uuid, frag_reuse_uuid.as_ref()); + self.index_cache + .insert_with_key(&state_key, Arc::new(ivf_entry)) + .await; + } else { + self.index_cache + .insert_with_key(&cache_key, Arc::new(CachedLegacyVectorIndex(index.clone()))) + .await; + } Ok(index) } diff --git a/rust/lance/src/index/vector.rs b/rust/lance/src/index/vector.rs index 887ae040848..e137237b9a0 100644 --- a/rust/lance/src/index/vector.rs +++ b/rust/lance/src/index/vector.rs @@ -1191,7 +1191,7 @@ pub(crate) async fn build_vector_index_incremental( ))); }; - let (vector_type, element_type) = get_vector_type(dataset.schema(), column)?; + let (vector_type, _) = get_vector_type(dataset.schema(), column)?; if let DataType::List(_) = vector_type && params.metric_type != DistanceType::Cosine { @@ -1234,48 +1234,40 @@ pub(crate) async fn build_vector_index_incremental( match (sub_index_type, quantization_type) { // IVF_FLAT - (SubIndexType::Flat, QuantizationType::Flat) => match element_type { - DataType::Float16 | DataType::Float32 | DataType::Float64 => { - IvfIndexBuilder::::new_incremental( - dataset.clone(), - column.to_owned(), - index_dir, - params.metric_type, - shuffler, - (), - frag_reuse_index, - OptimizeOptions::append(), - )? - .with_ivf(ivf_model) - .with_quantizer(quantizer.try_into()?) - .with_progress(progress.clone()) - .build() - .await?; - } - DataType::UInt8 => { - IvfIndexBuilder::::new_incremental( - dataset.clone(), - column.to_owned(), - index_dir, - params.metric_type, - shuffler, - (), - frag_reuse_index, - OptimizeOptions::append(), - )? - .with_ivf(ivf_model) - .with_quantizer(quantizer.try_into()?) - .with_progress(progress.clone()) - .build() - .await?; - } - _ => { - return Err(Error::index(format!( - "Build Vector Index: invalid data type: {:?}", - element_type - ))); - } - }, + (SubIndexType::Flat, QuantizationType::Flat) => { + IvfIndexBuilder::::new_incremental( + dataset.clone(), + column.to_owned(), + index_dir, + params.metric_type, + shuffler, + (), + frag_reuse_index, + OptimizeOptions::append(), + )? + .with_ivf(ivf_model) + .with_quantizer(quantizer.try_into()?) + .with_progress(progress.clone()) + .build() + .await?; + } + (SubIndexType::Flat, QuantizationType::FlatBin) => { + IvfIndexBuilder::::new_incremental( + dataset.clone(), + column.to_owned(), + index_dir, + params.metric_type, + shuffler, + (), + frag_reuse_index, + OptimizeOptions::append(), + )? + .with_ivf(ivf_model) + .with_quantizer(quantizer.try_into()?) + .with_progress(progress.clone()) + .build() + .await?; + } // IVF_PQ (SubIndexType::Flat, QuantizationType::Product) => { let mut builder = IvfIndexBuilder::::new_incremental( @@ -1344,42 +1336,40 @@ pub(crate) async fn build_vector_index_incremental( }; match quantization_type { - QuantizationType::Flat => match element_type { - DataType::UInt8 => { - IvfIndexBuilder::::new_incremental( - dataset.clone(), - column.to_owned(), - index_dir, - params.metric_type, - shuffler, - hnsw_params.clone(), - frag_reuse_index, - OptimizeOptions::append(), - )? - .with_ivf(ivf_model) - .with_quantizer(quantizer.try_into()?) - .with_progress(progress.clone()) - .build() - .await?; - } - _ => { - IvfIndexBuilder::::new_incremental( - dataset.clone(), - column.to_owned(), - index_dir, - params.metric_type, - shuffler, - hnsw_params.clone(), - frag_reuse_index, - OptimizeOptions::append(), - )? - .with_ivf(ivf_model) - .with_quantizer(quantizer.try_into()?) - .with_progress(progress.clone()) - .build() - .await?; - } - }, + QuantizationType::Flat => { + IvfIndexBuilder::::new_incremental( + dataset.clone(), + column.to_owned(), + index_dir, + params.metric_type, + shuffler, + hnsw_params.clone(), + frag_reuse_index, + OptimizeOptions::append(), + )? + .with_ivf(ivf_model) + .with_quantizer(quantizer.try_into()?) + .with_progress(progress.clone()) + .build() + .await?; + } + QuantizationType::FlatBin => { + IvfIndexBuilder::::new_incremental( + dataset.clone(), + column.to_owned(), + index_dir, + params.metric_type, + shuffler, + hnsw_params.clone(), + frag_reuse_index, + OptimizeOptions::append(), + )? + .with_ivf(ivf_model) + .with_quantizer(quantizer.try_into()?) + .with_progress(progress.clone()) + .build() + .await?; + } QuantizationType::Product => { IvfIndexBuilder::::new_incremental( dataset.clone(), @@ -1711,7 +1701,8 @@ pub async fn initialize_vector_index( let ivf_params = derive_ivf_params(ivf_model); let params = match (sub_index_type, quantization_type) { - (SubIndexType::Flat, QuantizationType::Flat) => { + (SubIndexType::Flat, QuantizationType::Flat) + | (SubIndexType::Flat, QuantizationType::FlatBin) => { VectorIndexParams::with_ivf_flat_params(metric_type, ivf_params) } (SubIndexType::Flat, QuantizationType::Product) => { @@ -1732,7 +1723,7 @@ pub async fn initialize_vector_index( (SubIndexType::Hnsw, quantization_type) => { let hnsw_params = derive_hnsw_params(source_vector_index.as_ref()); match quantization_type { - QuantizationType::Flat => { + QuantizationType::Flat | QuantizationType::FlatBin => { VectorIndexParams::ivf_hnsw(metric_type, ivf_params, hnsw_params) } QuantizationType::Product => { diff --git a/rust/lance/src/index/vector/builder.rs b/rust/lance/src/index/vector/builder.rs index 54fa9f6ac96..79a41b4571b 100644 --- a/rust/lance/src/index/vector/builder.rs +++ b/rust/lance/src/index/vector/builder.rs @@ -2073,6 +2073,12 @@ enum PartitionAdjustment { } pub(crate) fn index_type_string(sub_index: SubIndexType, quantizer: QuantizationType) -> String { + // FlatBin is a QuantizationType variant used internally for reconstruction, + // but the persisted index type string uses "FLAT" (differentiated by DataType). + let quantizer = match quantizer { + QuantizationType::FlatBin => QuantizationType::Flat, + other => other, + }; match (sub_index, quantizer) { // ignore FLAT sub index, // IVF_FLAT_FLAT => IVF_FLAT diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 1d388236add..9f226a42db5 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -108,6 +108,7 @@ use uuid::Uuid; pub mod builder; pub mod io; +mod partition_serde; pub mod v2; // Cache wrapper for vector index trait objects @@ -1659,6 +1660,13 @@ pub(crate) async fn remap_index_file_v3( .remap(mapping) .await } + (SubIndexType::Flat, QuantizationType::FlatBin) => { + IvfIndexBuilder::::new_remapper( + dataset, column, index_dir, index, + )? + .remap(mapping) + .await + } (SubIndexType::Flat, QuantizationType::Rabit) => { IvfIndexBuilder::::new_remapper( dataset, column, index_dir, index, @@ -1666,22 +1674,18 @@ pub(crate) async fn remap_index_file_v3( .remap(mapping) .await } - (SubIndexType::Hnsw, QuantizationType::Flat) => match element_type { - DataType::UInt8 => { - IvfIndexBuilder::::new_remapper( - dataset, column, index_dir, index, - )? - .remap(mapping) - .await - } - _ => { - IvfIndexBuilder::::new_remapper( - dataset, column, index_dir, index, - )? + (SubIndexType::Hnsw, QuantizationType::Flat) => { + IvfIndexBuilder::::new_remapper(dataset, column, index_dir, index)? .remap(mapping) .await - } - }, + } + (SubIndexType::Hnsw, QuantizationType::FlatBin) => { + IvfIndexBuilder::::new_remapper( + dataset, column, index_dir, index, + )? + .remap(mapping) + .await + } (SubIndexType::Hnsw, QuantizationType::Product) => { IvfIndexBuilder::::new_remapper( dataset, column, index_dir, index, diff --git a/rust/lance/src/index/vector/ivf/partition_serde.rs b/rust/lance/src/index/vector/ivf/partition_serde.rs new file mode 100644 index 00000000000..97f53708a21 --- /dev/null +++ b/rust/lance/src/index/vector/ivf/partition_serde.rs @@ -0,0 +1,1067 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Serialization and zero-copy deserialization for IVF partition cache entries. +//! +//! The format is: +//! +//! ```text +//! [header_len: u64 LE] +//! [header: JSON bytes] +//! [sub_index Arrow IPC stream] +//! [... quantizer-specific IPC streams ...] +//! [storage Arrow IPC stream] +//! ``` +//! +//! Each IPC section is a self-delimiting Arrow IPC stream (schema + batches + EOS +//! marker), written directly to the underlying writer without buffering. On +//! deserialization, each message is read into a per-message buffer and zero-copy +//! decoded via [`lance_arrow::ipc`]. + +use std::io::Write; +use std::sync::Arc; + +use arrow_array::{FixedSizeListArray, RecordBatch}; +use arrow_schema::{DataType, Field, Schema}; +use bytes::Bytes; +use lance_arrow::ipc::{ + read_ipc_stream_at, read_ipc_stream_single_at, read_len_prefixed_bytes_at, write_ipc_stream, + write_ipc_stream_batches, write_len_prefixed_bytes, +}; +use lance_core::cache::CacheCodecImpl; +use lance_core::{Error, Result}; +use lance_index::vector::bq::RQRotationType; +use lance_index::vector::bq::builder::RabitQuantizer; +use lance_index::vector::bq::storage::RabitQuantizationMetadata; +use lance_index::vector::flat::index::{FlatBinQuantizer, FlatMetadata, FlatQuantizer}; +use lance_index::vector::pq::ProductQuantizer; +use lance_index::vector::pq::storage::ProductQuantizationMetadata; +use lance_index::vector::quantizer::{Quantization, QuantizerStorage}; +use lance_index::vector::sq::ScalarQuantizer; +use lance_index::vector::sq::storage::ScalarQuantizationMetadata; +use lance_index::vector::storage::VectorStore; +use lance_index::vector::v3::subindex::IvfSubIndex; +use lance_linalg::distance::DistanceType; +use serde::{Deserialize, Serialize}; + +use super::v2::PartitionEntry; + +/// Returns an erased codec for `PartitionEntry` by matching on the +/// quantizer's [`QuantizationType`]. Returns `None` for quantizer types +/// that don't have a `CacheCodecImpl` implementation. +/// +/// Uses enum dispatch rather than trait bounds to avoid propagating +/// `CacheCodecImpl` constraints through the `IVFIndex` type hierarchy. +pub fn partition_entry_codec() +-> Option { + use lance_index::vector::quantizer::QuantizationType; + match Q::quantization_type() { + QuantizationType::Product => Some(codec_for::()), + QuantizationType::Flat => Some(codec_for::()), + QuantizationType::FlatBin => Some(codec_for::()), + QuantizationType::Scalar => Some(codec_for::()), + QuantizationType::Rabit => Some(codec_for::()), + } +} + +type ArcAny = Arc; + +fn serialize_partition_entry( + any: &ArcAny, + writer: &mut dyn Write, +) -> lance_core::Result<()> +where + S: IvfSubIndex + 'static, + Concrete: Quantization + 'static, + PartitionEntry: CacheCodecImpl, +{ + let concrete = any + .downcast_ref::>() + .expect("quantization_type matched but downcast failed (this is a bug)"); + concrete.serialize(writer) +} + +fn deserialize_partition_entry(data: &Bytes) -> lance_core::Result +where + S: IvfSubIndex + 'static, + Q: Quantization + 'static, + Concrete: Quantization + 'static, + PartitionEntry: CacheCodecImpl, +{ + let concrete = PartitionEntry::::deserialize(data)?; + let any: ArcAny = Arc::new(concrete); + Ok(any + .downcast::>() + .expect("quantization_type matched but downcast failed (this is a bug)")) +} + +/// Build a CacheCodec for `PartitionEntry` by delegating to the +/// CacheCodecImpl for `PartitionEntry`. +/// +/// Q and Concrete must be the same type (enforced by the QuantizationType +/// match in the caller). Uses Any-based downcasting to bridge the types. +fn codec_for< + S: IvfSubIndex + 'static, + Q: Quantization + 'static, + Concrete: Quantization + 'static, +>() -> lance_core::cache::CacheCodec +where + PartitionEntry: CacheCodecImpl, +{ + lance_core::cache::CacheCodec::new( + serialize_partition_entry::, + deserialize_partition_entry::, + ) +} + +// --------------------------------------------------------------------------- +// Common helpers +// --------------------------------------------------------------------------- + +fn distance_type_to_u8(dt: DistanceType) -> u8 { + match dt { + DistanceType::L2 => 0, + DistanceType::Cosine => 1, + DistanceType::Dot => 2, + DistanceType::Hamming => 3, + } +} + +fn u8_to_distance_type(v: u8) -> Result { + match v { + 0 => Ok(DistanceType::L2), + 1 => Ok(DistanceType::Cosine), + 2 => Ok(DistanceType::Dot), + 3 => Ok(DistanceType::Hamming), + _ => Err(Error::io(format!("unknown distance type: {v}"))), + } +} + +fn rotation_type_to_u8(rt: RQRotationType) -> u8 { + match rt { + RQRotationType::Matrix => 0, + RQRotationType::Fast => 1, + } +} + +fn u8_to_rotation_type(v: u8) -> Result { + match v { + 0 => Ok(RQRotationType::Matrix), + 1 => Ok(RQRotationType::Fast), + _ => Err(Error::io(format!("unknown rotation type: {v}"))), + } +} + +/// Write a JSON-serializable header using [`write_len_prefixed_bytes`]. +fn write_json_header(writer: &mut dyn Write, header: &impl Serialize) -> Result<()> { + let header_json = serde_json::to_vec(header)?; + write_len_prefixed_bytes(writer, &header_json)?; + Ok(()) +} + +/// Read a JSON header written by [`write_json_header`]. +fn read_json_header(data: &Bytes, offset: &mut usize) -> Result { + let bytes = read_len_prefixed_bytes_at(data, offset).map_err(|e| Error::io(e.to_string()))?; + serde_json::from_slice(&bytes).map_err(|e| Error::io(e.to_string())) +} + +/// Wrap a `FixedSizeListArray` in a single-column `RecordBatch` with the given +/// column name. +fn fsl_to_batch(arr: &FixedSizeListArray, name: &str) -> Result { + let field = Field::new( + name, + DataType::FixedSizeList( + Arc::new(Field::new("item", arr.value_type(), true)), + arr.value_length(), + ), + false, + ); + let schema = Arc::new(Schema::new(vec![field])); + Ok(RecordBatch::try_new(schema, vec![Arc::new(arr.clone())])?) +} + +/// Extract a `FixedSizeListArray` from the first column of a `RecordBatch`. +fn batch_to_fsl(batch: &RecordBatch) -> Result { + batch + .column(0) + .as_any() + .downcast_ref::() + .cloned() + .ok_or_else(|| Error::io("column is not FixedSizeListArray".to_string())) +} + +fn codebook_to_batch(codebook: &FixedSizeListArray) -> Result { + fsl_to_batch(codebook, "codebook") +} + +fn batch_to_codebook(batch: &RecordBatch) -> Result { + batch_to_fsl(batch) +} + +// --------------------------------------------------------------------------- +// PQ +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize)] +struct PqPartitionHeader { + distance_type: u8, + nbits: u32, + num_sub_vectors: usize, + dimension: usize, + transposed: bool, +} + +impl CacheCodecImpl for PartitionEntry { + fn serialize(&self, writer: &mut dyn Write) -> Result<()> { + let metadata = self.storage.metadata(); + let distance_type = self.storage.distance_type(); + + let codebook = metadata.codebook.as_ref().ok_or_else(|| { + Error::io("PQ metadata missing codebook during serialization".to_string()) + })?; + + let header = PqPartitionHeader { + distance_type: distance_type_to_u8(distance_type), + nbits: metadata.nbits, + num_sub_vectors: metadata.num_sub_vectors, + dimension: metadata.dimension, + transposed: metadata.transposed, + }; + + write_json_header(writer, &header)?; + write_ipc_stream(&self.index.to_batch()?, writer)?; + write_ipc_stream(&codebook_to_batch(codebook)?, writer)?; + write_ipc_stream_batches(self.storage.to_batches()?, writer)?; + + Ok(()) + } + + fn deserialize(data: &Bytes) -> Result { + let mut offset = 0; + let header: PqPartitionHeader = read_json_header(data, &mut offset)?; + let distance_type = u8_to_distance_type(header.distance_type)?; + + let sub_index_batch = + read_ipc_stream_single_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + let codebook_batch = + read_ipc_stream_single_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + let storage_batch = + read_ipc_stream_single_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + + let index = S::load(sub_index_batch)?; + let codebook = batch_to_codebook(&codebook_batch)?; + + let metadata = ProductQuantizationMetadata { + codebook_position: 0, + nbits: header.nbits, + num_sub_vectors: header.num_sub_vectors, + dimension: header.dimension, + codebook: Some(codebook), + codebook_tensor: Vec::new(), + transposed: header.transposed, + }; + + let storage = ::Storage::try_from_batch( + storage_batch, + &metadata, + distance_type, + None, + )?; + + Ok(Self { index, storage }) + } +} + +// --------------------------------------------------------------------------- +// Flat (Float32) +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize)] +struct FlatPartitionHeader { + distance_type: u8, + dim: usize, +} + +impl CacheCodecImpl for PartitionEntry { + fn serialize(&self, writer: &mut dyn Write) -> Result<()> { + let metadata = self.storage.metadata(); + let distance_type = self.storage.distance_type(); + + let header = FlatPartitionHeader { + distance_type: distance_type_to_u8(distance_type), + dim: metadata.dim, + }; + + write_json_header(writer, &header)?; + write_ipc_stream(&self.index.to_batch()?, writer)?; + write_ipc_stream_batches(self.storage.to_batches()?, writer)?; + + Ok(()) + } + + fn deserialize(data: &Bytes) -> Result { + let mut offset = 0; + let header: FlatPartitionHeader = read_json_header(data, &mut offset)?; + let distance_type = u8_to_distance_type(header.distance_type)?; + + let sub_index_batch = + read_ipc_stream_single_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + let storage_batch = + read_ipc_stream_single_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + + let index = S::load(sub_index_batch)?; + let metadata = FlatMetadata { dim: header.dim }; + let storage = ::Storage::try_from_batch( + storage_batch, + &metadata, + distance_type, + None, + )?; + + Ok(Self { index, storage }) + } +} + +// --------------------------------------------------------------------------- +// Flat (Binary / Hamming) +// --------------------------------------------------------------------------- + +impl CacheCodecImpl for PartitionEntry { + fn serialize(&self, writer: &mut dyn Write) -> Result<()> { + let metadata = self.storage.metadata(); + let distance_type = self.storage.distance_type(); + + let header = FlatPartitionHeader { + distance_type: distance_type_to_u8(distance_type), + dim: metadata.dim, + }; + + write_json_header(writer, &header)?; + write_ipc_stream(&self.index.to_batch()?, writer)?; + write_ipc_stream_batches(self.storage.to_batches()?, writer)?; + + Ok(()) + } + + fn deserialize(data: &Bytes) -> Result { + let mut offset = 0; + let header: FlatPartitionHeader = read_json_header(data, &mut offset)?; + let distance_type = u8_to_distance_type(header.distance_type)?; + + let sub_index_batch = + read_ipc_stream_single_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + let storage_batch = + read_ipc_stream_single_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + + let index = S::load(sub_index_batch)?; + let metadata = FlatMetadata { dim: header.dim }; + let storage = ::Storage::try_from_batch( + storage_batch, + &metadata, + distance_type, + None, + )?; + + Ok(Self { index, storage }) + } +} + +// --------------------------------------------------------------------------- +// SQ +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize)] +struct SqPartitionHeader { + distance_type: u8, + num_bits: u16, + dim: usize, + bounds_start: f64, + bounds_end: f64, +} + +impl CacheCodecImpl for PartitionEntry { + fn serialize(&self, writer: &mut dyn Write) -> Result<()> { + let metadata = self.storage.metadata(); + let distance_type = self.storage.distance_type(); + + let header = SqPartitionHeader { + distance_type: distance_type_to_u8(distance_type), + num_bits: metadata.num_bits, + dim: metadata.dim, + bounds_start: metadata.bounds.start, + bounds_end: metadata.bounds.end, + }; + + write_json_header(writer, &header)?; + write_ipc_stream(&self.index.to_batch()?, writer)?; + // SQ storage may contain multiple batches; stream them all in one IPC stream. + write_ipc_stream_batches(self.storage.to_batches()?, writer)?; + + Ok(()) + } + + fn deserialize(data: &Bytes) -> Result { + let mut offset = 0; + let header: SqPartitionHeader = read_json_header(data, &mut offset)?; + let distance_type = u8_to_distance_type(header.distance_type)?; + + let sub_index_batch = + read_ipc_stream_single_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + let storage_batches = + read_ipc_stream_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + + let index = S::load(sub_index_batch)?; + let metadata = ScalarQuantizationMetadata { + dim: header.dim, + num_bits: header.num_bits, + bounds: header.bounds_start..header.bounds_end, + }; + let storage = ::Storage::try_new( + metadata.num_bits, + distance_type, + metadata.bounds, + storage_batches, + None, + )?; + + Ok(Self { index, storage }) + } +} + +// --------------------------------------------------------------------------- +// RabitQ +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize)] +struct RabitPartitionHeader { + distance_type: u8, + num_bits: u8, + code_dim: u32, + /// 0 = Matrix, 1 = Fast + rotation_type: u8, + /// Fast rotation signs (only set when rotation_type == Fast). + fast_rotation_signs: Option>, +} + +impl CacheCodecImpl for PartitionEntry { + fn serialize(&self, writer: &mut dyn Write) -> Result<()> { + let metadata = self.storage.metadata(); + let distance_type = self.storage.distance_type(); + + let header = RabitPartitionHeader { + distance_type: distance_type_to_u8(distance_type), + num_bits: metadata.num_bits, + code_dim: metadata.code_dim, + rotation_type: rotation_type_to_u8(metadata.rotation_type), + fast_rotation_signs: metadata.fast_rotation_signs.clone(), + }; + + write_json_header(writer, &header)?; + + write_ipc_stream(&self.index.to_batch()?, writer)?; + + // Write the rotation matrix IPC stream only for Matrix rotation; the + // Fast rotation case stores its signs compactly in the JSON header. + if metadata.rotation_type == RQRotationType::Matrix { + let mat = metadata.rotate_mat.as_ref().ok_or_else(|| { + Error::io( + "RabitQ Matrix metadata missing rotate_mat during serialization".to_string(), + ) + })?; + write_ipc_stream(&fsl_to_batch(mat, "rotate_mat")?, writer)?; + } + + write_ipc_stream_batches(self.storage.to_batches()?, writer)?; + + Ok(()) + } + + fn deserialize(data: &Bytes) -> Result { + let mut offset = 0; + let header: RabitPartitionHeader = read_json_header(data, &mut offset)?; + let distance_type = u8_to_distance_type(header.distance_type)?; + let rotation_type = u8_to_rotation_type(header.rotation_type)?; + + let sub_index_batch = + read_ipc_stream_single_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + + let rotate_mat = if rotation_type == RQRotationType::Matrix { + let mat_batch = read_ipc_stream_single_at(data, &mut offset) + .map_err(|e| Error::io(e.to_string()))?; + Some(batch_to_fsl(&mat_batch)?) + } else { + None + }; + + let storage_batch = + read_ipc_stream_single_at(data, &mut offset).map_err(|e| Error::io(e.to_string()))?; + + let index = S::load(sub_index_batch)?; + let metadata = RabitQuantizationMetadata { + rotate_mat, + rotate_mat_position: None, + fast_rotation_signs: header.fast_rotation_signs, + rotation_type, + code_dim: header.code_dim, + num_bits: header.num_bits, + // The storage batch already has packed codes; skip re-packing. + packed: true, + }; + let storage = ::Storage::try_from_batch( + storage_batch, + &metadata, + distance_type, + None, + )?; + + Ok(Self { index, storage }) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::Arc; + + use arrow_array::cast::AsArray; + use arrow_array::{ + Float32Array, UInt8Array, UInt64Array, + types::{Float32Type, UInt8Type}, + }; + use arrow_schema::{DataType, Field, Schema}; + use lance_arrow::FixedSizeListArrayExt; + use lance_index::vector::bq::storage::RABIT_CODE_COLUMN; + use lance_index::vector::bq::transform::{ADD_FACTORS_COLUMN, SCALE_FACTORS_COLUMN}; + use lance_index::vector::bq::{RQRotationType, builder::RabitQuantizer}; + use lance_index::vector::flat::index::FlatIndex; + use lance_index::vector::flat::storage::FlatFloatStorage; + use lance_index::vector::sq::storage::ScalarQuantizationStorage; + + // ----- PQ helpers ------------------------------------------------------- + + fn make_test_codebook(dim: usize, num_sub_vectors: usize) -> FixedSizeListArray { + let sub_dim = dim / num_sub_vectors; + let num_centroids = 256; + let total_values = num_sub_vectors * num_centroids * sub_dim; + let values: Vec = (0..total_values).map(|i| i as f32 * 0.01).collect(); + let values_array = Float32Array::from(values); + FixedSizeListArray::try_new_from_values(values_array, sub_dim as i32).unwrap() + } + + fn make_test_pq_storage( + num_rows: usize, + dim: usize, + num_sub_vectors: usize, + ) -> ::Storage { + let codebook = make_test_codebook(dim, num_sub_vectors); + let row_ids = UInt64Array::from((0..num_rows as u64).collect::>()); + let pq_codes_flat: Vec = (0..num_rows * num_sub_vectors) + .map(|i| (i % 256) as u8) + .collect(); + let pq_codes = UInt8Array::from(pq_codes_flat); + let pq_codes_fsl = + FixedSizeListArray::try_new_from_values(pq_codes, num_sub_vectors as i32).unwrap(); + + let schema = Arc::new(Schema::new(vec![ + Field::new(lance_core::ROW_ID, DataType::UInt64, false), + Field::new( + lance_index::vector::PQ_CODE_COLUMN, + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::UInt8, true)), + num_sub_vectors as i32, + ), + false, + ), + ])); + + let batch = + RecordBatch::try_new(schema, vec![Arc::new(row_ids), Arc::new(pq_codes_fsl)]).unwrap(); + + ::Storage::new( + codebook, + batch, + 8, + num_sub_vectors, + dim, + DistanceType::L2, + false, + None, + ) + .unwrap() + } + + // ----- PQ tests --------------------------------------------------------- + + #[test] + fn test_roundtrip_flat_pq() { + let dim = 128; + let num_sub_vectors = 16; + let num_rows = 100; + + let storage = make_test_pq_storage(num_rows, dim, num_sub_vectors); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + + let mut serialized = Vec::new(); + entry.serialize(&mut serialized).unwrap(); + let deserialized = PartitionEntry::::deserialize( + &bytes::Bytes::from(serialized), + ) + .unwrap(); + + assert_eq!(entry.storage, deserialized.storage); + } + + #[test] + fn test_roundtrip_preserves_distance_type() { + for dt in [DistanceType::L2, DistanceType::Cosine, DistanceType::Dot] { + let dim = 32; + let num_sub_vectors = 4; + let codebook = make_test_codebook(dim, num_sub_vectors); + let row_ids = UInt64Array::from(vec![0u64, 1, 2]); + let pq_codes = UInt8Array::from(vec![0u8; 3 * num_sub_vectors]); + let pq_codes_fsl = + FixedSizeListArray::try_new_from_values(pq_codes, num_sub_vectors as i32).unwrap(); + + let schema = Arc::new(Schema::new(vec![ + Field::new(lance_core::ROW_ID, DataType::UInt64, false), + Field::new( + lance_index::vector::PQ_CODE_COLUMN, + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::UInt8, true)), + num_sub_vectors as i32, + ), + false, + ), + ])); + let batch = + RecordBatch::try_new(schema, vec![Arc::new(row_ids), Arc::new(pq_codes_fsl)]) + .unwrap(); + + let storage = ::Storage::new( + codebook, + batch, + 8, + num_sub_vectors, + dim, + dt, + false, + None, + ) + .unwrap(); + + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + let restored = PartitionEntry::::deserialize( + &bytes::Bytes::from(bytes), + ) + .unwrap(); + assert_eq!( + restored.storage.distance_type(), + entry.storage.distance_type() + ); + } + } + + #[test] + fn test_empty_partition() { + let dim = 16; + let num_sub_vectors = 2; + let storage = make_test_pq_storage(0, dim, num_sub_vectors); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + + let mut serialized = Vec::new(); + entry.serialize(&mut serialized).unwrap(); + let deserialized = PartitionEntry::::deserialize( + &bytes::Bytes::from(serialized), + ) + .unwrap(); + assert_eq!(entry.storage, deserialized.storage); + } + + #[test] + fn test_truncated_data_errors() { + // Serialize a valid entry, then truncate the bytes and verify that + // deserialization fails rather than panicking. + let storage = make_test_pq_storage(1, 16, 2); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + bytes.truncate(3); + assert!( + PartitionEntry::::deserialize(&bytes::Bytes::from(bytes)) + .is_err() + ); + } + + // ----- Flat helpers ----------------------------------------------------- + + fn make_flat_storage(num_rows: usize, dim: usize) -> FlatFloatStorage { + let values: Vec = (0..num_rows * dim).map(|i| i as f32 * 0.01).collect(); + let values_array = Float32Array::from(values); + let vectors = FixedSizeListArray::try_new_from_values(values_array, dim as i32).unwrap(); + FlatFloatStorage::new(vectors, DistanceType::L2) + } + + // ----- Flat tests ------------------------------------------------------- + + #[test] + fn test_roundtrip_flat_flat() { + let storage = make_flat_storage(50, 64); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + let restored = + PartitionEntry::::deserialize(&bytes::Bytes::from(bytes)) + .unwrap(); + + assert_eq!( + restored.storage.metadata().dim, + entry.storage.metadata().dim + ); + assert_eq!( + restored.storage.distance_type(), + entry.storage.distance_type() + ); + assert_eq!(restored.storage.len(), entry.storage.len()); + let orig_batch = entry.storage.to_batches().unwrap().next().unwrap(); + let rest_batch = restored.storage.to_batches().unwrap().next().unwrap(); + assert_eq!(orig_batch, rest_batch); + } + + #[test] + fn test_flat_distance_types() { + for dt in [DistanceType::L2, DistanceType::Cosine, DistanceType::Dot] { + let values = Float32Array::from(vec![1.0f32; 32]); + let vectors = FixedSizeListArray::try_new_from_values(values, 32).unwrap(); + let storage = FlatFloatStorage::new(vectors, dt); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + let restored = + PartitionEntry::::deserialize(&bytes::Bytes::from(bytes)) + .unwrap(); + assert_eq!(restored.storage.distance_type(), dt); + } + } + + // ----- SQ helpers ------------------------------------------------------- + + fn make_sq_storage( + num_rows: usize, + dim: usize, + distance_type: DistanceType, + ) -> ScalarQuantizationStorage { + let row_ids = UInt64Array::from_iter_values(0..num_rows as u64); + let sq_codes_flat: Vec = (0..num_rows * dim).map(|i| (i % 256) as u8).collect(); + let sq_codes = UInt8Array::from(sq_codes_flat); + let sq_codes_fsl = FixedSizeListArray::try_new_from_values(sq_codes, dim as i32).unwrap(); + + let schema = Arc::new(Schema::new(vec![ + Field::new(lance_core::ROW_ID, DataType::UInt64, false), + Field::new( + lance_index::vector::SQ_CODE_COLUMN, + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::UInt8, true)), + dim as i32, + ), + false, + ), + ])); + let batch = + RecordBatch::try_new(schema, vec![Arc::new(row_ids), Arc::new(sq_codes_fsl)]).unwrap(); + + ScalarQuantizationStorage::try_new(8, distance_type, -1.0..1.0, [batch], None).unwrap() + } + + // ----- SQ tests --------------------------------------------------------- + + #[test] + fn test_roundtrip_flat_sq() { + let storage = make_sq_storage(100, 64, DistanceType::L2); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + let restored = + PartitionEntry::::deserialize(&bytes::Bytes::from(bytes)) + .unwrap(); + + let m = entry.storage.metadata(); + let rm = restored.storage.metadata(); + assert_eq!(rm.dim, m.dim); + assert_eq!(rm.num_bits, m.num_bits); + assert_eq!(rm.bounds, m.bounds); + assert_eq!( + restored.storage.distance_type(), + entry.storage.distance_type() + ); + assert_eq!(restored.storage.len(), entry.storage.len()); + + let orig_ids: Vec = entry.storage.row_ids().copied().collect(); + let rest_ids: Vec = restored.storage.row_ids().copied().collect(); + assert_eq!(orig_ids, rest_ids); + } + + #[test] + fn test_sq_distance_types() { + for dt in [DistanceType::L2, DistanceType::Cosine, DistanceType::Dot] { + let storage = make_sq_storage(10, 16, dt); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + let restored = PartitionEntry::::deserialize( + &bytes::Bytes::from(bytes), + ) + .unwrap(); + assert_eq!(restored.storage.distance_type(), dt); + } + } + + #[test] + fn test_sq_multiple_chunks_no_copy() { + // Build SQ storage with multiple chunks by appending batches separately. + let dim = 16usize; + let make_batch = |start: u64, n: usize| { + let row_ids = UInt64Array::from_iter_values(start..start + n as u64); + let codes = UInt8Array::from(vec![0u8; n * dim]); + let fsl = FixedSizeListArray::try_new_from_values(codes, dim as i32).unwrap(); + let schema = Arc::new(Schema::new(vec![ + Field::new(lance_core::ROW_ID, DataType::UInt64, false), + Field::new( + lance_index::vector::SQ_CODE_COLUMN, + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::UInt8, true)), + dim as i32, + ), + false, + ), + ])); + RecordBatch::try_new(schema, vec![Arc::new(row_ids), Arc::new(fsl)]).unwrap() + }; + // Three chunks with 10 rows each. + let storage = ScalarQuantizationStorage::try_new( + 8, + DistanceType::L2, + -1.0..1.0, + [make_batch(0, 10), make_batch(10, 10), make_batch(20, 10)], + None, + ) + .unwrap(); + assert_eq!(storage.len(), 30); + + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + let restored = + PartitionEntry::::deserialize(&bytes::Bytes::from(bytes)) + .unwrap(); + + assert_eq!(restored.storage.len(), 30); + let orig_ids: Vec = entry.storage.row_ids().copied().collect(); + let rest_ids: Vec = restored.storage.row_ids().copied().collect(); + assert_eq!(orig_ids, rest_ids); + } + + // ----- RabitQ helpers --------------------------------------------------- + + fn make_rabit_storage_fast( + num_rows: usize, + code_dim: usize, + distance_type: DistanceType, + ) -> ::Storage { + use lance_arrow::FixedSizeListArrayExt; + + let quantizer = RabitQuantizer::new_with_rotation::( + 1, + code_dim as i32, + RQRotationType::Fast, + ); + let values: Vec = (0..num_rows * code_dim) + .map(|i| (i % 100) as f32 / 100.0 - 0.5) + .collect(); + let values_arr = Float32Array::from(values); + let vectors = FixedSizeListArray::try_new_from_values(values_arr, code_dim as i32).unwrap(); + let codes = quantizer + .quantize(&vectors) + .unwrap() + .as_fixed_size_list() + .clone(); + + let metadata = quantizer.metadata(None); + let batch = RecordBatch::try_from_iter(vec![ + ( + lance_core::ROW_ID, + Arc::new(UInt64Array::from_iter_values(0..num_rows as u64)) + as Arc, + ), + ( + RABIT_CODE_COLUMN, + Arc::new(codes) as Arc, + ), + ( + ADD_FACTORS_COLUMN, + Arc::new(Float32Array::from_iter_values( + (0..num_rows).map(|i| i as f32 * 0.1), + )) as Arc, + ), + ( + SCALE_FACTORS_COLUMN, + Arc::new(Float32Array::from_iter_values( + (0..num_rows).map(|i| i as f32 * 0.01 + 0.5), + )) as Arc, + ), + ]) + .unwrap(); + + ::Storage::try_from_batch( + batch, + &metadata, + distance_type, + None, + ) + .unwrap() + } + + // ----- RabitQ tests ----------------------------------------------------- + + #[test] + fn test_roundtrip_flat_rabitq_fast() { + let num_rows = 50; + let code_dim = 64; + let storage = make_rabit_storage_fast(num_rows, code_dim, DistanceType::L2); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + let restored = + PartitionEntry::::deserialize(&bytes::Bytes::from(bytes)) + .unwrap(); + + let m = entry.storage.metadata(); + let rm = restored.storage.metadata(); + assert_eq!(rm.num_bits, m.num_bits); + assert_eq!(rm.code_dim, m.code_dim); + assert_eq!(rm.rotation_type, m.rotation_type); + assert_eq!(rm.fast_rotation_signs, m.fast_rotation_signs); + assert!(rm.packed); + assert_eq!( + restored.storage.distance_type(), + entry.storage.distance_type() + ); + assert_eq!(restored.storage.len(), entry.storage.len()); + + let orig_ids: Vec = entry.storage.row_ids().copied().collect(); + let rest_ids: Vec = restored.storage.row_ids().copied().collect(); + assert_eq!(orig_ids, rest_ids); + + let orig_batch = entry.storage.to_batches().unwrap().next().unwrap(); + let rest_batch = restored.storage.to_batches().unwrap().next().unwrap(); + let orig_codes = orig_batch[RABIT_CODE_COLUMN].as_fixed_size_list(); + let rest_codes = rest_batch[RABIT_CODE_COLUMN].as_fixed_size_list(); + assert_eq!( + orig_codes.values().as_primitive::().values(), + rest_codes.values().as_primitive::().values(), + ); + } + + #[test] + fn test_rabitq_distance_types() { + for dt in [DistanceType::L2, DistanceType::Cosine, DistanceType::Dot] { + let storage = make_rabit_storage_fast(10, 32, dt); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + let restored = PartitionEntry::::deserialize( + &bytes::Bytes::from(bytes), + ) + .unwrap(); + assert_eq!(restored.storage.distance_type(), dt); + } + } + + #[test] + fn test_ivf_index_state_roundtrip() { + use crate::index::vector::ivf::v2::{IvfIndexState, IvfStateEntryBox}; + use lance_index::vector::flat::index::FlatQuantizer; + use lance_index::vector::ivf::storage::IvfModel; + use lance_index::vector::quantizer::QuantizationType; + use lance_index::vector::v3::subindex::SubIndexType; + + // Build a minimal IvfModel (single centroid, dim=2). + let centroids = + FixedSizeListArray::try_new_from_values(Float32Array::from(vec![0.0f32, 1.0]), 2) + .unwrap(); + let ivf = IvfModel::new(centroids, None); + + let state = IvfIndexState:: { + index_file_path: "my/index.lance".to_string(), + uuid: "test-uuid-1234".to_string(), + ivf: ivf.clone(), + aux_ivf: ivf, + distance_type: DistanceType::L2, + sub_index_metadata: vec!["meta1".to_string()], + metadata: lance_index::vector::flat::index::FlatMetadata { dim: 2 }, + sub_index_type: SubIndexType::Flat, + quantization_type: QuantizationType::Flat, + cache_key_prefix: "prefix/".to_string(), + index_file_size: 1024, + aux_file_size: 512, + }; + + let entry = IvfStateEntryBox(Arc::new(state)); + + let mut bytes = Vec::new(); + CacheCodecImpl::serialize(&entry, &mut bytes).unwrap(); + + let restored = + ::deserialize(&bytes::Bytes::from(bytes.clone())) + .unwrap(); + + // Re-serialize the restored entry and compare bytes — a stronger check + // than field-by-field comparison and avoids needing to downcast. + let mut restored_bytes = Vec::new(); + CacheCodecImpl::serialize(&restored, &mut restored_bytes).unwrap(); + assert_eq!(bytes, restored_bytes); + } +} diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 566e42887e8..595e5fb530f 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -3,14 +3,12 @@ //! IVF - Inverted File index. +use std::io::Write as IoWrite; use std::marker::PhantomData; use std::{any::Any, collections::HashMap, sync::Arc}; use crate::index::vector::{IndexFileVersion, builder::index_type_string}; -use crate::index::{ - PreFilter, - vector::{VectorIndex, utils::PartitionLoadLock}, -}; +use crate::index::{PreFilter, vector::VectorIndex}; use arrow::compute::concat_batches; use arrow_arith::numeric::sub; use arrow_array::{Float32Array, RecordBatch, UInt32Array}; @@ -18,23 +16,29 @@ use async_trait::async_trait; use datafusion::execution::SendableRecordBatchStream; use datafusion::physical_plan::stream::RecordBatchStreamAdapter; use deepsize::DeepSizeOf; +use futures::future::BoxFuture; use futures::prelude::stream::{self, TryStreamExt}; use futures::{StreamExt, TryFutureExt}; use lance_arrow::RecordBatchExt; -use lance_core::cache::{CacheKey, LanceCache, WeakLanceCache}; +use lance_arrow::ipc::write_len_prefixed_bytes; +use lance_core::cache::{CacheCodec, CacheCodecImpl, CacheKey, LanceCache, WeakLanceCache}; use lance_core::utils::tokio::spawn_cpu; use lance_core::utils::tracing::{IO_TYPE_LOAD_VECTOR_PART, TRACE_IO_EVENTS}; use lance_core::{Error, ROW_ID, Result}; use lance_encoding::decoder::{DecoderPlugins, FilterExpression}; -use lance_file::reader::{FileReader, FileReaderOptions}; +use lance_file::LanceEncodingsIo; +use lance_file::reader::{CachedFileMetadata, FileReader, FileReaderOptions}; use lance_index::frag_reuse::FragReuseIndex; use lance_index::metrics::{LocalMetricsCollector, MetricsCollector, NoOpMetricsCollector}; use lance_index::vector::VectorIndexCacheEntry; -use lance_index::vector::flat::index::{FlatIndex, FlatQuantizer}; +use lance_index::vector::bq::builder::RabitQuantizer; +use lance_index::vector::flat::index::{FlatBinQuantizer, FlatIndex, FlatQuantizer}; use lance_index::vector::hnsw::HNSW; use lance_index::vector::ivf::storage::IvfModel; use lance_index::vector::pq::ProductQuantizer; -use lance_index::vector::quantizer::{QuantizationType, Quantizer}; +use lance_index::vector::quantizer::{ + QuantizationType, Quantizer, QuantizerMetadata, QuantizerStorage, +}; use lance_index::vector::sq::ScalarQuantizer; use lance_index::vector::storage::VectorStore; use lance_index::vector::v3::subindex::SubIndexType; @@ -60,6 +64,381 @@ use tracing::{info, instrument}; use super::{IvfIndexPartitionStatistics, IvfIndexStatistics, centroids_to_vectors}; +/// Serializable state of an IVF index, sufficient to reconstruct the index +/// without re-reading global buffers from object storage. +/// +/// Serializable, type-specific state of an IVF index. +/// +/// Generic over `Q` so that the parsed quantizer metadata (`Q::Metadata`) can +/// be stored directly, avoiding repeated JSON round-trips on reconstruction. +/// Produced by [`IVFIndex::to_state_entry`] and wrapped in [`IvfStateEntryBox`] +/// for storage in the index cache. +#[derive(Debug, Clone)] +pub(crate) struct IvfIndexState { + pub(crate) index_file_path: String, + pub(crate) uuid: String, + pub(crate) ivf: IvfModel, + /// IvfModel for the auxiliary/storage file (quantizer row layout). + /// The index and aux files have independent row layouts, so we must store + /// both to avoid using wrong row offsets during reconstruction. + pub(crate) aux_ivf: IvfModel, + pub(crate) distance_type: DistanceType, + pub(crate) sub_index_metadata: Vec, + /// Parsed quantizer metadata — stored directly to avoid JSON re-parsing on + /// every warm-path reconstruction. + pub(crate) metadata: ::Metadata, + pub(crate) sub_index_type: SubIndexType, + pub(crate) quantization_type: QuantizationType, + /// The cache key prefix used by the original index's WeakLanceCache. + pub(crate) cache_key_prefix: String, + /// File sizes for the index and auxiliary files, used to avoid HEAD requests + /// when reconstructing from cache. + pub(crate) index_file_size: u64, + pub(crate) aux_file_size: u64, +} + +impl DeepSizeOf for IvfIndexState { + fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize { + self.index_file_path.deep_size_of_children(context) + + self.uuid.deep_size_of_children(context) + + self.ivf.deep_size_of_children(context) + + self.aux_ivf.deep_size_of_children(context) + + self.sub_index_metadata.deep_size_of_children(context) + + self.metadata.deep_size_of_children(context) + + self.cache_key_prefix.deep_size_of_children(context) + } +} + +/// Serialization header for the `IvfIndexState` wire format. +/// +/// Kept as a flat, non-generic struct so the JSON header format is stable +/// regardless of `Q`. `quantizer_metadata_json` holds the serialized +/// `Q::Metadata`; large blobs (PQ codebook, RQ matrix) follow as raw bytes. +#[derive(serde::Serialize, serde::Deserialize)] +struct IvfIndexStateHeader { + index_file_path: String, + uuid: String, + distance_type: String, + sub_index_metadata: Vec, + sub_index_type: String, + quantization_type: String, + quantizer_metadata_json: String, + #[serde(default)] + cache_key_prefix: String, + #[serde(default)] + index_file_size: u64, + #[serde(default)] + aux_file_size: u64, +} + +/// Object-safe interface for a type-erased `IvfIndexState`. +/// +/// Stored as `Arc` inside [`IvfStateEntryBox`], which is +/// the concrete type held in the index cache. Splitting the trait from the +/// wrapper lets the cache infrastructure work with a sized type while the +/// hot paths call `reconstruct` without knowing `Q`. +pub(crate) trait IvfStateEntry: DeepSizeOf + Send + Sync + 'static { + fn serialize_state(&self, writer: &mut dyn IoWrite) -> Result<()>; + + fn reconstruct<'a>( + &'a self, + object_store: Arc, + file_metadata_cache: &'a LanceCache, + index_cache: LanceCache, + ) -> BoxFuture<'a, Result>>; +} + +/// Sized wrapper around `Arc` for use as a cache value. +/// +/// `IvfStateEntryBox` is the `CacheKey::ValueType` for `IvfIndexStateCacheKey`. +/// `CacheCodecImpl` on this type holds the full deserialization dispatch +/// (matching on `quantization_type`) so callers never need to branch on +/// index type after a cache hit. +pub(crate) struct IvfStateEntryBox(pub(crate) Arc); + +impl DeepSizeOf for IvfStateEntryBox { + fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize { + self.0.deep_size_of_children(context) + } +} + +/// Wire format (unchanged from the non-generic `IvfIndexState`): +/// `[header_json_len: u64 LE][header JSON][ivf_pb_len: u64 LE][ivf protobuf] +/// [extra_len: u64 LE][extra bytes][aux_ivf_pb_len: u64 LE][aux_ivf protobuf]` +impl CacheCodecImpl for IvfStateEntryBox { + fn serialize(&self, writer: &mut dyn IoWrite) -> Result<()> { + self.0.serialize_state(writer) + } + + fn deserialize(data: &bytes::Bytes) -> Result { + use lance_arrow::ipc::read_len_prefixed_bytes_at; + + // Parse the common wire format, then dispatch on quantization_type to + // construct the right IvfIndexState. + let mut offset = 0; + let header_bytes = read_len_prefixed_bytes_at(data, &mut offset)?; + let header: IvfIndexStateHeader = serde_json::from_slice(&header_bytes) + .map_err(|e| lance_core::Error::io(format!("IvfIndexState header: {e}")))?; + + let ivf_bytes = read_len_prefixed_bytes_at(data, &mut offset)?; + let ivf = IvfModel::try_from( + pb::Ivf::decode(ivf_bytes.as_ref()) + .map_err(|e| lance_core::Error::io(format!("IvfIndexState IVF decode: {e}")))?, + )?; + + let extra_bytes = read_len_prefixed_bytes_at(data, &mut offset)?; + + // aux_ivf was added after initial deployment; fall back to ivf on + // clean EOF (legacy format without the field). + let aux_ivf = if offset + 8 <= data.len() { + let aux_ivf_bytes = read_len_prefixed_bytes_at(data, &mut offset)?; + IvfModel::try_from(pb::Ivf::decode(aux_ivf_bytes.as_ref()).map_err(|e| { + lance_core::Error::io(format!("IvfIndexState aux IVF decode: {e}")) + })?)? + } else { + ivf.clone() + }; + + let distance_type = DistanceType::try_from(header.distance_type.as_str())?; + let sub_index_type = SubIndexType::try_from(header.sub_index_type.as_str())?; + let quantization_type = header.quantization_type.parse::()?; + + // Helper: parse Q::Metadata from the JSON+extra_bytes in the header, + // then build an IvfStateEntryBox wrapping IvfIndexState. + fn make_entry( + header: IvfIndexStateHeader, + ivf: IvfModel, + aux_ivf: IvfModel, + extra_bytes: bytes::Bytes, + distance_type: DistanceType, + sub_index_type: SubIndexType, + quantization_type: QuantizationType, + ) -> Result + where + ::Metadata: + serde::de::DeserializeOwned + QuantizerMetadata, + { + let mut metadata: ::Metadata = + serde_json::from_str(&header.quantizer_metadata_json) + .map_err(|e| lance_core::Error::io(format!("IvfIndexState metadata: {e}")))?; + if !extra_bytes.is_empty() { + metadata.parse_buffer(extra_bytes)?; + } + Ok(IvfStateEntryBox(Arc::new(IvfIndexState:: { + index_file_path: header.index_file_path, + uuid: header.uuid, + ivf, + aux_ivf, + distance_type, + sub_index_metadata: header.sub_index_metadata, + metadata, + sub_index_type, + quantization_type, + cache_key_prefix: header.cache_key_prefix, + index_file_size: header.index_file_size, + aux_file_size: header.aux_file_size, + }))) + } + + match quantization_type { + QuantizationType::Flat => make_entry::( + header, + ivf, + aux_ivf, + extra_bytes, + distance_type, + sub_index_type, + quantization_type, + ), + QuantizationType::FlatBin => make_entry::( + header, + ivf, + aux_ivf, + extra_bytes, + distance_type, + sub_index_type, + quantization_type, + ), + QuantizationType::Product => make_entry::( + header, + ivf, + aux_ivf, + extra_bytes, + distance_type, + sub_index_type, + quantization_type, + ), + QuantizationType::Scalar => make_entry::( + header, + ivf, + aux_ivf, + extra_bytes, + distance_type, + sub_index_type, + quantization_type, + ), + QuantizationType::Rabit => make_entry::( + header, + ivf, + aux_ivf, + extra_bytes, + distance_type, + sub_index_type, + quantization_type, + ), + } + } +} + +impl IvfStateEntry for IvfIndexState { + fn serialize_state(&self, writer: &mut dyn IoWrite) -> Result<()> { + let quantizer_metadata_json = serde_json::to_string(&self.metadata) + .map_err(|e| lance_core::Error::io(format!("IvfIndexState metadata: {e}")))?; + let extra = self.metadata.extra_metadata()?; + let extra = extra.as_deref().unwrap_or(&[]); + + let header = IvfIndexStateHeader { + index_file_path: self.index_file_path.clone(), + uuid: self.uuid.clone(), + distance_type: self.distance_type.to_string(), + sub_index_metadata: self.sub_index_metadata.clone(), + sub_index_type: self.sub_index_type.to_string(), + quantization_type: self.quantization_type.to_string(), + quantizer_metadata_json, + cache_key_prefix: self.cache_key_prefix.clone(), + index_file_size: self.index_file_size, + aux_file_size: self.aux_file_size, + }; + let header_json = serde_json::to_vec(&header) + .map_err(|e| lance_core::Error::io(format!("IvfIndexState header: {e}")))?; + let ivf_bytes = pb::Ivf::try_from(&self.ivf)?.encode_to_vec(); + let aux_ivf_bytes = pb::Ivf::try_from(&self.aux_ivf)?.encode_to_vec(); + + write_len_prefixed_bytes(writer, &header_json)?; + write_len_prefixed_bytes(writer, &ivf_bytes)?; + write_len_prefixed_bytes(writer, extra)?; + write_len_prefixed_bytes(writer, &aux_ivf_bytes)?; + Ok(()) + } + + fn reconstruct<'a>( + &'a self, + object_store: Arc, + file_metadata_cache: &'a LanceCache, + index_cache: LanceCache, + ) -> BoxFuture<'a, Result>> { + Box::pin(async move { + match self.sub_index_type { + SubIndexType::Flat => { + reconstruct_typed::( + self, + object_store, + file_metadata_cache, + index_cache, + ) + .await + } + SubIndexType::Hnsw => { + reconstruct_typed::( + self, + object_store, + file_metadata_cache, + index_cache, + ) + .await + } + } + }) + } +} + +struct FileMetadataCacheKey; + +impl CacheKey for FileMetadataCacheKey { + type ValueType = CachedFileMetadata; + fn type_name() -> &'static str { + "CachedFileMetadata" + } + fn key(&self) -> std::borrow::Cow<'_, str> { + "".into() + } +} + +/// Cached open file readers for the index and aux files. +/// +/// Stored in `file_metadata_cache` to avoid re-opening files on every reconstruction. +/// Not serializable (no codec); a cache miss just triggers a re-open. +struct CachedIndexReaders { + index_reader: Arc, + aux_reader: Arc, +} + +impl deepsize::DeepSizeOf for CachedIndexReaders { + fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize { + // FileReader doesn't impl DeepSizeOf. We approximate by counting the + // fixed struct size for each reader plus the Arc + // heap contents. The metadata Arcs are also held by FileMetadataCacheKey + // entries, so this may over-count across cache entries, but + // over-counting is safer than under-counting for eviction purposes. + std::mem::size_of::() * 2 + + self.index_reader.metadata().deep_size_of_children(context) + + self.aux_reader.metadata().deep_size_of_children(context) + } +} + +struct CachedIndexReadersKey { + uuid: String, +} + +impl CacheKey for CachedIndexReadersKey { + type ValueType = CachedIndexReaders; + fn type_name() -> &'static str { + "CachedIndexReaders" + } + fn key(&self) -> std::borrow::Cow<'_, str> { + self.uuid.as_str().into() + } + // No codec() override → in-memory only +} + +/// Open a FileReader, reusing cached file metadata if available. +async fn open_reader_cached( + scheduler: &Arc, + path: &Path, + cache: &LanceCache, + known_file_size: u64, +) -> Result { + let file_cache = cache.with_key_prefix(path.as_ref()); + // CachedFileSize::new(0) == CachedFileSize::unknown(); passing the raw + // hint directly is safe — the type already encodes 0 as "unknown". + let cached_size = CachedFileSize::new(known_file_size); + + if let Some(cached_meta) = file_cache.get_with_key(&FileMetadataCacheKey).await { + let file_scheduler = scheduler.open_file(path, &cached_size).await?; + let encodings_io = Arc::new(LanceEncodingsIo::new(file_scheduler)); + FileReader::try_open_with_file_metadata( + encodings_io, + path.clone(), + None, + Arc::::default(), + cached_meta, + cache, + FileReaderOptions::default(), + ) + .await + } else { + let file_scheduler = scheduler.open_file(path, &cached_size).await?; + FileReader::try_open( + file_scheduler, + None, + Arc::::default(), + cache, + FileReaderOptions::default(), + ) + .await + } +} + #[derive(Debug, DeepSizeOf)] pub struct PartitionEntry { pub index: S, @@ -102,12 +481,20 @@ impl CacheKey for IVFPartit // types, so the monomorphized pointer is consistent. std::any::type_name::>() } + + fn codec() -> Option { + super::partition_serde::partition_entry_codec::() + } } /// IVF Index. #[derive(Debug)] pub struct IVFIndex { + /// Local display path (via `to_local_path`), used for statistics. uri: String, + /// Object-store path to the index file (forward-slash separated). + /// Used by `cacheable_state()` for cross-platform reconstruction. + index_path: String, uuid: String, /// Ivf model @@ -117,8 +504,6 @@ pub struct IVFIndex { sub_index_metadata: Vec, storage: IvfQuantizationStorage, - partition_locks: PartitionLoadLock, - distance_type: DistanceType, index_cache: WeakLanceCache, @@ -131,6 +516,7 @@ pub struct IVFIndex { impl DeepSizeOf for IVFIndex { fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize { self.uri.deep_size_of_children(context) + + self.index_path.deep_size_of_children(context) + self.ivf.deep_size_of_children(context) + self.sub_index_metadata.deep_size_of_children(context) + self.uuid.deep_size_of_children(context) @@ -216,14 +602,38 @@ impl IVFIndex { let storage = IvfQuantizationStorage::try_new(storage_reader, frag_reuse_index.clone()).await?; - let num_partitions = ivf.num_partitions(); + // Cache file metadata so reconstructions from IvfIndexState can skip + // footer reads. + file_metadata_cache + .with_key_prefix(uri.as_ref()) + .insert_with_key(&FileMetadataCacheKey, index_reader.metadata().clone()) + .await; + let aux_path = index_dir + .child(uuid.as_str()) + .child(INDEX_AUXILIARY_FILE_NAME); + file_metadata_cache + .with_key_prefix(aux_path.as_ref()) + .insert_with_key(&FileMetadataCacheKey, storage.reader().metadata().clone()) + .await; + + // Cache open readers so the first reconstruction also skips file opens. + file_metadata_cache + .insert_with_key( + &CachedIndexReadersKey { uuid: uuid.clone() }, + Arc::new(CachedIndexReaders { + index_reader: Arc::new(index_reader.clone()), + aux_reader: Arc::new(storage.reader().clone()), + }), + ) + .await; + Ok(Self { uri: to_local_path(&uri), + index_path: uri.as_ref().to_string(), uuid, ivf, reader: index_reader, storage, - partition_locks: PartitionLoadLock::new(num_partitions), sub_index_metadata, distance_type, index_cache: WeakLanceCache::from(&index_cache), @@ -232,6 +642,35 @@ impl IVFIndex { }) } + /// Reconstruct an IVFIndex from pre-parsed state without any I/O. + #[allow(clippy::too_many_arguments)] + pub(crate) fn from_cached_state( + uri: String, + index_path: String, + uuid: String, + ivf: IvfModel, + reader: FileReader, + storage: IvfQuantizationStorage, + sub_index_metadata: Vec, + distance_type: DistanceType, + index_cache: LanceCache, + io_parallelism: usize, + ) -> Self { + Self { + uri, + index_path, + uuid, + ivf, + reader, + storage, + sub_index_metadata, + distance_type, + index_cache: WeakLanceCache::from(&index_cache), + io_parallelism, + _marker: PhantomData, + } + } + #[instrument(level = "debug", skip(self, metrics))] pub async fn load_partition( &self, @@ -239,71 +678,69 @@ impl IVFIndex { write_cache: bool, metrics: &dyn MetricsCollector, ) -> Result> { + if partition_id >= self.ivf.num_partitions() { + return Err(Error::index(format!( + "partition id {} is out of range of {} partitions", + partition_id, + self.ivf.num_partitions() + ))); + } + let cache_key = IVFPartitionKey::::new(partition_id); - let part_entry = if let Some(part_idx) = self.index_cache.get_with_key(&cache_key).await { - part_idx + + if write_cache { + let entry = self + .index_cache + .get_or_insert_with_key(cache_key, || async { + info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_VECTOR_PART, index_type="ivf", part_id=partition_id); + metrics.record_part_load(); + self.load_partition_entry(partition_id).await + }) + .await?; + Ok(entry as Arc) } else { - info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_VECTOR_PART, index_type="ivf", part_id=cache_key.key().as_ref()); - metrics.record_part_load(); - if partition_id >= self.ivf.num_partitions() { - return Err(Error::index(format!( - "partition id {} is out of range of {} partitions", - partition_id, - self.ivf.num_partitions() - ))); + if let Some(part_idx) = self.index_cache.get_with_key(&cache_key).await { + return Ok(part_idx); } + info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_VECTOR_PART, index_type="ivf", part_id=partition_id); + metrics.record_part_load(); + Ok(Arc::new(self.load_partition_entry(partition_id).await?)) + } + } - let mtx = self.partition_locks.get_partition_mutex(partition_id); - let _guard = mtx.lock().await; - - // check the cache again, as the partition may have been loaded by another - // thread that held the lock on loading the partition - if let Some(part_idx) = self.index_cache.get_with_key(&cache_key).await { - part_idx - } else { - let schema = Arc::new(self.reader.schema().as_ref().into()); - let batch = match self.reader.metadata().num_rows { - 0 => RecordBatch::new_empty(schema), - _ => { - let row_range = self.ivf.row_range(partition_id); - if row_range.is_empty() { - RecordBatch::new_empty(schema) - } else { - let batches = self - .reader - .read_stream( - ReadBatchParams::Range(row_range), - u32::MAX, - 1, - FilterExpression::no_filter(), - )? - .try_collect::>() - .await?; - concat_batches(&schema, batches.iter())? - } - } - }; - let batch = batch.add_metadata( - S::metadata_key().to_owned(), - self.sub_index_metadata[partition_id].clone(), - )?; - let idx = S::load(batch)?; - let storage = self.load_partition_storage(partition_id).await?; - let partition_entry = Arc::new(PartitionEntry:: { - index: idx, - storage, - }); - if write_cache { - self.index_cache - .insert_with_key(&cache_key, partition_entry.clone()) - .await; + async fn load_partition_entry(&self, partition_id: usize) -> Result> { + let schema = Arc::new(self.reader.schema().as_ref().into()); + let batch = match self.reader.metadata().num_rows { + 0 => RecordBatch::new_empty(schema), + _ => { + let row_range = self.ivf.row_range(partition_id); + if row_range.is_empty() { + RecordBatch::new_empty(schema) + } else { + let batches = self + .reader + .read_stream( + ReadBatchParams::Range(row_range), + u32::MAX, + 1, + FilterExpression::no_filter(), + )? + .try_collect::>() + .await?; + concat_batches(&schema, batches.iter())? } - - partition_entry } }; - - Ok(part_entry) + let batch = batch.add_metadata( + S::metadata_key().to_owned(), + self.sub_index_metadata[partition_id].clone(), + )?; + let idx = S::load(batch)?; + let storage = self.load_partition_storage(partition_id).await?; + Ok(PartitionEntry { + index: idx, + storage, + }) } pub async fn load_partition_storage(&self, partition_id: usize) -> Result { @@ -330,6 +767,25 @@ impl IVFIndex { Ok(query.clone()) } } + + /// Export the index state needed for reconstruction from a disk cache. + pub(crate) fn to_state_entry(&self) -> IvfStateEntryBox { + let (sub_index_type, quantization_type) = self.sub_index_type(); + IvfStateEntryBox(Arc::new(IvfIndexState:: { + index_file_path: self.index_path.clone(), + uuid: self.uuid.clone(), + ivf: self.ivf.clone(), + aux_ivf: self.storage.ivf().clone(), + distance_type: self.distance_type, + sub_index_metadata: self.sub_index_metadata.clone(), + metadata: self.storage.metadata().clone(), + sub_index_type, + quantization_type, + cache_key_prefix: self.index_cache.prefix().to_string(), + index_file_size: self.reader.metadata().file_size(), + aux_file_size: self.storage.reader().metadata().file_size(), + })) + } } #[async_trait] @@ -358,13 +814,15 @@ impl Index for IVFIndex IndexType { match self.sub_index_type() { - (SubIndexType::Flat, QuantizationType::Flat) => IndexType::IvfFlat, + (SubIndexType::Flat, QuantizationType::Flat) + | (SubIndexType::Flat, QuantizationType::FlatBin) => IndexType::IvfFlat, (SubIndexType::Flat, QuantizationType::Product) => IndexType::IvfPq, (SubIndexType::Flat, QuantizationType::Scalar) => IndexType::IvfSq, (SubIndexType::Flat, QuantizationType::Rabit) => IndexType::IvfRq, (SubIndexType::Hnsw, QuantizationType::Product) => IndexType::IvfHnswPq, (SubIndexType::Hnsw, QuantizationType::Scalar) => IndexType::IvfHnswSq, - (SubIndexType::Hnsw, QuantizationType::Flat) => IndexType::IvfHnswFlat, + (SubIndexType::Hnsw, QuantizationType::Flat) + | (SubIndexType::Hnsw, QuantizationType::FlatBin) => IndexType::IvfHnswFlat, (sub_index_type, quantization_type) => { unimplemented!( "unsupported index type: {}, {}", @@ -399,10 +857,12 @@ impl Index for IVFIndex "FLAT".to_string(), + other => other.to_string(), + }; + sub_index_stats.insert("index_type".to_string(), qt_label.into()); } else { sub_index_stats.insert("index_type".to_string(), S::name().into()); } @@ -609,6 +1069,84 @@ pub type IvfPq = IVFIndex; pub type IvfHnswSqIndex = IVFIndex; pub type IvfHnswPqIndex = IVFIndex; +async fn reconstruct_typed( + state: &IvfIndexState, + object_store: Arc, + file_metadata_cache: &LanceCache, + index_cache: LanceCache, +) -> Result> { + let io_parallelism = object_store.io_parallelism(); + + let index_path = Path::parse(&state.index_file_path) + .map_err(|e| Error::io(format!("invalid index path: {e}")))?; + + // Derive aux path from the index path's parent directory. + let mut parts: Vec<_> = index_path.parts().collect(); + parts.pop(); + let dir: Path = parts.into_iter().collect(); + let aux_path = dir.child(INDEX_AUXILIARY_FILE_NAME); + + let readers_key = CachedIndexReadersKey { + uuid: state.uuid.clone(), + }; + + let (index_reader, aux_reader) = + if let Some(cached) = file_metadata_cache.get_with_key(&readers_key).await { + // Warm path: reuse the cached readers directly, no file opens needed. + ((*cached.index_reader).clone(), (*cached.aux_reader).clone()) + } else { + // Cold path: open files, then cache the readers for future reconstructions. + let scheduler_config = SchedulerConfig::max_bandwidth(&object_store); + let scheduler = ScanScheduler::new(object_store, scheduler_config); + let index_reader = open_reader_cached( + &scheduler, + &index_path, + file_metadata_cache, + state.index_file_size, + ) + .await?; + let aux_reader = open_reader_cached( + &scheduler, + &aux_path, + file_metadata_cache, + state.aux_file_size, + ) + .await?; + file_metadata_cache + .insert_with_key( + &readers_key, + Arc::new(CachedIndexReaders { + index_reader: Arc::new(index_reader.clone()), + aux_reader: Arc::new(aux_reader.clone()), + }), + ) + .await; + (index_reader, aux_reader) + }; + + let storage = IvfQuantizationStorage::from_cached( + aux_reader, + state.aux_ivf.clone(), + state.metadata.clone(), + state.distance_type, + None, + ); + + let index = IVFIndex::::from_cached_state( + to_local_path(&index_path), + index_path.to_string(), + state.uuid.clone(), + state.ivf.clone(), + index_reader, + storage, + state.sub_index_metadata.clone(), + state.distance_type, + index_cache, + io_parallelism, + ); + Ok(Arc::new(index)) +} + #[cfg(test)] mod tests { use std::collections::HashSet; @@ -675,7 +1213,6 @@ mod tests { use lance_linalg::kernels::normalize_fsl; use lance_table::format::IndexMetadata; use lance_testing::datagen::{generate_random_array, generate_random_array_with_range}; - use object_store::path::Path; use rand::distr::uniform::SampleUniform; use rand::{Rng, SeedableRng, rngs::StdRng}; use rstest::rstest; @@ -2496,10 +3033,10 @@ mod tests { && matches!(params.version, IndexFileVersion::V3) && params.index_type() == IndexType::IvfPq { - let index = dataset.load_indices().await.unwrap(); - assert_eq!(index.len(), 1); - let index_path = dataset.indices_dir().child(index[0].uuid.to_string()); - rewrite_pq_storage(index_path).await.unwrap(); + let indices = dataset.load_indices().await.unwrap(); + assert_eq!(indices.len(), 1); + let old_meta = indices[0].clone(); + rewrite_pq_storage(&mut dataset, &old_meta).await.unwrap(); // do the test again test_recall::( params, @@ -3473,17 +4010,36 @@ mod tests { ); } - async fn rewrite_pq_storage(dir: Path) -> Result<()> { + /// Rewrite the auxiliary storage file to the legacy PQ format (codebook + /// embedded in schema metadata rather than stored as a global buffer), then + /// commit a `CreateIndex` transaction so the manifest records the correct + /// new file size. + /// Rewrite the auxiliary PQ storage file with the codebook inlined into + /// schema metadata (legacy format). Uses a new UUID to avoid cache key + /// collisions with the original index. + async fn rewrite_pq_storage(dataset: &mut Dataset, old_meta: &IndexMetadata) -> Result<()> { + use crate::dataset::transaction::{Operation, Transaction}; + let obj_store = Arc::new(ObjectStore::local()); - let store_path = dir.child(INDEX_AUXILIARY_FILE_NAME); - let copied_path = dir.child(format!("{}.original", INDEX_AUXILIARY_FILE_NAME)); - obj_store.copy(&store_path, &copied_path).await?; - obj_store.delete(&store_path).await?; + let old_dir = dataset.indices_dir().child(old_meta.uuid.to_string()); + let new_uuid = uuid::Uuid::new_v4(); + let new_dir = dataset.indices_dir().child(new_uuid.to_string()); + + // Copy the main index file to the new directory unchanged. + obj_store + .copy( + &old_dir.child(super::INDEX_FILE_NAME), + &new_dir.child(super::INDEX_FILE_NAME), + ) + .await?; + + // Read the original auxiliary file. + let old_aux_path = old_dir.child(INDEX_AUXILIARY_FILE_NAME); let scheduler = ScanScheduler::new(obj_store.clone(), SchedulerConfig::default_for_testing()); let reader = FileReader::try_open( scheduler - .open_file(&copied_path, &CachedFileSize::unknown()) + .open_file(&old_aux_path, &CachedFileSize::unknown()) .await?, None, Arc::::default(), @@ -3492,21 +4048,21 @@ mod tests { ) .await?; + // Rewrite auxiliary file with PQ codebook inlined into schema metadata. let mut metadata = reader.schema().metadata.clone(); let batch = reader .read_range(0..reader.num_rows() as usize, None) .await?; + let new_aux_path = new_dir.child(INDEX_AUXILIARY_FILE_NAME); let mut writer = FileWriter::try_new( - obj_store.create(&store_path).await?, + obj_store.create(&new_aux_path).await?, batch.schema_ref().as_ref().try_into()?, Default::default(), )?; writer.write_batch(&batch).await?; - // write the IVF writer .add_global_buffer(reader.read_global_buffer(1).await?) .await?; - // rewrite the PQ to legacy format let codebook = reader.read_global_buffer(2).await?; let pq_metadata: Vec = serde_json::from_str(&metadata[STORAGE_METADATA_KEY])?; let mut pq_metadata: ProductQuantizationMetadata = serde_json::from_str(&pq_metadata[0])?; @@ -3518,7 +4074,26 @@ mod tests { serde_json::to_string(&vec![pq_metadata])?, ); writer.finish_with_metadata(metadata).await?; - obj_store.delete(&copied_path).await?; + + // Build new IndexMetadata with the new UUID and file sizes. + let new_files = + lance_table::format::list_index_files_with_sizes(&obj_store, &new_dir).await?; + let mut new_meta = old_meta.clone(); + new_meta.uuid = new_uuid; + new_meta.files = Some(new_files); + + let transaction = Transaction::new( + dataset.manifest.version, + Operation::CreateIndex { + new_indices: vec![new_meta], + removed_indices: vec![old_meta.clone()], + }, + None, + ); + dataset + .apply_commit(transaction, &Default::default(), &Default::default()) + .await?; + Ok(()) } @@ -4385,4 +4960,205 @@ mod tests { let stats = dataset.object_store().io_stats_incremental(); assert_io_eq!(stats, read_iops, 0, "second prewarm should not perform IO"); } + + type SerializedEntry = (Vec, lance_core::cache::CacheCodec, usize); + + #[derive(Debug)] + struct SerializingBackend { + /// Serialized entries: key -> (bytes, codec, size). + serialized: tokio::sync::Mutex< + std::collections::HashMap, + >, + /// Fallback for entries without a codec. + passthrough: lance_core::cache::MokaCacheBackend, + } + + impl SerializingBackend { + fn new() -> Self { + Self { + serialized: tokio::sync::Mutex::new(std::collections::HashMap::new()), + passthrough: lance_core::cache::MokaCacheBackend::with_capacity(256 * 1024 * 1024), + } + } + + async fn serialized_entry_count(&self) -> usize { + self.serialized.lock().await.len() + } + + async fn passthrough_entry_count(&self) -> usize { + use lance_core::cache::CacheBackend; + self.passthrough.num_entries().await + } + } + + #[async_trait::async_trait] + impl lance_core::cache::CacheBackend for SerializingBackend { + async fn get( + &self, + key: &lance_core::cache::InternalCacheKey, + codec: Option, + ) -> Option { + // Try serialized store first + let guard = self.serialized.lock().await; + if let Some((bytes, stored_codec, _)) = guard.get(key) { + return Some( + stored_codec + .deserialize(&bytes::Bytes::copy_from_slice(bytes)) + .expect("deserialization should succeed"), + ); + } + drop(guard); + // Fall through to passthrough + self.passthrough.get(key, codec).await + } + + async fn insert( + &self, + key: &lance_core::cache::InternalCacheKey, + entry: lance_core::cache::CacheEntry, + size_bytes: usize, + codec: Option, + ) { + if let Some(codec) = codec { + let mut bytes = Vec::new(); + codec + .serialize(&entry, &mut bytes) + .expect("serialization should succeed"); + self.serialized + .lock() + .await + .insert(key.clone(), (bytes, codec, size_bytes)); + } else { + self.passthrough.insert(key, entry, size_bytes, None).await; + } + } + + async fn get_or_insert<'a>( + &self, + key: &lance_core::cache::InternalCacheKey, + loader: std::pin::Pin< + Box< + dyn futures::Future> + + Send + + 'a, + >, + >, + codec: Option, + ) -> Result<(lance_core::cache::CacheEntry, bool)> { + if let Some(entry) = self.get(key, codec).await { + return Ok((entry, true)); + } + let (entry, size) = loader.await?; + self.insert(key, entry.clone(), size, codec).await; + Ok((entry, false)) + } + + async fn invalidate_prefix(&self, prefix: &str) { + self.serialized + .lock() + .await + .retain(|k, _| !k.starts_with(prefix)); + self.passthrough.invalidate_prefix(prefix).await; + } + + async fn clear(&self) { + self.serialized.lock().await.clear(); + self.passthrough.clear().await; + } + + async fn num_entries(&self) -> usize { + self.serialized.lock().await.len() + self.passthrough.num_entries().await + } + + async fn size_bytes(&self) -> usize { + let serialized: usize = self + .serialized + .lock() + .await + .values() + .map(|(_, _, s)| *s) + .sum(); + serialized + self.passthrough.size_bytes().await + } + } + + /// Integration test: create a vector index, prewarm it through a + /// serializing cache backend, then query. Verifies that entries are + /// serialized to bytes and that queries produce correct results after + /// deserialization. + #[tokio::test] + async fn test_prewarm_and_query_with_serializing_backend() { + let test_dir = TempStrDir::default(); + let test_uri = test_dir.as_str(); + + // Create dataset with vector index using default cache + let (mut dataset, _) = generate_test_dataset::(test_uri, 0.0..1.0).await; + let params = VectorIndexParams::with_ivf_pq_params( + DistanceType::L2, + IvfBuildParams::new(4), + PQBuildParams::default(), + ); + dataset + .create_index( + &["vector"], + IndexType::Vector, + Some("serde_idx".to_owned()), + ¶ms, + true, + ) + .await + .unwrap(); + + // Re-open with the serializing backend + let backend = Arc::new(SerializingBackend::new()); + let session = Arc::new(crate::session::Session::with_index_cache_backend( + backend.clone(), + 128 * 1024 * 1024, + Arc::new(lance_io::object_store::ObjectStoreRegistry::default()), + )); + let dataset = crate::DatasetBuilder::from_uri(test_uri) + .with_session(session) + .load() + .await + .unwrap(); + + // Prewarm — this should serialize entries into the backend + dataset.prewarm_index("serde_idx").await.unwrap(); + let serialized = backend.serialized_entry_count().await; + let passthrough = backend.passthrough_entry_count().await; + assert!( + serialized > 0, + "prewarm should have serialized entries into the backend" + ); + assert_eq!( + passthrough, 0, + "all index cache entries should have codecs (nothing in passthrough), \ + but found {passthrough} passthrough entries" + ); + + // Query — the backend will deserialize entries from bytes. + // After prewarm, all entries are in serialized form, so every + // cache hit involves a deserialization round-trip. + let q = Float32Array::from_iter_values(repeat_n(0.5, DIM)); + let results = dataset + .scan() + .nearest("vector", &q, 10) + .unwrap() + .nprobes(4) + .try_into_batch() + .await + .unwrap(); + assert_eq!(results.num_rows(), 10, "should return 10 nearest neighbors"); + + // Verify distances are sorted (ascending for L2) + let distances: Vec = results + .column_by_name("_distance") + .unwrap() + .as_primitive::() + .values() + .to_vec(); + for w in distances.windows(2) { + assert!(w[1] >= w[0], "distances should be sorted ascending"); + } + } } diff --git a/rust/lance/src/session/caches.rs b/rust/lance/src/session/caches.rs index 55f78a5068f..f8cdd520dad 100644 --- a/rust/lance/src/session/caches.rs +++ b/rust/lance/src/session/caches.rs @@ -34,12 +34,6 @@ impl GlobalMetadataCache { // This prevents collisions between different datasets. DSMetadataCache(self.0.with_key_prefix(uri)) } - - /// Create a file-specific metadata cache with the given prefix. - /// This is used by file readers and other components that need file-level caching. - pub(crate) fn file_metadata_cache(&self, path: &Path) -> LanceCache { - self.0.with_key_prefix(path.as_ref()) - } } impl Clone for GlobalMetadataCache { diff --git a/rust/lance/src/session/index_caches.rs b/rust/lance/src/session/index_caches.rs index 43443b5dd34..3ae777880aa 100644 --- a/rust/lance/src/session/index_caches.rs +++ b/rust/lance/src/session/index_caches.rs @@ -109,6 +109,10 @@ impl CacheKey for IndexMetadataKey { fn type_name() -> &'static str { "Vec" } + + fn codec() -> Option { + Some(lance_table::format::index_metadata_codec()) + } } pub struct ProstAny(pub Arc); From 83ae74b345c0ce92eb385ef2d0f38b191cc87e54 Mon Sep 17 00:00:00 2001 From: ccmao1130 Date: Tue, 7 Apr 2026 21:20:07 -0700 Subject: [PATCH 151/206] docs: add community sync to docs (#6434) Add verbiage for community sync to docs. Fix small typos. --- README.md | 3 ++- docs/CONTRIBUTING.md | 2 +- docs/src/community/communication.md | 12 ++++++++---- docs/src/guide/performance.md | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index edef967fb0f..2f0b2bca18f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ DocumentationCommunity • -Discord +Discord • +Mailing List [CI]: https://github.com/lance-format/lance/actions/workflows/rust.yml [CI Badge]: https://github.com/lance-format/lance/actions/workflows/rust.yml/badge.svg diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ec95d95e47f..f592df6966c 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -7,7 +7,7 @@ To build the docs, first install requirements: ```bash cd docs -uc sync --dev +uv sync --dev ``` Then build and start the docs server: diff --git a/docs/src/community/communication.md b/docs/src/community/communication.md index 1777788f345..21b7e9eba61 100644 --- a/docs/src/community/communication.md +++ b/docs/src/community/communication.md @@ -24,8 +24,12 @@ There are two mailing lists used by Lance: - [dev@lance.org](https://groups.google.com/a/lance.org/g/dev): Archive for discussions from GitHub Discussions (public) - [private@lance.org](https://groups.google.com/a/lance.org/g/private): Private discussions, security and harassment issues reporting, voting (private, PMC only) -## Community Events +## Community Sync -Check out [Lance Community Events](https://calendar.google.com/calendar/u/0?cid=Y29tbXVuaXR5QGxhbmNlLm9yZw) -for upcoming virtual or in-person events related to Lance. -The community can self-organize additional meetups as well. +We hold Lance community syncs every other Thursday at 9am PT. Everyone is welcome to attend! + +- Subscribe to the [Lance Format mailing list](https://groups.google.com/a/lance.org/g/dev) to receive the meeting invite +- Feel free to suggest discussion topics on the [meeting notes](https://docs.google.com/document/d/1cP058pJLVjj39DGaFV5yHI6qyWBwp1B2YKyIwXzYlAI/edit?tab=t.0) +- Past recordings can be found [here](https://www.youtube.com/playlist?list=PLQysAafL04jUgOj7j6mCE5fcZEBB9Amu9) + +If you have issues seeing the calendar invite, please reach out via the mailing list or the [Lance discord](https://discord.gg/lance). \ No newline at end of file diff --git a/docs/src/guide/performance.md b/docs/src/guide/performance.md index 6fab095a7cf..4934b7c49f6 100644 --- a/docs/src/guide/performance.md +++ b/docs/src/guide/performance.md @@ -64,7 +64,7 @@ debugging query performance. Lance is designed to be thread-safe and performant. Lance APIs can be called concurrently unless explicitly stated otherwise. Users may create multiple tables and share tables between threads. Operations may run in parallel on the same table, but some operations may lead to conflicts. For -details see [conflict resolution](../format/table/transaction/#conflict-resolution). +details see [conflict resolution](../format/table/transaction.md/#conflict-resolution). Most Lance operations will use multiple threads to perform work in parallel. There are two thread pools in lance: the IO thread pool and the compute thread pool. The IO thread pool is used for From 87ef5e2fce9f50cfb268cf6a4de54af26980bb54 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 8 Apr 2026 18:38:44 +0800 Subject: [PATCH 152/206] test: fix vector progress callback timing assertion (#6437) This updates the vector progress-callback test to stop treating `complete:merge_partitions` as if the full `create_index` operation has already finished. That assumption is timing-dependent because the final dataset commit happens after partition merge completes, which made the test fail on Windows while the implementation was still behaving as designed. The revised test now checks a deterministic earlier failure point: if the callback raises at `complete:shuffle`, `create_index` should propagate the error and leave the dataset without a committed index. Validation was limited to a local `python3 -m py_compile python/python/tests/test_vector_index.py` syntax check; I did not run the full Python test suite locally because building the editable extension would require a longer Rust/Python release build. --- python/python/tests/test_vector_index.py | 25 ++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/python/python/tests/test_vector_index.py b/python/python/tests/test_vector_index.py index ebaa8534abc..5371aa1f2a7 100644 --- a/python/python/tests/test_vector_index.py +++ b/python/python/tests/test_vector_index.py @@ -219,24 +219,25 @@ def test_create_index_progress_callback_vector(tmp_path): assert merge_progress[-1] == 4 -def test_create_index_progress_callback_error_after_completion_is_ignored(tmp_path): +def test_create_index_progress_callback_error_before_completion_propagates(tmp_path): ds = _make_sample_dataset_base( tmp_path, "vector_progress_post_commit_error", 1500, 128 ) - recorder = ProgressRecorder(fail_on_tag="complete:merge_partitions") + recorder = ProgressRecorder(fail_on_tag="start:train_ivf") - ds.create_index( - column="vector", - index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=4, - progress_callback=recorder, - ) + with pytest.raises(RuntimeError, match="progress callback failure"): + ds.create_index( + column="vector", + index_type="IVF_PQ", + num_partitions=4, + num_sub_vectors=4, + progress_callback=recorder, + ) tags = progress_event_tags(recorder.events) - assert tags[-1] == "complete:merge_partitions" - assert ds.has_index - assert ds.describe_indices()[0].field_names == ["vector"] + assert tags == ["start:train_ivf"] + assert not ds.has_index + assert ds.describe_indices() == [] def test_distributed_ivf_pq_partition_window_env_override(tmp_path, monkeypatch): From 5310f361e210e4f54484574006866e4a929a6c2f Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Wed, 8 Apr 2026 19:51:03 +0800 Subject: [PATCH 153/206] feat: support IVF partitions multi-split (#6423) ## Feature ### What is the new feature? This PR allows a single `optimize_indices` call on the v3 IVF incremental optimize path to split multiple oversized IVF partitions in one pass. ### Why do we need this feature? Previously, optimize could split at most one oversized partition per run. After large appends, several partitions can exceed the split threshold at the same time, which forced repeated optimize cycles to bring the index back into a healthy partition layout. ### How does it work? - `check_partition_adjustment` now collects all split candidates from the current snapshot and keeps the existing single-partition join fallback. - The multi-split path preserves existing partition ids and appends one new partition per split partition. - When any split happens, optimize continues to merge all existing delta indices in the same round, preserving the existing merge semantics. - Candidate rows from overlapping reassign partitions are resolved globally so the same row is moved at most once, choosing the best destination by distance. ## Performance Improvement ### What is the performance issue or bottleneck? The initial multi-split implementation removed the functional limitation, but overlapping reassign partitions still had avoidable overhead: - split planning was done sequentially - split plans retained full raw vector payloads longer than necessary - a reused candidate partition recomputed its baseline distance to the original centroid for every overlapping split request ### How does this PR improve performance? - split plans are now built with bounded parallelism across compute CPUs - split plans no longer retain raw partition vectors after producing the original-partition assign ops - each reused candidate partition is loaded once and computes its baseline distance once, then reuses that result across overlapping split requests - best candidate moves are updated in-place per row id instead of materializing intermediate move vectors per request ## Testing - `cargo test -p lance compute_reassign_candidate_moves_vectors_to_new_centroids` - `cargo test -p lance test_partition_split_on_append_multivec` - `cargo test -p lance test_split_multiple_partitions_in_one_optimize` - `cargo test -p lance test_join_partition_on_delete_multivec` --- rust/lance/src/index/vector/builder.rs | 717 +++++++++++++++---------- rust/lance/src/index/vector/ivf/v2.rs | 165 +++++- 2 files changed, 589 insertions(+), 293 deletions(-) diff --git a/rust/lance/src/index/vector/builder.rs b/rust/lance/src/index/vector/builder.rs index 79a41b4571b..8354f80416c 100644 --- a/rust/lance/src/index/vector/builder.rs +++ b/rust/lance/src/index/vector/builder.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::cmp::Ordering; use std::collections::HashSet; -use std::future; use std::sync::Arc; use std::{collections::HashMap, pin::Pin}; @@ -80,7 +80,7 @@ use crate::Dataset; use crate::dataset::ProjectionRequest; use crate::dataset::index::dataset_format_version; use crate::index::vector::ivf::v2::PartitionEntry; -use crate::index::vector::utils::{infer_vector_dim, infer_vector_element_type}; +use crate::index::vector::utils::infer_vector_dim; use super::v2::IVFIndex; use super::{ @@ -764,53 +764,52 @@ impl IvfIndexBuilder ) }; - let (assign_batches, merge_indices, partition_adjustment) = if num_indices_to_merge - .is_some() - || self.optimize_options.is_none() - { - no_partition_adjustment() - } else { - match Self::check_partition_adjustment(ivf, reader.as_ref(), &self.existing_indices)? { - Some(partition_adjustment) => match partition_adjustment { - PartitionAdjustment::Split(partition) => { - // Perform split and record the fact for downstream build/merge - log::info!( - "split partition {}, will merge all {} delta indices", - partition, - self.existing_indices.len() - ); - let split_results = self.split_partition(partition, ivf).await?; - let Some(ivf) = self.ivf.as_mut() else { - return Err(Error::invalid_input( - "IVF not set before building partitions", - )); - }; - ivf.centroids = Some(split_results.new_centroids); - ( - split_results.assign_batches, - Arc::new(self.existing_indices.clone()), - Some(partition_adjustment), - ) - } - PartitionAdjustment::Join(partition) => { - log::info!("join partition {}", partition); - let results = self.join_partition(partition, ivf).await?; - let Some(ivf) = self.ivf.as_mut() else { - return Err(Error::invalid_input( - "IVF model not set before joining partition", - )); - }; - ivf.centroids = Some(results.new_centroids); - ( - results.assign_batches, - Arc::new(self.existing_indices.clone()), - Some(partition_adjustment), - ) + let (assign_batches, merge_indices, partition_adjustment) = + if num_indices_to_merge.is_some() || self.optimize_options.is_none() { + no_partition_adjustment() + } else { + let (split_partitions, join_partition) = + Self::check_partition_adjustment(ivf, reader.as_ref(), &self.existing_indices)?; + if !split_partitions.is_empty() { + log::info!( + "split partitions {:?}, will merge all {} delta indices", + split_partitions, + self.existing_indices.len() + ); + let split_results = self.split_partitions(&split_partitions, ivf).await?; + let actual_split_partitions = split_results.split_partitions.clone(); + let Some(ivf) = self.ivf.as_mut() else { + return Err(Error::invalid_input( + "IVF not set before building partitions", + )); + }; + ivf.centroids = Some(split_results.new_centroids); + ( + split_results.assign_batches, + Arc::new(self.existing_indices.clone()), + Some(PartitionAdjustment::Split(actual_split_partitions)), + ) + } else { + match join_partition { + Some(partition) => { + log::info!("join partition {}", partition); + let results = self.join_partition(partition, ivf).await?; + let Some(ivf) = self.ivf.as_mut() else { + return Err(Error::invalid_input( + "IVF model not set before joining partition", + )); + }; + ivf.centroids = Some(results.new_centroids); + ( + results.assign_batches, + Arc::new(self.existing_indices.clone()), + Some(PartitionAdjustment::Join(partition)), + ) + } + None => no_partition_adjustment(), } - }, - None => no_partition_adjustment(), - } - }; + } + }; self.merged_num = merge_indices.len(); log::info!( "merge {}/{} delta indices", @@ -833,11 +832,15 @@ impl IvfIndexBuilder let sub_index_params = sub_index_params.clone(); let column = column.clone(); let frag_reuse_index = frag_reuse_index.clone(); - let skip_existing_batches = - partition_adjustment == Some(PartitionAdjustment::Split(partition)); - let partition = match partition_adjustment { + let skip_existing_batches = match &partition_adjustment { + Some(PartitionAdjustment::Split(split_partitions)) => { + split_partitions.binary_search(&partition).is_ok() + } + _ => false, + }; + let partition = match &partition_adjustment { Some(PartitionAdjustment::Join(joined_partition)) - if partition >= joined_partition => + if partition >= *joined_partition => { partition + 1 } @@ -1286,25 +1289,21 @@ impl IvfIndexBuilder ivf: &IvfModel, reader: &dyn ShuffleReader, existing_indices: &[Arc], - ) -> Result> { + ) -> Result<(Vec, Option)> { let index_type = IndexType::try_from( index_type_string(S::name().try_into()?, Q::quantization_type()).as_str(), )?; - let mut split_partition = None; + let mut split_partitions = Vec::new(); let mut join_partition = None; - let mut max_partition_size = 0; let mut min_partition_size = usize::MAX; for partition in 0..ivf.num_partitions() { let mut num_rows = reader.partition_size(partition)?; for index in existing_indices.iter() { num_rows += index.partition_size(partition); } - if num_rows > max_partition_size - && num_rows > MAX_PARTITION_SIZE_FACTOR * index_type.target_partition_size() - { - max_partition_size = num_rows; - split_partition = Some(partition); + if num_rows > MAX_PARTITION_SIZE_FACTOR * index_type.target_partition_size() { + split_partitions.push(partition); } if ivf.num_partitions() > 1 && num_rows < min_partition_size @@ -1315,44 +1314,36 @@ impl IvfIndexBuilder } } - if let Some(partition) = split_partition { - Ok(Some(PartitionAdjustment::Split(partition))) - } else if let Some(partition) = join_partition { - Ok(Some(PartitionAdjustment::Join(partition))) - } else { - Ok(None) - } + Ok((split_partitions, join_partition)) } - // split this partition, - // 1. take raw vectors by row ids in this partition - // 2. run KMeans with k=2 to get 2 new centroids - // 3. reassign the vectors to the 2 new partitions - async fn split_partition(&self, part_idx: usize, ivf: &IvfModel) -> Result { - // take the raw vectors from dataset - let Some((row_ids, vectors)) = self.load_partition_raw_vectors(part_idx).await? else { - return Ok(AssignResult { - assign_batches: vec![None; ivf.num_partitions()], - new_centroids: ivf.centroids_array().unwrap().clone(), - }); + async fn split_partitions( + &self, + split_partitions: &[usize], + ivf: &IvfModel, + ) -> Result { + let Some(dataset) = self.dataset.as_ref() else { + return Err(Error::invalid_input( + "dataset not set before split partition", + )); }; - let element_type = infer_vector_element_type(vectors.data_type())?; + let (_, element_type) = get_vector_type(dataset.schema(), &self.column)?; match element_type { DataType::Float16 => { - self.split_partition_impl::(part_idx, ivf, &row_ids, &vectors) + self.split_partitions_impl::(split_partitions, ivf) .await } DataType::Float32 => { - self.split_partition_impl::(part_idx, ivf, &row_ids, &vectors) + self.split_partitions_impl::(split_partitions, ivf) .await } DataType::Float64 => { - self.split_partition_impl::(part_idx, ivf, &row_ids, &vectors) + self.split_partitions_impl::(split_partitions, ivf) .await } DataType::UInt8 => { - self.split_partition_impl::(part_idx, ivf, &row_ids, &vectors) + self.split_partitions_impl::(split_partitions, ivf) .await } dt => Err(Error::invalid_input(format!( @@ -1362,26 +1353,101 @@ impl IvfIndexBuilder } } - async fn split_partition_impl( + async fn split_partitions_impl( &self, - part_idx: usize, + split_partitions: &[usize], ivf: &IvfModel, - row_ids: &UInt64Array, - vectors: &FixedSizeListArray, ) -> Result where T::Native: Dot + L2 + Normalize, PrimitiveArray: From>, { let centroids = ivf.centroids_array().unwrap(); - let mut new_centroids: Vec = Vec::with_capacity(ivf.num_partitions() + 1); + let mut new_centroids: Vec = + Vec::with_capacity(ivf.num_partitions() + split_partitions.len()); new_centroids.extend(centroids.iter().map(|vec| vec.unwrap())); + let split_plans = stream::iter(split_partitions.iter().copied().enumerate()) + .map(|(split_order, part_idx)| async move { + let centroid2_part_idx = ivf.num_partitions() + split_order; + self.build_split_plan::(part_idx, centroid2_part_idx, ivf) + .await + }) + .buffered(get_num_compute_intensive_cpus()) + .try_collect::>() + .await?; + let mut split_plans = split_plans.into_iter().flatten().collect::>(); + split_plans.sort_by_key(|plan| plan.part_idx); + Self::finalize_split_plans(&mut split_plans, ivf.num_partitions()); + + if split_plans.is_empty() { + return Ok(AssignResult { + assign_batches: vec![None; ivf.num_partitions()], + new_centroids: centroids.clone(), + split_partitions: Vec::new(), + }); + } + + for split_plan in &split_plans { + new_centroids[split_plan.part_idx] = split_plan.centroid1.clone(); + } + new_centroids.extend(split_plans.iter().map(|plan| plan.centroid2.clone())); + + let split_partition_set = + HashSet::::from_iter(split_plans.iter().map(|plan| plan.part_idx)); + let new_centroids = new_centroids + .iter() + .map(|vec| vec.as_ref()) + .collect::>(); + let new_centroids = arrow::compute::concat(&new_centroids)?; + let new_centroids = + FixedSizeListArray::try_new_from_values(new_centroids, centroids.value_length())?; + let mut assign_ops = vec![Vec::new(); new_centroids.len()]; + + for split_plan in &split_plans { + for (target_idx, op) in &split_plan.original_assign_ops { + assign_ops[*target_idx].push(op.clone()); + } + } + + for candidate_move in self + .collect_candidate_moves::(&split_plans, &split_partition_set, ivf) + .await? + { + assign_ops[candidate_move.source_part_idx] + .push(AssignOp::Remove(candidate_move.row_id)); + assign_ops[candidate_move.dest_part_idx].push(AssignOp::Add(( + candidate_move.row_id, + candidate_move.vector, + ))); + } + + let assign_batches = self.build_assign_batch::(&new_centroids, &assign_ops)?; + + Ok(AssignResult { + assign_batches, + new_centroids, + split_partitions: split_plans.iter().map(|plan| plan.part_idx).collect(), + }) + } + + async fn build_split_plan( + &self, + part_idx: usize, + centroid2_part_idx: usize, + ivf: &IvfModel, + ) -> Result> + where + T::Native: Dot + L2 + Normalize, + PrimitiveArray: From>, + { + let Some((row_ids, vectors)) = self.load_partition_raw_vectors(part_idx).await? else { + return Ok(None); + }; let dimension = infer_vector_dim(vectors.data_type())?; - // train kmeans to get 2 new centroids let (normalized_dist_type, normalized_vectors) = match self.distance_type { DistanceType::Cosine => { - let vectors = normalize_fsl(vectors)?; + let vectors = normalize_fsl(&vectors)?; (DistanceType::L2, vectors) } _ => (self.distance_type, vectors.clone()), @@ -1394,121 +1460,132 @@ impl IvfIndexBuilder 2, 256, )?; - // the original centroid + let c0 = ivf .centroid(part_idx) .ok_or(Error::invalid_input("original centroid not found"))?; - // the 2 new centroids - let c1 = kmeans.centroids.slice(0, dimension); - let c2 = kmeans.centroids.slice(dimension, dimension); - // replace the original centroid with the first new one - new_centroids[part_idx] = c1.clone(); - // append the second new one - new_centroids.push(c2.clone()); - let centroid1_part_idx = part_idx; - let centroid2_part_idx = new_centroids.len() - 1; - - let new_centroids = new_centroids - .iter() - .map(|vec| vec.as_ref()) - .collect::>(); - let new_centroids = arrow::compute::concat(&new_centroids)?; - - // get top REASSIGN_RANGE centroids from c0 + let centroid1 = kmeans.centroids.slice(0, dimension); + let centroid2 = kmeans.centroids.slice(dimension, dimension); let (reassign_part_ids, reassign_part_centroids) = self.select_reassign_candidates(ivf, part_idx, &c0)?; - // compute the distance between the vectors and the 3 centroids (original one and the 2 new ones) - let d0 = self.distance_type.arrow_batch_func()(&c0, vectors)?; - let d1 = self.distance_type.arrow_batch_func()(&c1, vectors)?; - let d2 = self.distance_type.arrow_batch_func()(&c2, vectors)?; - let d0 = d0.values(); - let d1 = d1.values(); - let d2 = d2.values(); - - let mut assign_ops = vec![Vec::new(); ivf.num_partitions() + 1]; - // assign the vectors in the original partition - self.assign_vectors::( + let d0 = self.distance_type.arrow_batch_func()(&c0, &vectors)?; + let d1 = self.distance_type.arrow_batch_func()(centroid1.as_ref(), &vectors)?; + let d2 = self.distance_type.arrow_batch_func()(centroid2.as_ref(), &vectors)?; + let mut original_assign_ops = Vec::with_capacity(row_ids.len()); + Self::assign_vectors_impl::( + self.distance_type, + part_idx, part_idx, - centroid1_part_idx, centroid2_part_idx, - row_ids, - vectors, - d0, - d1, - d2, + &row_ids, + &vectors, + d0.values(), + d1.values(), + d2.values(), &reassign_part_ids, &reassign_part_centroids, true, - &mut assign_ops, + |idx, op| original_assign_ops.push((idx, op)), )?; - // assign the vectors in the reassigned partitions - let reassign_targets = reassign_part_ids - .values() - .iter() - .copied() - .enumerate() - .collect::>(); - if !reassign_targets.is_empty() { - let builder = self; - let distance_type = self.distance_type; - let reassign_part_ids_clone = reassign_part_ids.clone(); - let reassign_part_centroids_clone = reassign_part_centroids.clone(); - stream::iter( - reassign_targets - .into_iter() - .map(move |(candidate_idx, part_id)| { - let builder = builder; - let reassign_part_ids = reassign_part_ids_clone.clone(); - let reassign_part_centroids = reassign_part_centroids_clone.clone(); - let centroid1 = c1.clone(); - let centroid2 = c2.clone(); - async move { - let part_idx = part_id as usize; - let Some((row_ids, vectors)) = - builder.load_partition_raw_vectors(part_idx).await? - else { - // all vectors in this partition have been deleted - return Ok::, Error>(Vec::new()); - }; - let ops = spawn_cpu(move || { - Self::compute_reassign_assign_ops::( - distance_type, - part_idx, - candidate_idx, - centroid1_part_idx, - centroid2_part_idx, - &row_ids, - &vectors, - centroid1, - centroid2, - &reassign_part_ids, - &reassign_part_centroids, - ) - }) - .await?; - Ok(ops) - } - }), - ) - .buffered(get_num_compute_intensive_cpus()) - .try_for_each(|ops| { - for (target_idx, op) in ops { - assign_ops[target_idx].push(op); + + Ok(Some(SplitPlan { + part_idx, + centroid2_part_idx, + centroid1, + centroid2, + reassign_part_ids, + original_assign_ops, + })) + } + + async fn collect_candidate_moves( + &self, + split_plans: &[SplitPlan], + split_partition_set: &HashSet, + ivf: &IvfModel, + ) -> Result> + where + T::Native: Dot + L2 + Normalize, + PrimitiveArray: From>, + { + let mut candidate_partitions = HashMap::>::new(); + for split_plan in split_plans { + for part_id in split_plan.reassign_part_ids.values().iter().copied() { + let part_idx = part_id as usize; + if split_partition_set.contains(&part_idx) { + continue; + } + candidate_partitions + .entry(part_idx) + .or_default() + .push(CandidateRequest { + centroid1_part_idx: split_plan.part_idx, + centroid2_part_idx: split_plan.centroid2_part_idx, + centroid1: split_plan.centroid1.clone(), + centroid2: split_plan.centroid2.clone(), + }); + } + } + + let candidate_moves = stream::iter(candidate_partitions.into_iter()) + .map(|(part_idx, requests)| async move { + let Some((row_ids, vectors)) = self.load_partition_raw_vectors(part_idx).await? + else { + return Ok::, Error>(Vec::new()); + }; + + let candidate_centroid = ivf.centroid(part_idx).ok_or(Error::invalid_input( + format!("candidate centroid not found for partition {part_idx}"), + ))?; + let baseline_dists = + self.distance_type.arrow_batch_func()(candidate_centroid.as_ref(), &vectors)?; + let mut best_moves = vec![None; row_ids.len()]; + for request in requests { + let d1 = self.distance_type.arrow_batch_func()( + request.centroid1.as_ref(), + &vectors, + )?; + let d2 = self.distance_type.arrow_batch_func()( + request.centroid2.as_ref(), + &vectors, + )?; + Self::update_best_candidate_moves::( + request.centroid1_part_idx, + request.centroid2_part_idx, + &row_ids, + &vectors, + baseline_dists.values(), + d1.values(), + d2.values(), + &mut best_moves, + part_idx, + ); } - future::ready(Ok(())) + + Ok(best_moves.into_iter().flatten().collect::>()) }) + .buffered(get_num_compute_intensive_cpus()) + .try_collect::>() .await?; - } - let new_centroids = - FixedSizeListArray::try_new_from_values(new_centroids, dimension as i32)?; - let assign_batches = self.build_assign_batch::(&new_centroids, &assign_ops)?; + Ok(candidate_moves.into_iter().flatten().collect()) + } - Ok(AssignResult { - assign_batches, - new_centroids, - }) + fn finalize_split_plans(split_plans: &mut [SplitPlan], base_num_partitions: usize) { + for (split_order, split_plan) in split_plans.iter_mut().enumerate() { + let actual_centroid2_part_idx = base_num_partitions + split_order; + if split_plan.centroid2_part_idx == actual_centroid2_part_idx { + continue; + } + let placeholder_centroid2_part_idx = split_plan.centroid2_part_idx; + for (target_idx, _) in &mut split_plan.original_assign_ops { + if *target_idx == placeholder_centroid2_part_idx { + *target_idx = actual_centroid2_part_idx; + } + } + split_plan.centroid2_part_idx = actual_centroid2_part_idx; + } } // join the given partition: @@ -1537,6 +1614,7 @@ impl IvfIndexBuilder return Ok(AssignResult { assign_batches: vec![None; ivf.num_partitions() - 1], new_centroids, + split_partitions: Vec::new(), }); }; @@ -1640,6 +1718,7 @@ impl IvfIndexBuilder Ok(AssignResult { assign_batches, new_centroids, + split_partitions: Vec::new(), }) } @@ -1808,42 +1887,6 @@ impl IvfIndexBuilder ) -> Result<(UInt32Array, FixedSizeListArray)> { select_reassign_candidates_impl(self.distance_type, ivf, part_idx, c0) } - // assign the vectors of original partition - #[allow(clippy::too_many_arguments)] - fn assign_vectors( - &self, - part_idx: usize, - centroid1_part_idx: usize, - centroid2_part_idx: usize, - row_ids: &UInt64Array, - vectors: &FixedSizeListArray, - d0: &[f32], - d1: &[f32], - d2: &[f32], - reassign_part_ids: &UInt32Array, - reassign_part_centroids: &FixedSizeListArray, - // the assign ops for each partition - // the length must be `old_num_partitions + 1` - deleted_original_partition: bool, - assign_ops: &mut [Vec], - ) -> Result<()> { - Self::assign_vectors_impl::( - self.distance_type, - part_idx, - centroid1_part_idx, - centroid2_part_idx, - row_ids, - vectors, - d0, - d1, - d2, - reassign_part_ids, - reassign_part_centroids, - deleted_original_partition, - |idx, op| assign_ops[idx].push(op), - ) - } - #[allow(clippy::too_many_arguments)] fn assign_vectors_impl( distance_type: DistanceType, @@ -1916,50 +1959,54 @@ impl IvfIndexBuilder } #[allow(clippy::too_many_arguments)] - fn compute_reassign_assign_ops( - distance_type: DistanceType, - part_idx: usize, - candidate_idx: usize, + fn update_best_candidate_moves( centroid1_part_idx: usize, centroid2_part_idx: usize, row_ids: &UInt64Array, vectors: &FixedSizeListArray, - centroid1: ArrayRef, - centroid2: ArrayRef, - reassign_part_ids: &UInt32Array, - reassign_part_centroids: &FixedSizeListArray, - ) -> Result> - where + baseline_dists: &[f32], + centroid1_dists: &[f32], + centroid2_dists: &[f32], + best_moves: &mut [Option], + part_idx: usize, + ) where T::Native: Dot + L2 + Normalize, PrimitiveArray: From>, { - let d0 = distance_type.arrow_batch_func()( - reassign_part_centroids.value(candidate_idx).as_ref(), - vectors, - )?; - let d1 = distance_type.arrow_batch_func()(centroid1.as_ref(), vectors)?; - let d2 = distance_type.arrow_batch_func()(centroid2.as_ref(), vectors)?; - let d0 = d0.values(); - let d1 = d1.values(); - let d2 = d2.values(); + for (i, &row_id) in row_ids.values().iter().enumerate() { + if baseline_dists[i] <= centroid1_dists[i] && baseline_dists[i] <= centroid2_dists[i] { + continue; + } + let (dest_part_idx, dest_distance) = if centroid1_dists[i] <= centroid2_dists[i] { + (centroid1_part_idx, centroid1_dists[i]) + } else { + (centroid2_part_idx, centroid2_dists[i]) + }; + let candidate_move = CandidateMove { + row_id, + source_part_idx: part_idx, + dest_part_idx, + dest_distance, + vector: vectors.value(i), + }; + match best_moves[i].as_mut() { + Some(best_move) if Self::is_better_candidate_move(&candidate_move, best_move) => { + *best_move = candidate_move; + } + None => { + best_moves[i] = Some(candidate_move); + } + _ => {} + } + } + } - let mut ops = Vec::new(); - Self::assign_vectors_impl::( - distance_type, - part_idx, - centroid1_part_idx, - centroid2_part_idx, - row_ids, - vectors, - d0, - d1, - d2, - reassign_part_ids, - reassign_part_centroids, - false, - |idx, op| ops.push((idx, op)), - )?; - Ok(ops) + fn is_better_candidate_move(candidate: &CandidateMove, current: &CandidateMove) -> bool { + match candidate.dest_distance.total_cmp(¤t.dest_distance) { + Ordering::Less => true, + Ordering::Equal => candidate.dest_part_idx < current.dest_part_idx, + Ordering::Greater => false, + } } // assign a vector to the closest partition among: @@ -2046,6 +2093,7 @@ struct AssignResult { // and the deleted row ids assign_batches: Vec>, new_centroids: FixedSizeListArray, + split_partitions: Vec, } #[derive(Debug, Clone)] @@ -2064,14 +2112,39 @@ enum ReassignPartition { ReassignCandidate(u32), } -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum PartitionAdjustment { - /// Split partition at given id - Split(usize), + /// Split partitions at the given ids. + Split(Vec), /// Join partition at given id Join(usize), } +struct SplitPlan { + part_idx: usize, + centroid2_part_idx: usize, + centroid1: ArrayRef, + centroid2: ArrayRef, + reassign_part_ids: UInt32Array, + original_assign_ops: Vec<(usize, AssignOp)>, +} + +struct CandidateRequest { + centroid1_part_idx: usize, + centroid2_part_idx: usize, + centroid1: ArrayRef, + centroid2: ArrayRef, +} + +#[derive(Clone)] +struct CandidateMove { + row_id: u64, + source_part_idx: usize, + dest_part_idx: usize, + dest_distance: f32, + vector: ArrayRef, +} + pub(crate) fn index_type_string(sub_index: SubIndexType, quantizer: QuantizationType) -> String { // FlatBin is a QuantizationType variant used internally for reconstruction, // but the persisted index type string uses "FLAT" (differentiated by DataType). @@ -2166,49 +2239,115 @@ mod tests { } #[test] - fn compute_reassign_assign_ops_moves_vectors_to_new_centroids() { + fn compute_reassign_candidate_moves_vectors_to_new_centroids() { let row_ids = UInt64Array::from(vec![1_u64, 2_u64]); let vectors = FixedSizeListArray::try_new_from_values( Float32Array::from(vec![0.0_f32, 0.0, 10.0, 10.0]), 2, ) .unwrap(); - let reassign_part_ids = UInt32Array::from(vec![0_u32]); let reassign_part_centroids = FixedSizeListArray::try_new_from_values(Float32Array::from(vec![9.0_f32, 9.0]), 2) .unwrap(); - let centroid1: ArrayRef = Arc::new(Float32Array::from(vec![0.0_f32, 0.0])); - let centroid2: ArrayRef = Arc::new(Float32Array::from(vec![20.0_f32, 20.0])); + let baseline_dists = DistanceType::L2.arrow_batch_func()( + reassign_part_centroids.value(0).as_ref(), + &vectors, + ) + .unwrap(); + let centroid1_dists = + DistanceType::L2.arrow_batch_func()(&Float32Array::from(vec![0.0_f32, 0.0]), &vectors) + .unwrap(); + let centroid2_dists = DistanceType::L2.arrow_batch_func()( + &Float32Array::from(vec![20.0_f32, 20.0]), + &vectors, + ) + .unwrap(); - let ops = IvfIndexBuilder::::compute_reassign_assign_ops::< - Float32Type, - >( - DistanceType::L2, - 0, - 0, + let mut best_moves = vec![None; row_ids.len()]; + IvfIndexBuilder::::update_best_candidate_moves::( 1, 2, &row_ids, &vectors, - centroid1, - centroid2, - &reassign_part_ids, - &reassign_part_centroids, + baseline_dists.values(), + centroid1_dists.values(), + centroid2_dists.values(), + &mut best_moves, + 0, + ); + + assert_eq!(best_moves.iter().flatten().count(), 1); + let candidate_move = best_moves[0].as_ref().unwrap(); + assert_eq!(candidate_move.row_id, 1); + assert_eq!(candidate_move.source_part_idx, 0); + assert_eq!(candidate_move.dest_part_idx, 1); + assert_eq!( + candidate_move.vector.as_primitive::().values(), + &[0.0_f32, 0.0] + ); + } + + #[test] + fn update_best_candidate_moves_preserves_multivector_entries() { + let row_ids = UInt64Array::from(vec![7_u64, 7_u64]); + let vectors = FixedSizeListArray::try_new_from_values( + Float32Array::from(vec![0.0_f32, 0.0, 1.0, 1.0]), + 2, ) .unwrap(); + let baseline_dists = Float32Array::from(vec![10.0_f32, 10.0]); + let centroid1_dists = Float32Array::from(vec![1.0_f32, 2.0]); + let centroid2_dists = Float32Array::from(vec![3.0_f32, 4.0]); - assert_eq!(ops.len(), 2); - assert!(matches!(ops[0], (0, AssignOp::Remove(1)))); - match &ops[1] { - (1, AssignOp::Add((row_id, vector))) => { - assert_eq!(*row_id, 1); - assert_eq!( - vector.as_primitive::().values(), - &[0.0_f32, 0.0] - ); - } - other => panic!("unexpected op: {:?}", other), - } + let mut best_moves = vec![None; row_ids.len()]; + IvfIndexBuilder::::update_best_candidate_moves::( + 1, + 2, + &row_ids, + &vectors, + baseline_dists.values(), + centroid1_dists.values(), + centroid2_dists.values(), + &mut best_moves, + 0, + ); + + let best_moves = best_moves.into_iter().flatten().collect::>(); + assert_eq!(best_moves.len(), 2); + assert_eq!(best_moves[0].row_id, 7); + assert_eq!(best_moves[1].row_id, 7); + assert_eq!( + best_moves[0].vector.as_primitive::().values(), + &[0.0_f32, 0.0] + ); + assert_eq!( + best_moves[1].vector.as_primitive::().values(), + &[1.0_f32, 1.0] + ); + } + + #[test] + fn finalize_split_plans_reassigns_filtered_centroid_ids() { + let centroid1: ArrayRef = Arc::new(Float32Array::from(vec![0.0_f32, 0.0])); + let centroid2: ArrayRef = Arc::new(Float32Array::from(vec![1.0_f32, 1.0])); + let vector: ArrayRef = Arc::new(Float32Array::from(vec![2.0_f32, 2.0])); + let mut split_plans = vec![SplitPlan { + part_idx: 3, + centroid2_part_idx: 5, + centroid1, + centroid2, + reassign_part_ids: UInt32Array::from(vec![0_u32]), + original_assign_ops: vec![ + (3, AssignOp::Add((10, vector.clone()))), + (5, AssignOp::Add((11, vector))), + ], + }]; + + IvfIndexBuilder::::finalize_split_plans(&mut split_plans, 4); + + assert_eq!(split_plans[0].centroid2_part_idx, 4); + assert_eq!(split_plans[0].original_assign_ops[0].0, 3); + assert_eq!(split_plans[0].original_assign_ops[1].0, 4); } #[tokio::test] diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 595e5fb530f..4ccb65f086a 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -1652,10 +1652,9 @@ mod tests { let dataset = Dataset::open(test_uri).await.unwrap(); let final_ctx = load_vector_index_context(&dataset, "vector", INDEX_NAME).await; - assert_eq!( - final_ctx.num_partitions(), - 3, - "Expected partition split to increase partitions from 2 to 3 for {}, got stats: {}", + assert!( + final_ctx.num_partitions() >= 3, + "Expected partition split to increase partitions beyond 2 for {}, got stats: {}", description, final_ctx.stats_json() ); @@ -1732,6 +1731,51 @@ mod tests { append_constant_vector_with_params(dataset, rows, template, None).await; } + async fn append_partition_templates( + dataset: &mut Dataset, + rows_per_template: usize, + templates: &[Vec], + ) { + assert!( + !templates.is_empty(), + "at least one template is required for append" + ); + for template in templates { + assert_eq!( + template.len(), + DIM, + "Template vector should have {} dimensions", + DIM + ); + } + + let start_id = dataset.count_all_rows().await.unwrap() as u64; + let total_rows = rows_per_template * templates.len(); + let ids = Arc::new(UInt64Array::from_iter_values( + start_id..start_id + total_rows as u64, + )); + let mut appended_values = Vec::with_capacity(total_rows * DIM); + for template in templates { + for _ in 0..rows_per_template { + appended_values.extend_from_slice(template); + } + } + let vectors = Arc::new( + FixedSizeListArray::try_new_from_values( + Float32Array::from(appended_values), + DIM as i32, + ) + .unwrap(), + ); + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::UInt64, false), + Field::new("vector", vectors.data_type().clone(), false), + ])); + let batch = RecordBatch::try_new(schema.clone(), vec![ids, vectors]).unwrap(); + let batches = RecordBatchIterator::new(vec![Ok(batch)], schema); + dataset.append(batches, None).await.unwrap(); + } + async fn append_constant_vector_with_params( dataset: &mut Dataset, rows: usize, @@ -4598,6 +4642,119 @@ mod tests { verify_partition_split_after_append(dataset, test_uri, params, "multivector data").await; } + #[tokio::test] + async fn test_split_multiple_partitions_in_one_optimize() { + const INDEX_NAME: &str = "vector_idx"; + const BASE_ROWS_PER_PARTITION: usize = 512; + const APPEND_ROWS_PER_PARTITION: usize = 40_000; + let offsets = [-50.0, 50.0]; + + let test_dir = TempStrDir::default(); + let test_uri = test_dir.as_str(); + + let (batch, schema) = generate_clustered_batch(BASE_ROWS_PER_PARTITION, offsets); + let batches = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); + let mut dataset = Dataset::write( + batches, + test_uri, + Some(WriteParams { + mode: WriteMode::Overwrite, + ..Default::default() + }), + ) + .await + .unwrap(); + + let centroids = build_centroids_for_offsets(&offsets); + let ivf_params = IvfBuildParams::try_with_centroids(2, centroids).unwrap(); + let params = VectorIndexParams::with_ivf_pq_params( + DistanceType::L2, + ivf_params, + PQBuildParams::default(), + ); + dataset + .create_index( + &["vector"], + IndexType::Vector, + Some(INDEX_NAME.to_string()), + ¶ms, + true, + ) + .await + .unwrap(); + + let initial_ctx = load_vector_index_context(&dataset, "vector", INDEX_NAME).await; + assert_eq!(initial_ctx.num_partitions(), 2); + let mut templates = Vec::with_capacity(2); + for partition_idx in 0..2 { + let row_ids = load_partition_row_ids(initial_ctx.ivf(), partition_idx).await; + let template_batch = dataset + .take_rows(&[row_ids[0]], dataset.schema().clone()) + .await + .unwrap(); + templates.push( + template_batch["vector"] + .as_fixed_size_list() + .value(0) + .as_primitive::() + .values() + .to_vec(), + ); + } + + append_partition_templates(&mut dataset, APPEND_ROWS_PER_PARTITION, &templates).await; + + dataset + .optimize_indices(&OptimizeOptions::new()) + .await + .unwrap(); + dataset.validate().await.unwrap(); + + let final_ctx = load_vector_index_context(&dataset, "vector", INDEX_NAME).await; + assert_eq!( + final_ctx.num_partitions(), + 4, + "Expected both original partitions to split in one optimize, stats: {}", + final_ctx.stats_json() + ); + + let indices = final_ctx.stats()["indices"] + .as_array() + .expect("indices should be present"); + assert_eq!( + indices.len(), + 1, + "Expected split optimize to merge into one index, stats: {}", + final_ctx.stats_json() + ); + + let partitions = indices[0]["partitions"] + .as_array() + .expect("partitions should be present"); + assert_eq!(partitions.len(), 4); + let expected_rows = 2 * BASE_ROWS_PER_PARTITION + 2 * APPEND_ROWS_PER_PARTITION; + let total_partition_rows = partitions + .iter() + .map(|part| part["size"].as_u64().unwrap() as usize) + .sum::(); + assert_eq!(total_partition_rows, expected_rows); + assert_eq!(dataset.count_all_rows().await.unwrap(), expected_rows); + + let nearest = dataset + .scan() + .with_row_id() + .nearest("vector", &Float32Array::from(templates[0].clone()), 10) + .unwrap() + .try_into_batch() + .await + .unwrap(); + let ids = nearest[ROW_ID].as_primitive::(); + let mut seen = HashSet::new(); + for row_id in ids.values() { + assert!(seen.insert(*row_id), "Duplicate row id found: {}", row_id); + } + } + #[tokio::test] async fn test_join_partition_on_delete_multivec() { // This test verifies that IVF index with multivector data handles deletions From 40d09bf805b2810c89fe4ff61930a76ba1f25d53 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Wed, 8 Apr 2026 07:11:47 -0700 Subject: [PATCH 154/206] feat: add batch_size_bytes to encoding decode stream (#6388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This adds some initial framework for #6387 . It adds the `batch_size_bytes` option which will be wired up in a future PR. It provides a basic implementation that uses a guess-and-check strategy to try and figure out how many rows to use. This is inexact and some batches it emits will be too large. Future PRs will add accurate sizing from the decode layers to hopefully avoid the need for guessing entirely. Still, this is enough to get the feature wired up and then we can improve it later. - Add `batch_size_bytes: Option` to `SchedulerDecoderConfig` and thread it through the structural v2.1 decode path - When set, compute rows-per-batch from byte estimates instead of the fixed `batch_size` row count - After each batch decodes, measure actual bytes-per-row and feed it back so subsequent batches converge toward the target byte size - Feedback degrades gradually (midpoint) when actual size is smaller than the estimate; adopts immediately when larger to avoid OOM - Only the v2.1+ `StructuralBatchDecodeStream` is modified; v2.0 `BatchDecodeStream` is unchanged (logs a warning if the option is set) - Wiring this through to the file reader and Python/Java bindings will be done in a follow-up PR ## Test plan - [x] `test_estimate_bytes_per_row` — unit test for the schema-based byte estimator - [x] `test_byte_sized_batches_fixed_width` — 1000 rows × 4 Int32 columns, `batch_size_bytes=1600` → 10 batches of exactly 100 rows, roundtrip verified - [x] `test_byte_sized_batches_none_unchanged` — `batch_size_bytes=None` still uses `rows_per_batch` (no behavioral change) - [x] `test_byte_sized_batches_feedback_convergence` — 100-byte strings with 64-byte schema estimate; verifies second/third batches converge to ~50 rows after feedback - [x] `cargo clippy -p lance-encoding --tests -p lance-file -- -D warnings` clean - [x] `cargo fmt --all -- --check` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- rust/lance-encoding/benches/decoder.rs | 1 + rust/lance-encoding/src/decoder.rs | 333 ++++++++++++++++++++++++- rust/lance-encoding/src/testing.rs | 1 + rust/lance-file/src/reader.rs | 6 + 4 files changed, 339 insertions(+), 2 deletions(-) diff --git a/rust/lance-encoding/benches/decoder.rs b/rust/lance-encoding/benches/decoder.rs index 0a444eaa9da..efabee87be6 100644 --- a/rust/lance-encoding/benches/decoder.rs +++ b/rust/lance-encoding/benches/decoder.rs @@ -541,6 +541,7 @@ fn bench_decode_compressed_parallel(c: &mut Criterion) { false, false, rx, + None, ) .unwrap(); diff --git a/rust/lance-encoding/src/decoder.rs b/rust/lance-encoding/src/decoder.rs index aa1bdaed777..41e20688037 100644 --- a/rust/lance-encoding/src/decoder.rs +++ b/rust/lance-encoding/src/decoder.rs @@ -213,6 +213,7 @@ //! relation to the way the data is stored. use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{LazyLock, Once, OnceLock}; use std::{ops::Range, sync::Arc}; @@ -1686,6 +1687,39 @@ impl RecordBatchReader for BatchDecodeIterator { } } +/// Estimate the number of bytes per row for a given Arrow data type. +/// +/// For fixed-width types this is exact. For variable-width types (strings, +/// binary, lists) a rough default is used. The estimate is used as a +/// starting point when `batch_size_bytes` is set; a post-decode feedback +/// loop corrects it after the first batch. +/// +/// This estimate ignores validity bitmaps at the moment. We can't infer +/// their presence simply from the data_type and their impact is probably +/// fairly negligible. +fn estimate_bytes_per_row(data_type: &DataType) -> f64 { + if let Some(w) = data_type.byte_width_opt() { + return w as f64; + } + match data_type { + DataType::Boolean => 1.0 / 8.0, + DataType::Utf8 | DataType::Binary | DataType::LargeUtf8 | DataType::LargeBinary => 64.0, + DataType::Struct(fields) => fields + .iter() + .map(|f| estimate_bytes_per_row(f.data_type())) + .sum(), + DataType::List(child) | DataType::LargeList(child) => { + 5.0 * estimate_bytes_per_row(child.data_type()) + } + DataType::FixedSizeList(child, dim) => { + *dim as f64 * estimate_bytes_per_row(child.data_type()) + } + DataType::Dictionary(_, value_type) => estimate_bytes_per_row(value_type), + DataType::Map(entries, _) => 5.0 * estimate_bytes_per_row(entries.data_type()), + _ => 64.0, + } +} + /// A stream that takes scheduled jobs and generates decode tasks from them. pub struct StructuralBatchDecodeStream { context: DecoderContext, @@ -1704,6 +1738,14 @@ pub struct StructuralBatchDecodeStream { // - false: run `into_batch` inline, which avoids Tokio scheduling overhead and is // typically better for point lookups / small takes. spawn_batch_decode_tasks: bool, + /// If set, target this many bytes per batch instead of `rows_per_batch` rows. + batch_size_bytes: Option, + /// Schema-based estimate of bytes per row, computed once at construction. + /// Only meaningful when `batch_size_bytes` is `Some`. + schema_bytes_per_row: f64, + /// Post-decode feedback: actual bytes-per-row measured from the most + /// recently decoded batch. Zero means no feedback yet (use schema estimate). + bytes_per_row_feedback: Arc, } impl StructuralBatchDecodeStream { @@ -1722,7 +1764,13 @@ impl StructuralBatchDecodeStream { num_rows: u64, root_decoder: StructuralStructDecoder, spawn_batch_decode_tasks: bool, + batch_size_bytes: Option, ) -> Self { + let schema_bytes_per_row = if batch_size_bytes.is_some() { + estimate_bytes_per_row(root_decoder.data_type()).max(1.0) + } else { + 0.0 + }; Self { context: DecoderContext::new(scheduled), root_decoder, @@ -1733,6 +1781,9 @@ impl StructuralBatchDecodeStream { scheduler_exhausted: false, emitted_batch_size_warning: Arc::new(Once::new()), spawn_batch_decode_tasks, + batch_size_bytes, + schema_bytes_per_row, + bytes_per_row_feedback: Arc::new(AtomicU64::new(0)), } } @@ -1775,7 +1826,18 @@ impl StructuralBatchDecodeStream { return Ok(None); } - let mut to_take = self.rows_remaining.min(self.rows_per_batch as u64); + let mut to_take = if let Some(batch_size_bytes) = self.batch_size_bytes { + let feedback = self.bytes_per_row_feedback.load(Ordering::Relaxed); + let bpr = if feedback > 0 { + feedback as f64 + } else { + self.schema_bytes_per_row + }; + let rows = (batch_size_bytes as f64 / bpr) as u64; + self.rows_remaining.min(rows.max(1)) + } else { + self.rows_remaining.min(self.rows_per_batch as u64) + }; self.rows_remaining -= to_take; let scheduled_need = (self.rows_drained + to_take).saturating_sub(self.rows_scheduled); @@ -1811,12 +1873,13 @@ impl StructuralBatchDecodeStream { let next_task = next_task.transpose().map(|next_task| { let num_rows = next_task.as_ref().map(|t| t.num_rows).unwrap_or(0); let emitted_batch_size_warning = slf.emitted_batch_size_warning.clone(); + let bytes_per_row_feedback = slf.bytes_per_row_feedback.clone(); // Capture the per-stream policy once so every emitted batch task follows the // same throughput-vs-overhead choice made by the scheduler. let spawn_batch_decode_tasks = slf.spawn_batch_decode_tasks; let task = async move { let next_task = next_task?; - let (batch, _data_size) = if spawn_batch_decode_tasks { + let (batch, data_size) = if spawn_batch_decode_tasks { tokio::spawn( async move { next_task.into_batch(emitted_batch_size_warning) }, ) @@ -1825,6 +1888,22 @@ impl StructuralBatchDecodeStream { } else { next_task.into_batch(emitted_batch_size_warning)? }; + let num_rows = batch.num_rows() as u64; + if num_rows > 0 { + let bpr = data_size / num_rows; + let prev = bytes_per_row_feedback.load(Ordering::Relaxed); + let next = if prev == 0 || bpr >= prev { + // First batch or actual size is larger than estimate: + // adopt immediately to avoid OOM. + bpr + } else { + // Actual size is smaller: degrade gradually toward + // the true value to avoid over-correcting on a + // single anomalous batch. + (prev + bpr) / 2 + }; + bytes_per_row_feedback.store(next.max(1), Ordering::Relaxed); + } Ok(batch) }; (task, num_rows) @@ -1896,6 +1975,11 @@ pub struct SchedulerDecoderConfig { pub cache: Arc, /// Decoder configuration pub decoder_config: DecoderConfig, + /// If set, target this many bytes per batch instead of using `batch_size` rows. + /// + /// Only supported for v2.1+ (structural) files. For v2.0 files this + /// option is ignored and a warning is logged. + pub batch_size_bytes: Option, } fn check_scheduler_on_drop( @@ -1931,6 +2015,7 @@ fn check_scheduler_on_drop( .boxed() } +#[allow(clippy::too_many_arguments)] pub fn create_decode_stream( schema: &Schema, num_rows: u64, @@ -1939,6 +2024,7 @@ pub fn create_decode_stream( should_validate: bool, spawn_structural_batch_decode_tasks: bool, rx: mpsc::UnboundedReceiver>, + batch_size_bytes: Option, ) -> Result> { if is_structural { let arrow_schema = ArrowSchema::from(schema); @@ -1953,9 +2039,13 @@ pub fn create_decode_stream( num_rows, structural_decoder, spawn_structural_batch_decode_tasks, + batch_size_bytes, ) .into_stream()) } else { + if batch_size_bytes.is_some() { + warn!("batch_size_bytes is not supported for v2.0 files and will be ignored"); + } let arrow_schema = ArrowSchema::from(schema); let root_fields = arrow_schema.fields; @@ -2027,6 +2117,7 @@ fn create_scheduler_decoder( config.decoder_config.validate_on_decode, spawn_structural_batch_decode_tasks, rx, + config.batch_size_bytes, )?; let scheduler_handle = tokio::task::spawn(async move { @@ -2729,6 +2820,7 @@ pub async fn decode_batch( should_validate, spawn_structural_batch_decode_tasks, rx, + None, )?; decode_stream.next().await.unwrap().task.await } @@ -2758,4 +2850,241 @@ mod tests { let ranges = DecodeBatchScheduler::indices_to_ranges(&indices); assert_eq!(ranges, vec![1..4, 5..8, 9..10]); } + + #[test] + fn test_estimate_bytes_per_row() { + assert_eq!(estimate_bytes_per_row(&DataType::Int32), 4.0); + assert_eq!(estimate_bytes_per_row(&DataType::Int64), 8.0); + assert_eq!(estimate_bytes_per_row(&DataType::Float32), 4.0); + assert_eq!(estimate_bytes_per_row(&DataType::Boolean), 1.0 / 8.0); + assert_eq!(estimate_bytes_per_row(&DataType::Utf8), 64.0); + assert_eq!(estimate_bytes_per_row(&DataType::Binary), 64.0); + // Struct of 4 x Int32 = 16 bytes + let struct_type = DataType::Struct(Fields::from(vec![ + ArrowField::new("a", DataType::Int32, false), + ArrowField::new("b", DataType::Int32, false), + ArrowField::new("c", DataType::Int32, false), + ArrowField::new("d", DataType::Int32, false), + ])); + assert_eq!(estimate_bytes_per_row(&struct_type), 16.0); + } + + /// Helper: encode a batch, then decode it as a stream with optional + /// `batch_size_bytes`, collecting all output batches. + async fn decode_batches_with_byte_limit( + batch: &RecordBatch, + batch_size: u32, + batch_size_bytes: Option, + ) -> Vec { + use crate::encoder::{EncodingOptions, default_encoding_strategy, encode_batch}; + use crate::version::LanceFileVersion; + + let version = LanceFileVersion::V2_1; + let options = EncodingOptions { + version, + ..Default::default() + }; + let strategy = default_encoding_strategy(version); + let schema = Schema::try_from(batch.schema().as_ref()).unwrap(); + let encoded = encode_batch(batch, Arc::new(schema.clone()), strategy.as_ref(), &options) + .await + .unwrap(); + + let io_scheduler = + Arc::new(BufferScheduler::new(encoded.data.clone())) as Arc; + let cache = Arc::new(lance_core::cache::LanceCache::with_capacity( + 128 * 1024 * 1024, + )); + let decoder_plugins = Arc::new(DecoderPlugins::default()); + + let mut decode_scheduler = DecodeBatchScheduler::try_new( + encoded.schema.as_ref(), + &encoded.top_level_columns, + &encoded.page_table, + &vec![], + encoded.num_rows, + decoder_plugins, + io_scheduler.clone(), + cache, + &FilterExpression::no_filter(), + &DecoderConfig::default(), + ) + .await + .unwrap(); + + let (tx, rx) = unbounded_channel(); + decode_scheduler.schedule_range( + 0..encoded.num_rows, + &FilterExpression::no_filter(), + tx, + io_scheduler, + ); + + let mut decode_stream = create_decode_stream( + &encoded.schema, + encoded.num_rows, + batch_size, + /*is_structural=*/ true, + /*should_validate=*/ true, + /*spawn_structural_batch_decode_tasks=*/ true, + rx, + batch_size_bytes, + ) + .unwrap(); + + let mut batches = Vec::new(); + while let Some(task) = decode_stream.next().await { + batches.push(task.task.await.unwrap()); + } + batches + } + + #[tokio::test] + async fn test_byte_sized_batches_fixed_width() { + use arrow_array::Int32Array; + + // 1000 rows x 4 Int32 columns = 16 bytes/row + let num_rows: i32 = 1000; + let arrays: Vec> = (0..4) + .map(|col| { + Arc::new(Int32Array::from_iter_values( + (0..num_rows).map(move |row| row * 10 + col), + )) as _ + }) + .collect(); + + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("a", DataType::Int32, false), + ArrowField::new("b", DataType::Int32, false), + ArrowField::new("c", DataType::Int32, false), + ArrowField::new("d", DataType::Int32, false), + ])); + let input_batch = RecordBatch::try_new(schema, arrays).unwrap(); + + // 16 bytes/row, batch_size_bytes=1600 => 100 rows/batch + let batches = + decode_batches_with_byte_limit(&input_batch, /*batch_size=*/ 1024, Some(1600)).await; + + // Should produce 10 batches of 100 rows each + assert_eq!(batches.len(), 10); + for (i, batch) in batches.iter().enumerate() { + assert_eq!( + batch.num_rows(), + 100, + "batch {i} should have 100 rows, got {}", + batch.num_rows() + ); + } + + // Verify roundtrip: concatenate and compare + let all_batches: Vec<&RecordBatch> = batches.iter().collect(); + let concatenated = + arrow_select::concat::concat_batches(&batches[0].schema(), all_batches.iter().copied()) + .unwrap(); + assert_eq!(concatenated.num_rows(), num_rows as usize); + for col in 0..4 { + assert_eq!( + concatenated.column(col).as_ref(), + input_batch.column(col).as_ref(), + "column {col} roundtrip mismatch" + ); + } + } + + #[tokio::test] + async fn test_byte_sized_batches_none_unchanged() { + use arrow_array::Int32Array; + + // Without batch_size_bytes, rows_per_batch controls batching + let num_rows: i32 = 1000; + let arrays: Vec> = (0..2) + .map(|col| { + Arc::new(Int32Array::from_iter_values( + (0..num_rows).map(move |row| row * 10 + col), + )) as _ + }) + .collect(); + + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("x", DataType::Int32, false), + ArrowField::new("y", DataType::Int32, false), + ])); + let input_batch = RecordBatch::try_new(schema, arrays).unwrap(); + + // batch_size=250, batch_size_bytes=None => 4 batches of 250 rows + let batches = decode_batches_with_byte_limit(&input_batch, /*batch_size=*/ 250, None).await; + assert_eq!(batches.len(), 4); + for (i, batch) in batches.iter().enumerate() { + assert_eq!( + batch.num_rows(), + 250, + "batch {i} should have 250 rows, got {}", + batch.num_rows() + ); + } + } + + #[tokio::test] + async fn test_byte_sized_batches_feedback_convergence() { + use arrow_array::StringArray; + + // Each row has a 100-byte string. Schema estimate = 64 bytes (default + // for Utf8), so the first batch will overshoot. The feedback loop + // should correct subsequent batches toward the target. + let num_rows = 500; + let value: String = "x".repeat(100); + let arrays: Vec> = vec![Arc::new(StringArray::from( + (0..num_rows).map(|_| value.as_str()).collect::>(), + ))]; + let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new( + "s", + DataType::Utf8, + false, + )])); + let input_batch = RecordBatch::try_new(schema, arrays).unwrap(); + + // Target 5000 bytes/batch. At 100 bytes/row the ideal is 50 rows/batch. + // Schema estimate is 64 bytes/row → first batch ~78 rows (overshoot). + // After feedback kicks in, batches should converge to ~50 rows. + let target_bytes: u64 = 5000; + let batches = decode_batches_with_byte_limit( + &input_batch, + /*batch_size=*/ 1024, + Some(target_bytes), + ) + .await; + + // Verify all data round-trips correctly + let all_batches: Vec<&RecordBatch> = batches.iter().collect(); + let concatenated = + arrow_select::concat::concat_batches(&batches[0].schema(), all_batches.iter().copied()) + .unwrap(); + assert_eq!(concatenated.num_rows(), num_rows as usize); + assert_eq!( + concatenated.column(0).as_ref(), + input_batch.column(0).as_ref() + ); + + // After the first batch, subsequent batches should be closer to the + // target. The ideal is 50 rows/batch. + assert!( + batches.len() >= 2, + "need at least 2 batches to test convergence" + ); + // The first batch uses the schema estimate (64 bytes/row) → + // ~78 rows. After feedback the rows should settle near 50. + if batches.len() >= 3 { + let second_batch_rows = batches[1].num_rows(); + let third_batch_rows = batches[2].num_rows(); + // Both should be within 20% of the ideal (50 rows) + assert!( + (40..=60).contains(&second_batch_rows), + "second batch should be near 50 rows, got {second_batch_rows}" + ); + assert!( + (40..=60).contains(&third_batch_rows), + "third batch should be near 50 rows, got {third_batch_rows}" + ); + } + } } diff --git a/rust/lance-encoding/src/testing.rs b/rust/lance-encoding/src/testing.rs index 7016c5be7a4..176083d6d64 100644 --- a/rust/lance-encoding/src/testing.rs +++ b/rust/lance-encoding/src/testing.rs @@ -218,6 +218,7 @@ async fn test_decode( /*should_validate=*/ true, /*spawn_structural_batch_decode_tasks=*/ is_structural_encoding, rx, + /*batch_size_bytes=*/ None, ) .unwrap(); diff --git a/rust/lance-file/src/reader.rs b/rust/lance-file/src/reader.rs index 15f1d1d466d..d41820f75a8 100644 --- a/rust/lance-file/src/reader.rs +++ b/rust/lance-file/src/reader.rs @@ -887,6 +887,7 @@ impl FileReader { decoder_plugins, io, decoder_config, + batch_size_bytes: None, }; let requested_rows = RequestedRows::Ranges(vec![range]); @@ -950,6 +951,7 @@ impl FileReader { decoder_plugins, io, decoder_config, + batch_size_bytes: None, }; let requested_rows = RequestedRows::Indices(indices); @@ -1013,6 +1015,7 @@ impl FileReader { decoder_plugins, io, decoder_config, + batch_size_bytes: None, }; let requested_rows = RequestedRows::Ranges(ranges); @@ -1191,6 +1194,7 @@ impl FileReader { decoder_plugins: self.decoder_plugins.clone(), io: self.scheduler.clone(), decoder_config: self.options.decoder_config.clone(), + batch_size_bytes: None, }; let requested_rows = RequestedRows::Indices(indices); @@ -1230,6 +1234,7 @@ impl FileReader { decoder_plugins: self.decoder_plugins.clone(), io: self.scheduler.clone(), decoder_config: self.options.decoder_config.clone(), + batch_size_bytes: None, }; let requested_rows = RequestedRows::Ranges(ranges); @@ -1269,6 +1274,7 @@ impl FileReader { decoder_plugins: self.decoder_plugins.clone(), io: self.scheduler.clone(), decoder_config: self.options.decoder_config.clone(), + batch_size_bytes: None, }; let requested_rows = RequestedRows::Ranges(vec![range]); From 6112a34bfe38618f07c099217dc3d89fd39ca6bb Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Wed, 8 Apr 2026 07:12:01 -0700 Subject: [PATCH 155/206] feat: add DataFile.create helper for building DataFile metadata (#6427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `DataFile.create(dataset, path, *, base_id=None)` classmethod that reads a lance file's metadata and automatically constructs a `DataFile` with correct field IDs, column indices, file version, and file size - Eliminates the need for manual `DataFile` construction when performing `DataReplacement` operations - Handles packed structs, structural file versions (v2.1+), subset columns, and external base paths Closes #6413 ## Test plan - [x] `test_data_file_create_basic` — verifies fields, column_indices, version, file_size for a two-column file - [x] `test_data_file_create_subset_columns` — single column from a multi-column dataset - [x] `test_data_file_create_end_to_end` — full DataReplacement round-trip using the new helper - [x] `test_data_file_create_unknown_column` — error on column not in dataset schema - [x] All existing `test_table_ops.py` tests still pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- python/python/lance/fragment.py | 30 +++++++ python/python/tests/test_table_ops.py | 92 +++++++++++++++++++ python/src/fragment.rs | 14 +++ rust/lance/src/dataset.rs | 124 ++++++++++++++++++++++++-- 4 files changed, 251 insertions(+), 9 deletions(-) diff --git a/python/python/lance/fragment.py b/python/python/lance/fragment.py index 150e4db7ab7..deeb141a73f 100644 --- a/python/python/lance/fragment.py +++ b/python/python/lance/fragment.py @@ -250,6 +250,36 @@ def field_ids(self) -> List[int]: ) return self.fields + @classmethod + def create( + cls, + dataset: "LanceDataset", + path: str, + *, + base_id: Optional[int] = None, + ) -> "DataFile": + """Create a DataFile by reading metadata from an existing lance file. + + This is a convenience method for creating DataFile metadata needed + for operations like DataReplacement. It opens the file, reads its + schema and version information, matches columns to the dataset's + schema to determine field IDs, and calculates column indices. + + Parameters + ---------- + dataset : LanceDataset + The dataset this file will belong to. + path : str + The path to the data file, relative to the dataset's data directory. + base_id : int, optional + The base path ID if the file is outside the dataset directory. + + Returns + ------- + DataFile + """ + return _Fragment.create_data_file(dataset._ds, path, base_id=base_id) + class LanceFragment(pa.dataset.Fragment): def __init__( diff --git a/python/python/tests/test_table_ops.py b/python/python/tests/test_table_ops.py index 4e61feff6d0..777b89da05a 100644 --- a/python/python/tests/test_table_ops.py +++ b/python/python/tests/test_table_ops.py @@ -106,3 +106,95 @@ def test_replacement_after_index(tmp_path: str): ), read_version=ds3.version, ) + + +def test_data_file_create_basic(tmp_path: str): + """DataFile.create should read file metadata and produce correct fields/indices.""" + table = pa.table({"a": range(10), "b": range(10, 20)}) + ds = lance.write_dataset(table, tmp_path) + + # Write a lance file with both columns + new_file_name = f"{uuid.uuid4()}.lance" + new_file_path = f"{tmp_path}/data/{new_file_name}" + with LanceFileWriter(new_file_path) as writer: + writer.write_batch(table) + + df = DataFile.create(ds, new_file_name) + + # Should have both field IDs from the dataset + frag = ds.get_fragments()[0] + expected_fields = frag.data_files()[0].fields + assert df.fields == expected_fields + assert df.column_indices == [0, 1] + assert df.file_major_version == int(stable_version().split(".")[0]) + assert df.file_minor_version == int(stable_version().split(".")[1]) + assert df.file_size_bytes is not None and df.file_size_bytes > 0 + + +def test_data_file_create_subset_columns(tmp_path: str): + """DataFile.create should work for a file with a subset of dataset columns.""" + table = pa.table({"a": range(10), "b": range(10, 20)}) + ds = lance.write_dataset(table, tmp_path) + ds.add_columns({"c": "a + b"}) + ds = lance.dataset(tmp_path) + + # Write a file with only column b + new_file_name = f"{uuid.uuid4()}.lance" + new_file_path = f"{tmp_path}/data/{new_file_name}" + with LanceFileWriter(new_file_path, pa.schema([("b", pa.int64())])) as writer: + writer.write_batch(pa.table({"b": range(100, 110)})) + + df = DataFile.create(ds, new_file_name) + + # Should only have b's field ID + frag = ds.get_fragments()[0] + all_fields = frag.data_files()[0].fields + # b is the second field in the original data file + b_field_id = all_fields[1] + assert df.fields == [b_field_id] + assert df.column_indices == [0] + + +def test_data_file_create_end_to_end(tmp_path: str): + """DataFile.create should work end-to-end with DataReplacement.""" + table = pa.table({"a": range(100)}) + ds = lance.write_dataset(table, tmp_path) + ds.add_columns({"b": "a + 1"}) + ds = lance.dataset(tmp_path) + + # Write a replacement file for column b + new_file_name = f"{uuid.uuid4()}.lance" + new_file_path = f"{tmp_path}/data/{new_file_name}" + replacement_data = pa.table({"b": range(200, 300)}) + with LanceFileWriter(new_file_path, pa.schema([("b", pa.int64())])) as writer: + writer.write_batch(replacement_data) + + # Use DataFile.create instead of manual construction + df = DataFile.create(ds, new_file_name) + + ds.commit( + ds.uri, + lance.LanceOperation.DataReplacement( + [lance.LanceOperation.DataReplacementGroup(0, df)] + ), + read_version=ds.version, + ) + + result = lance.dataset(tmp_path).to_table() + assert result.column("b").to_pylist() == list(range(200, 300)) + assert result.column("a").to_pylist() == list(range(100)) + + +def test_data_file_create_unknown_column(tmp_path: str): + """DataFile.create should raise an error for a file with unknown columns.""" + table = pa.table({"a": range(10)}) + ds = lance.write_dataset(table, tmp_path) + + # Write a file with a column not in the dataset + new_file_name = f"{uuid.uuid4()}.lance" + new_file_path = f"{tmp_path}/data/{new_file_name}" + with LanceFileWriter(new_file_path) as writer: + writer.write_batch(pa.table({"z": range(10)})) + + with pytest.raises(Exception, match="z"): + DataFile.create(ds, new_file_name) diff --git a/python/src/fragment.rs b/python/src/fragment.rs index 66817c6b322..de4165c6761 100644 --- a/python/src/fragment.rs +++ b/python/src/fragment.rs @@ -96,6 +96,20 @@ impl FileFragment { Ok(PyLance(metadata)) } + #[staticmethod] + #[pyo3(signature = (dataset, path, base_id=None))] + fn create_data_file( + dataset: &Dataset, + path: &str, + base_id: Option, + ) -> PyResult> { + let ds = dataset.ds.clone(); + let data_file = rt() + .block_on(None, ds.create_data_file(path, base_id))? + .infer_error()?; + Ok(PyLance(data_file)) + } + #[staticmethod] #[pyo3(signature = (dataset_uri, fragment_id, reader, **kwargs))] fn create( diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 8ef2c79a67e..c5e5d24e653 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -29,14 +29,17 @@ use lance_core::utils::tracing::{ }; use lance_datafusion::projection::ProjectionPlan; use lance_file::datatypes::populate_schema_dictionary; -use lance_file::reader::FileReaderOptions; +use lance_file::reader::{FileReader, FileReaderOptions}; use lance_file::version::LanceFileVersion; use lance_index::{IndexType, progress::IndexBuildProgress}; use lance_io::object_store::{ LanceNamespaceStorageOptionsProvider, ObjectStore, ObjectStoreParams, StorageOptions, StorageOptionsAccessor, StorageOptionsProvider, }; -use lance_io::utils::{read_last_block, read_message, read_metadata_offset, read_struct}; +use lance_io::scheduler::{ScanScheduler, SchedulerConfig}; +use lance_io::utils::{ + CachedFileSize, read_last_block, read_message, read_metadata_offset, read_struct, +}; use lance_namespace::LanceNamespace; use lance_table::format::{ DataFile, DataStorageFormat, DeletionFile, Fragment, IndexMetadata, Manifest, RowIdMeta, pb, @@ -57,6 +60,7 @@ use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt::Debug; +use std::num::NonZero; use std::ops::Range; use std::pin::Pin; use std::sync::Arc; @@ -1714,14 +1718,116 @@ impl Dataset { } pub(crate) fn data_file_dir(&self, data_file: &DataFile) -> Result { - match data_file.base_id.as_ref() { + self.data_file_dir_for_base(data_file.base_id) + } + + /// Create a [`DataFile`] by reading metadata from an existing lance file. + /// + /// This reads the file's schema and version information, matches columns to + /// the dataset's schema to determine field IDs, and calculates column indices. + /// This is useful for constructing `DataFile` metadata needed for operations + /// like [`Operation::DataReplacement`]. + /// + /// # Arguments + /// + /// * `path` - The path to the data file, relative to the dataset's data directory. + /// * `base_id` - The base path ID if the file is outside the dataset directory. + pub async fn create_data_file(&self, path: &str, base_id: Option) -> Result { + let data_dir = self.data_file_dir_for_base(base_id)?; + let filepath = data_dir.child(path); + + // Get file size + let file_size = self.object_store().size(&filepath).await?; + + // Read file metadata + let scheduler = ScanScheduler::new( + self.object_store.clone(), + SchedulerConfig::new(2 * 1024 * 1024 * 1024), + ); + let file = scheduler + .open_file(&filepath, &CachedFileSize::new(file_size)) + .await?; + let file_metadata = FileReader::read_all_metadata(&file).await?; + + let file_version = LanceFileVersion::try_from_major_minor( + file_metadata.major_version as u32, + file_metadata.minor_version as u32, + )?; + + // Get top-level column names from file schema in file order + let column_names: Vec<&str> = file_metadata + .file_schema + .fields + .iter() + .map(|f| f.name.as_str()) + .collect(); + + // Project dataset schema by file column names to get dataset field IDs + let projected_ds_schema = self.schema().project(&column_names)?; + + // Walk both schemas in parallel to build fields and column_indices + let is_structural = file_version >= LanceFileVersion::V2_1; + let ds_fields: Vec<_> = projected_ds_schema.fields_pre_order().collect(); + let file_fields: Vec<_> = file_metadata.file_schema.fields_pre_order().collect(); + + if ds_fields.len() != file_fields.len() { + return Err(Error::invalid_input(format!( + "Schema mismatch: dataset projection has {} fields but file has {} fields", + ds_fields.len(), + file_fields.len() + ))); + } + + let mut fields = Vec::new(); + let mut column_indices = Vec::new(); + let mut curr_column_idx: i32 = 0; + let mut packed_struct_fields_num: usize = 0; + + for (ds_field, file_field) in ds_fields.iter().zip(file_fields.iter()) { + if ds_field.name != file_field.name { + return Err(Error::invalid_input(format!( + "Schema mismatch: expected field '{}' but file has '{}'", + ds_field.name, file_field.name + ))); + } + + if packed_struct_fields_num > 0 { + packed_struct_fields_num -= 1; + continue; + } + + if file_field.is_packed_struct() { + fields.push(ds_field.id); + column_indices.push(curr_column_idx); + curr_column_idx += 1; + packed_struct_fields_num = file_field.children.len(); + } else if file_field.children.is_empty() || !is_structural { + fields.push(ds_field.id); + column_indices.push(curr_column_idx); + curr_column_idx += 1; + } + } + + let file_size_nz = NonZero::new(file_size); + Ok(DataFile::new( + path, + fields, + column_indices, + file_metadata.major_version as u32, + file_metadata.minor_version as u32, + file_size_nz, + base_id, + )) + } + + /// Resolve the data directory for a given base_id. + /// + /// If `base_id` is `None`, returns the default data directory. + fn data_file_dir_for_base(&self, base_id: Option) -> Result { + match base_id { Some(base_id) => { - let base_paths = &self.manifest.base_paths; - let base_path = base_paths.get(base_id).ok_or_else(|| { - Error::invalid_input(format!( - "base_path id {} not found for data_file {}", - base_id, data_file.path - )) + let base_path = self.manifest.base_paths.get(&base_id).ok_or_else(|| { + Error::invalid_input(format!("base_path id {} not found", base_id)) })?; let path = base_path.extract_path(self.session.store_registry())?; if base_path.is_dataset_root { From 25a20207e9ea416048db423b194e62e09dc44540 Mon Sep 17 00:00:00 2001 From: Beinan Date: Wed, 8 Apr 2026 09:41:54 -0700 Subject: [PATCH 156/206] feat(java): add Dataset.getZonemapStats() API (#6421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `Dataset.getZonemapStats(columnName)` Java API to read per-zone min/max/null_count statistics from zonemap indexes - Add `ZoneStats` data class representing per-zone statistics (fragment_id, zone_start, zone_length, min, max, null_count) - Add Rust JNI implementation that reads the `zonemap.lance` index file and converts `ScalarValue` to Java `Comparable` objects (Long, Double, String, Boolean) - This enables Spark connectors to perform fragment-level pruning at scan planning time using zonemap statistics — analogous to partition pruning in Hive/Iceberg ## Motivation The lance-spark connector currently only prunes fragments based on `_rowaddr` filters. With this API, it can also prune fragments based on arbitrary user-column predicates (e.g., `WHERE date = '2026-01-15'`) by consulting zonemap min/max statistics — skipping fragments whose zones provably cannot contain matching values. ## Changes ### New files - `java/src/main/java/org/lance/index/scalar/ZoneStats.java` — Serializable data class for per-zone statistics - `java/src/test/java/org/lance/index/ZonemapStatsTest.java` — 10 tests (data class unit tests + JNI integration tests) ### Modified files - `java/src/main/java/org/lance/Dataset.java` — Added `getZonemapStats()` method + native declaration - `java/lance-jni/src/blocking_dataset.rs` — JNI implementation: - `nativeGetZonemapStats`: finds zonemap index via `describe_indices`, reads `zonemap.lance` file, converts RecordBatch rows to Java `ZoneStats` objects - `scalar_value_to_java`: converts DataFusion `ScalarValue` to boxed Java types (Long, Double, String, Boolean) - `java/lance-jni/Cargo.toml` — Added `arrow-array` and `datafusion-common` dependencies ## Test plan - [x] `ZonemapStatsTest#testZoneStatsGetters` — data class getters - [x] `ZonemapStatsTest#testZoneStatsNullMinMax` — null min/max handling - [x] `ZonemapStatsTest#testZoneStatsToString` — toString format - [x] `ZonemapStatsTest#testGetZonemapStatsNoIndex` — empty dataset, no index → empty list - [x] `ZonemapStatsTest#testGetZonemapStatsNonexistentColumn` — bad column → exception - [x] `ZonemapStatsTest#testGetZonemapStatsWithData` — full JNI round-trip with real data - [x] `ZonemapStatsTest#testGetZonemapStatsMultiFragment` — 2 fragments → stats from both - [x] `ZonemapStatsTest#testGetZonemapStatsWrongColumnReturnsEmpty` — zonemap on col A, query col B → empty - [x] `ZonemapStatsTest#testGetZonemapStatsNullArgument` — null → exception - [x] `ZonemapStatsTest#testGetZonemapStatsEmptyArgument` — empty → exception 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- java/lance-jni/Cargo.lock | 2 + java/lance-jni/Cargo.toml | 2 + java/lance-jni/src/blocking_dataset.rs | 193 +++++++++++++ java/lance-jni/src/utils.rs | 82 ++++++ java/src/main/java/org/lance/Dataset.java | 24 ++ .../org/lance/index/scalar/ZoneStats.java | 116 ++++++++ .../org/lance/index/ZonemapStatsTest.java | 267 ++++++++++++++++++ 7 files changed, 686 insertions(+) create mode 100644 java/src/main/java/org/lance/index/scalar/ZoneStats.java create mode 100644 java/src/test/java/org/lance/index/ZonemapStatsTest.java diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 09143a6adf0..3417984f26b 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -3778,10 +3778,12 @@ name = "lance-jni" version = "5.0.0-beta.5" dependencies = [ "arrow", + "arrow-array", "arrow-schema", "async-trait", "bytes", "chrono", + "datafusion-common", "env_logger", "jni", "lance", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 08accd072ec..a20df585e9a 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -28,7 +28,9 @@ lance-core = { path = "../../rust/lance-core" } lance-file = { path = "../../rust/lance-file" } lance-table = { path = "../../rust/lance-table" } arrow = { version = "57.1", features = ["ffi"] } +arrow-array = "57.1" arrow-schema = "57.1" +datafusion-common = "52.1.0" object_store = { version = "0.12.2" } tokio = { version = "1.23", features = [ "rt-multi-thread", diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index aa56d54de95..6366341ec1a 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -3072,3 +3072,196 @@ fn inner_get_session_handle(env: &mut JNIEnv, java_dataset: JObject) -> Result( + mut env: JNIEnv<'local>, + java_dataset: JObject, + jcolumn_name: JString, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_get_zonemap_stats(&mut env, java_dataset, jcolumn_name) + ) +} + +fn inner_get_zonemap_stats<'local>( + env: &mut JNIEnv<'local>, + java_dataset: JObject, + jcolumn_name: JString, +) -> Result> { + use arrow_array::Array; + use datafusion_common::ScalarValue; + use lance::dataset::index::LanceIndexStoreExt; + use lance::index::DatasetIndexExt; + use lance_index::scalar::IndexStore; + use lance_index::scalar::lance_format::LanceIndexStore; + + let column_name: String = jcolumn_name.extract(env)?; + + // 1. Get the dataset and find the zonemap index for this column + let zonemap_data = { + let dataset = { + let dataset_guard = unsafe { + env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) + }?; + dataset_guard.inner.clone() + }; + // Guard is dropped here — dataset is owned + + // Find the field for the requested column (validates it exists) + dataset.schema().field(&column_name).ok_or_else(|| { + Error::input_error(format!( + "Column '{}' not found in dataset schema", + column_name + )) + })?; + + // Do all async work in a single block_on call to avoid nested runtime issues + RT.block_on(async { + // Find the zonemap index for this column using describe_indices + let descriptions = dataset + .describe_indices(Some(lance_index::IndexCriteria { + for_column: Some(&column_name), + has_name: None, + must_support_fts: false, + must_support_exact_equality: false, + })) + .await + .map_err(Error::from)?; + + let zonemap_desc = descriptions + .iter() + .find(|desc| desc.index_type().to_lowercase().contains("zonemap")); + + match zonemap_desc { + Some(desc) => { + let indices = dataset.load_indices().await.map_err(Error::from)?; + let index_meta = indices.iter().find(|idx| idx.name == desc.name()); + match index_meta { + Some(index) => { + let index_store = Arc::new( + LanceIndexStore::from_dataset_for_existing(&dataset, index) + .map_err(Error::from)?, + ); + let index_file = index_store + .open_index_file("zonemap.lance") + .await + .map_err(Error::from)?; + let record_batch = index_file + .read_range(0..index_file.num_rows(), None) + .await + .map_err(Error::from)?; + Ok::<_, Error>(Some(record_batch)) + } + None => Ok(None), + } + } + None => Ok(None), + } + })? + }; + + // 3. Convert the RecordBatch to a Java ArrayList + let array_list = env.new_object("java/util/ArrayList", "()V", &[])?; + + let record_batch = match zonemap_data { + Some(batch) => batch, + None => return Ok(array_list), // empty list if no zonemap index + }; + + if record_batch.num_rows() == 0 { + return Ok(array_list); + } + + let min_col = record_batch + .column_by_name("min") + .ok_or_else(|| Error::input_error("ZoneMap index file missing 'min' column".to_string()))?; + let max_col = record_batch + .column_by_name("max") + .ok_or_else(|| Error::input_error("ZoneMap index file missing 'max' column".to_string()))?; + let null_count_col = record_batch + .column_by_name("null_count") + .ok_or_else(|| { + Error::input_error("ZoneMap index file missing 'null_count' column".to_string()) + })? + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Error::input_error("ZoneMap 'null_count' column is not UInt32".to_string()) + })?; + let fragment_id_col = record_batch + .column_by_name("fragment_id") + .ok_or_else(|| { + Error::input_error("ZoneMap index file missing 'fragment_id' column".to_string()) + })? + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Error::input_error("ZoneMap 'fragment_id' column is not UInt64".to_string()) + })?; + let zone_start_col = record_batch + .column_by_name("zone_start") + .ok_or_else(|| { + Error::input_error("ZoneMap index file missing 'zone_start' column".to_string()) + })? + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Error::input_error("ZoneMap 'zone_start' column is not UInt64".to_string()) + })?; + let zone_length_col = record_batch + .column_by_name("zone_length") + .ok_or_else(|| { + Error::input_error("ZoneMap index file missing 'zone_length' column".to_string()) + })? + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Error::input_error("ZoneMap 'zone_length' column is not UInt64".to_string()) + })?; + + for i in 0..record_batch.num_rows() { + let fragment_id = fragment_id_col.value(i) as i32; + let zone_start = zone_start_col.value(i) as i64; + let zone_length = zone_length_col.value(i) as i64; + let null_count = null_count_col.value(i) as i64; + + // Convert min/max ScalarValues to Java Comparable objects + let min_scalar = ScalarValue::try_from_array(min_col, i).map_err(|e| { + Error::input_error(format!("Failed to read min value at row {}: {}", i, e)) + })?; + let max_scalar = ScalarValue::try_from_array(max_col, i).map_err(|e| { + Error::input_error(format!("Failed to read max value at row {}: {}", i, e)) + })?; + + let j_min = crate::utils::scalar_value_to_java(env, &min_scalar)?; + let j_max = crate::utils::scalar_value_to_java(env, &max_scalar)?; + + let zone_stats = env.new_object( + "org/lance/index/scalar/ZoneStats", + "(IJJLjava/lang/Comparable;Ljava/lang/Comparable;J)V", + &[ + JValue::Int(fragment_id), + JValue::Long(zone_start), + JValue::Long(zone_length), + JValue::Object(&j_min), + JValue::Object(&j_max), + JValue::Long(null_count), + ], + )?; + + env.call_method( + &array_list, + "add", + "(Ljava/lang/Object;)Z", + &[JValue::Object(&zone_stats)], + )?; + } + + Ok(array_list) +} diff --git a/java/lance-jni/src/utils.rs b/java/lance-jni/src/utils.rs index 3d7ef2ebdaf..d2787dc6400 100644 --- a/java/lance-jni/src/utils.rs +++ b/java/lance-jni/src/utils.rs @@ -570,3 +570,85 @@ pub fn to_java_float_obj<'local>( None => Ok(JObject::null()), } } + +pub fn to_java_double_obj<'local>( + env: &mut JNIEnv<'local>, + value: Option, +) -> Result> { + match value { + Some(v) => Ok(env.new_object("java/lang/Double", "(D)V", &[JValue::Double(v)])?), + None => Ok(JObject::null()), + } +} + +pub fn to_java_string_obj<'local>( + env: &mut JNIEnv<'local>, + value: Option<&str>, +) -> Result> { + match value { + Some(v) => { + let jstr = env.new_string(v)?; + Ok(jstr.into()) + } + None => Ok(JObject::null()), + } +} + +/// Convert a DataFusion ScalarValue to a Java Comparable object. +/// +/// Maps numeric types to their boxed Java equivalents (Long, Double) +/// and string types to Java String. Null ScalarValues produce JObject::null(). +/// +/// This is useful for exposing index statistics (e.g., zonemap min/max) +/// to Java clients in a type-safe, Comparable-compatible way. +pub fn scalar_value_to_java<'a>( + env: &mut JNIEnv<'a>, + value: &datafusion_common::ScalarValue, +) -> Result> { + use datafusion_common::ScalarValue; + + match value { + ScalarValue::Null => Ok(JObject::null()), + + ScalarValue::Boolean(v) => to_java_boolean_obj(env, *v), + + // Integer types → Java Long + ScalarValue::Int8(v) => to_java_long_obj(env, v.map(|x| x as i64)), + ScalarValue::Int16(v) => to_java_long_obj(env, v.map(|x| x as i64)), + ScalarValue::Int32(v) => to_java_long_obj(env, v.map(|x| x as i64)), + ScalarValue::Int64(v) => to_java_long_obj(env, *v), + ScalarValue::UInt8(v) => to_java_long_obj(env, v.map(|x| x as i64)), + ScalarValue::UInt16(v) => to_java_long_obj(env, v.map(|x| x as i64)), + ScalarValue::UInt32(v) => to_java_long_obj(env, v.map(|x| x as i64)), + // UInt64 may overflow i64, but for min/max stats this is acceptable + ScalarValue::UInt64(v) => to_java_long_obj(env, v.map(|x| x as i64)), + + // Float types → Java Double + ScalarValue::Float16(v) => to_java_double_obj(env, v.map(|x| f64::from(x.to_f32()))), + ScalarValue::Float32(v) => to_java_double_obj(env, v.map(|x| x as f64)), + ScalarValue::Float64(v) => to_java_double_obj(env, *v), + + // String types → Java String + ScalarValue::Utf8(v) => to_java_string_obj(env, v.as_deref()), + ScalarValue::LargeUtf8(v) => to_java_string_obj(env, v.as_deref()), + + // Date types → Java Long + ScalarValue::Date32(v) => to_java_long_obj(env, v.map(|x| x as i64)), + ScalarValue::Date64(v) => to_java_long_obj(env, *v), + + // Timestamp types → Java Long + ScalarValue::TimestampSecond(v, _) => to_java_long_obj(env, *v), + ScalarValue::TimestampMillisecond(v, _) => to_java_long_obj(env, *v), + ScalarValue::TimestampMicrosecond(v, _) => to_java_long_obj(env, *v), + ScalarValue::TimestampNanosecond(v, _) => to_java_long_obj(env, *v), + + // For any unsupported type, return null (conservative: caller will skip) + _ => { + log::warn!( + "Unsupported ScalarValue type for Java conversion: {:?}", + value.data_type() + ); + Ok(JObject::null()) + } + } +} diff --git a/java/src/main/java/org/lance/Dataset.java b/java/src/main/java/org/lance/Dataset.java index bc7500270d7..f5cc5e73fba 100644 --- a/java/src/main/java/org/lance/Dataset.java +++ b/java/src/main/java/org/lance/Dataset.java @@ -24,6 +24,7 @@ import org.lance.index.IndexParams; import org.lance.index.IndexType; import org.lance.index.OptimizeOptions; +import org.lance.index.scalar.ZoneStats; import org.lance.ipc.DataStatistics; import org.lance.ipc.LanceScanner; import org.lance.ipc.ScanOptions; @@ -1316,6 +1317,29 @@ public List describeIndices() { private native List nativeDescribeIndices(Optional criteria); + /** + * Read zonemap statistics for a column. + * + *

    Returns per-zone min/max/null_count statistics for the given column, if a zonemap index + * exists. Returns an empty list if no zonemap index exists for the column. + * + *

    The zonemap index file is typically small (one row per zone), so this is a lightweight + * metadata-only operation suitable for calling on the driver during scan planning. + * + * @param columnName the column name + * @return list of per-zone statistics, ordered by (fragment_id, zone_start) + */ + public List getZonemapStats(String columnName) { + Preconditions.checkArgument( + columnName != null && !columnName.isEmpty(), "columnName cannot be null or empty"); + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return nativeGetZonemapStats(columnName); + } + } + + private native List nativeGetZonemapStats(String columnName); + /** * Get the table config of the dataset. * diff --git a/java/src/main/java/org/lance/index/scalar/ZoneStats.java b/java/src/main/java/org/lance/index/scalar/ZoneStats.java new file mode 100644 index 00000000000..b97042ed226 --- /dev/null +++ b/java/src/main/java/org/lance/index/scalar/ZoneStats.java @@ -0,0 +1,116 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.lance.index.scalar; + +import java.io.Serializable; + +/** + * Per-zone statistics from a zonemap index. + * + *

    Each zone covers a contiguous range of row offsets within a single fragment. The min/max + * values are represented as {@link Comparable} objects (Long, Double, String, etc.) matching the + * column's data type. + * + *

    A zone never spans fragment boundaries: if a fragment has more rows than the zone size, + * multiple zones are created within that fragment. + * + *

    This class is populated from the Rust {@code ZoneMapStatistics} via JNI. See {@code + * lance-index/src/scalar/zonemap.rs} for the on-disk format. + */ +public class ZoneStats implements Serializable { + private static final long serialVersionUID = 1L; + + private final int fragmentId; + private final long zoneStart; + private final long zoneLength; + private final Comparable min; + private final Comparable max; + private final long nullCount; + + /** + * Constructs a new ZoneStats instance. + * + * @param fragmentId the fragment this zone belongs to + * @param zoneStart the starting row offset within the fragment + * @param zoneLength the span of row offsets (last_offset - first_offset + 1); may differ from + * physical row count if rows have been deleted + * @param min the minimum value in the zone, or null if all values are null + * @param max the maximum value in the zone, or null if all values are null + * @param nullCount the number of null values in the zone + */ + public ZoneStats( + int fragmentId, + long zoneStart, + long zoneLength, + Comparable min, + Comparable max, + long nullCount) { + this.fragmentId = fragmentId; + this.zoneStart = zoneStart; + this.zoneLength = zoneLength; + this.min = min; + this.max = max; + this.nullCount = nullCount; + } + + /** Returns the fragment ID this zone belongs to. */ + public int getFragmentId() { + return fragmentId; + } + + /** Returns the starting row offset within the fragment. */ + public long getZoneStart() { + return zoneStart; + } + + /** + * Returns the span of row offsets covered by this zone. + * + *

    This is (last_row_offset - first_row_offset + 1), not the count of physical rows. Deletions + * may create gaps within the span. + */ + public long getZoneLength() { + return zoneLength; + } + + /** + * Returns the minimum value in the zone. + * + * @return the min value, or null if all values in the zone are null + */ + public Comparable getMin() { + return min; + } + + /** + * Returns the maximum value in the zone. + * + * @return the max value, or null if all values in the zone are null + */ + public Comparable getMax() { + return max; + } + + /** Returns the number of null values in the zone. */ + public long getNullCount() { + return nullCount; + } + + @Override + public String toString() { + return String.format( + "ZoneStats{fragmentId=%d, zoneStart=%d, zoneLength=%d," + " min=%s, max=%s, nullCount=%d}", + fragmentId, zoneStart, zoneLength, min, max, nullCount); + } +} diff --git a/java/src/test/java/org/lance/index/ZonemapStatsTest.java b/java/src/test/java/org/lance/index/ZonemapStatsTest.java new file mode 100644 index 00000000000..19824cd4cfd --- /dev/null +++ b/java/src/test/java/org/lance/index/ZonemapStatsTest.java @@ -0,0 +1,267 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.lance.index; + +import org.lance.Dataset; +import org.lance.Fragment; +import org.lance.FragmentMetadata; +import org.lance.FragmentOperation; +import org.lance.WriteParams; +import org.lance.index.scalar.ScalarIndexParams; +import org.lance.index.scalar.ZoneStats; + +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** Tests for {@link Dataset#getZonemapStats(String)} and the {@link ZoneStats} data class. */ +public class ZonemapStatsTest { + + private static Schema intSchema() { + return new Schema( + Arrays.asList( + Field.nullable("id", new ArrowType.Int(32, true)), + Field.nullable("value", new ArrowType.Int(32, true))), + null); + } + + /** Write a single fragment with sequential integer values. */ + private Dataset writeIntFragment( + BufferAllocator allocator, String path, long version, int startValue, int rowCount) { + Schema schema = intSchema(); + List metas; + try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + root.allocateNew(); + IntVector idVec = (IntVector) root.getVector("id"); + IntVector valVec = (IntVector) root.getVector("value"); + for (int i = 0; i < rowCount; i++) { + idVec.setSafe(i, startValue + i); + valVec.setSafe(i, (startValue + i) * 10); + } + root.setRowCount(rowCount); + metas = Fragment.create(path, allocator, root, new WriteParams.Builder().build()); + } + FragmentOperation.Append appendOp = new FragmentOperation.Append(metas); + return Dataset.commit(allocator, path, appendOp, Optional.of(version)); + } + + // ------------------------------------------------------- + // ZoneStats data class tests + // ------------------------------------------------------- + + @Test + public void testZoneStatsGetters() { + ZoneStats stats = new ZoneStats(3, 100, 50, 10L, 99L, 5); + assertEquals(3, stats.getFragmentId()); + assertEquals(100, stats.getZoneStart()); + assertEquals(50, stats.getZoneLength()); + assertEquals(10L, stats.getMin()); + assertEquals(99L, stats.getMax()); + assertEquals(5, stats.getNullCount()); + } + + @Test + public void testZoneStatsNullMinMax() { + ZoneStats stats = new ZoneStats(0, 0, 10, null, null, 10); + assertNull(stats.getMin()); + assertNull(stats.getMax()); + assertEquals(10, stats.getNullCount()); + } + + @Test + public void testZoneStatsToString() { + ZoneStats stats = new ZoneStats(1, 0, 100, 0L, 99L, 0); + String str = stats.toString(); + assertTrue(str.contains("fragmentId=1")); + assertTrue(str.contains("min=0")); + assertTrue(str.contains("max=99")); + } + + // ------------------------------------------------------- + // getZonemapStats integration tests + // ------------------------------------------------------- + + @Test + public void testGetZonemapStatsNoIndex(@TempDir Path tempDir) { + String path = tempDir.resolve("no_index").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + try (Dataset dataset = + Dataset.create(allocator, path, intSchema(), new WriteParams.Builder().build())) { + List stats = dataset.getZonemapStats("id"); + assertNotNull(stats); + assertTrue(stats.isEmpty()); + } + } + } + + @Test + public void testGetZonemapStatsNonexistentColumn(@TempDir Path tempDir) { + String path = tempDir.resolve("bad_col").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + try (Dataset dataset = + Dataset.create(allocator, path, intSchema(), new WriteParams.Builder().build())) { + assertThrows(IllegalArgumentException.class, () -> dataset.getZonemapStats("nonexistent")); + } + } + } + + @Test + public void testGetZonemapStatsWithData(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("with_data").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + try (Dataset ds = + Dataset.create(allocator, path, intSchema(), new WriteParams.Builder().build())) { + // empty + } + Dataset ds2 = writeIntFragment(allocator, path, 1, 0, 100); + ds2.close(); + + try (Dataset dataset = Dataset.open(path, allocator)) { + ScalarIndexParams params = ScalarIndexParams.create("zonemap", "{}"); + IndexParams indexParams = IndexParams.builder().setScalarIndexParams(params).build(); + dataset.createIndex( + Collections.singletonList("value"), + IndexType.ZONEMAP, + Optional.of("value_zm"), + indexParams, + true); + + List stats = dataset.getZonemapStats("value"); + assertNotNull(stats); + assertFalse(stats.isEmpty()); + + for (ZoneStats z : stats) { + assertEquals(0, z.getFragmentId()); + assertNotNull(z.getMin()); + assertNotNull(z.getMax()); + assertTrue(z.getZoneLength() > 0); + } + + ZoneStats first = stats.get(0); + assertTrue( + ((Number) first.getMin()).longValue() <= 0, + "First zone min should be <= 0, got: " + first.getMin()); + + ZoneStats last = stats.get(stats.size() - 1); + assertTrue( + ((Number) last.getMax()).longValue() >= 990, + "Last zone max should be >= 990, got: " + last.getMax()); + } + } + } + + @Test + public void testGetZonemapStatsMultiFragment(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("multi_frag").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + try (Dataset ds = + Dataset.create(allocator, path, intSchema(), new WriteParams.Builder().build())) { + // empty + } + Dataset ds2 = writeIntFragment(allocator, path, 1, 0, 50); + ds2.close(); + Dataset ds3 = writeIntFragment(allocator, path, 2, 50, 50); + ds3.close(); + + try (Dataset dataset = Dataset.open(path, allocator)) { + assertEquals(2, dataset.getFragments().size()); + + ScalarIndexParams params = ScalarIndexParams.create("zonemap", "{}"); + IndexParams indexParams = IndexParams.builder().setScalarIndexParams(params).build(); + dataset.createIndex( + Collections.singletonList("value"), + IndexType.ZONEMAP, + Optional.of("value_zm"), + indexParams, + true); + + List stats = dataset.getZonemapStats("value"); + assertNotNull(stats); + assertFalse(stats.isEmpty()); + + Set fragmentIds = new HashSet<>(); + for (ZoneStats z : stats) { + fragmentIds.add(z.getFragmentId()); + } + assertEquals(2, fragmentIds.size(), "Expected zones from 2 fragments, got: " + fragmentIds); + } + } + } + + @Test + public void testGetZonemapStatsWrongColumnReturnsEmpty(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("wrong_col").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + try (Dataset ds = + Dataset.create(allocator, path, intSchema(), new WriteParams.Builder().build())) { + // empty + } + Dataset ds2 = writeIntFragment(allocator, path, 1, 0, 100); + ds2.close(); + + try (Dataset dataset = Dataset.open(path, allocator)) { + ScalarIndexParams params = ScalarIndexParams.create("zonemap", "{}"); + IndexParams indexParams = IndexParams.builder().setScalarIndexParams(params).build(); + dataset.createIndex( + Collections.singletonList("value"), + IndexType.ZONEMAP, + Optional.of("value_zm"), + indexParams, + true); + + List stats = dataset.getZonemapStats("id"); + assertNotNull(stats); + assertTrue(stats.isEmpty(), "Expected empty for non-indexed column"); + } + } + } + + @Test + public void testGetZonemapStatsNullArgument(@TempDir Path tempDir) { + String path = tempDir.resolve("null_arg").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + try (Dataset dataset = + Dataset.create(allocator, path, intSchema(), new WriteParams.Builder().build())) { + assertThrows(IllegalArgumentException.class, () -> dataset.getZonemapStats(null)); + } + } + } + + @Test + public void testGetZonemapStatsEmptyArgument(@TempDir Path tempDir) { + String path = tempDir.resolve("empty_arg").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + try (Dataset dataset = + Dataset.create(allocator, path, intSchema(), new WriteParams.Builder().build())) { + assertThrows(IllegalArgumentException.class, () -> dataset.getZonemapStats("")); + } + } + } +} From 943e14a27358eee9486064898f69239a32c0be86 Mon Sep 17 00:00:00 2001 From: Andrew Yao Date: Wed, 8 Apr 2026 13:27:53 -0700 Subject: [PATCH 157/206] fix: implement next page token support in list namespaces and tables (#6419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, both namespace backends accepted page_token/limit inputs but never populated page_token in the response, so callers had no way to know whether more results existed. This change closes that gap by returning the last-seen item as the cursor for the next page. dir.rs (DirectoryNamespace): - apply_pagination now returns Option instead of (). When results are truncated by the limit, it returns the last item in the page as the next page token. - list_tables stores the returned token in response.page_token. - New test test_list_tables_pagination verifies two-page traversal over 3 tables. dir/manifest.rs (ManifestNamespace): - Adds a new apply_pagination helper (same logic as the dir.rs version — sort, apply start-after filter, apply limit, return next page token). - list_tables and list_namespaces both use it and propagate page_token into their responses. --- rust/lance-namespace-impls/src/dir.rs | 102 ++++++++++++- .../lance-namespace-impls/src/dir/manifest.rs | 138 +++++++++++++++++- 2 files changed, 233 insertions(+), 7 deletions(-) diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index 28194c68f49..734cbc64c2b 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -835,7 +835,15 @@ impl DirectoryNamespace { /// * `names` - The vector of table names to paginate /// * `page_token` - Skip items until finding one greater than this value (start_after semantics) /// * `limit` - Maximum number of items to keep - fn apply_pagination(names: &mut Vec, page_token: Option, limit: Option) { + /// + /// # Returns + /// The next page token (last item in this page) if more results exist beyond the limit, + /// or `None` if this is the last page. + fn apply_pagination( + names: &mut Vec, + page_token: Option, + limit: Option, + ) -> Option { // Sort alphabetically for consistent ordering names.sort(); @@ -851,12 +859,23 @@ impl DirectoryNamespace { } } - // Apply limit + // Apply limit and compute next page token if let Some(limit) = limit && limit >= 0 { - names.truncate(limit as usize); + let limit = limit as usize; + if names.len() > limit { + let next_page_token = if limit > 0 { + Some(names[limit - 1].clone()) + } else { + None + }; + names.truncate(limit); + return next_page_token; + } } + + None } /// List tables using directory scanning (fallback method) @@ -1966,8 +1985,10 @@ impl LanceNamespace for DirectoryNamespace { }; // Apply sorting and pagination - Self::apply_pagination(&mut tables, request.page_token, request.limit); - let response = ListTablesResponse::new(tables); + let next_page_token = + Self::apply_pagination(&mut tables, request.page_token, request.limit); + let mut response = ListTablesResponse::new(tables); + response.page_token = next_page_token; Ok(response) } @@ -3581,6 +3602,77 @@ mod tests { assert!(tables.contains(&"table2".to_string())); } + #[tokio::test] + async fn test_list_tables_pagination() { + let (namespace, _temp_dir) = create_test_namespace().await; + + let schema = create_test_schema(); + let ipc_data = create_test_ipc_data(&schema); + + for name in ["alpha", "bravo", "charlie"] { + let mut req = CreateTableRequest::new(); + req.id = Some(vec![name.to_string()]); + namespace + .create_table(req, bytes::Bytes::from(ipc_data.clone())) + .await + .unwrap(); + } + + // First page: limit=2, no page_token + let first_page = namespace + .list_tables(ListTablesRequest { + id: Some(vec![]), + limit: Some(2), + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(first_page.tables, vec!["alpha", "bravo"]); + assert_eq!(first_page.page_token.as_deref(), Some("bravo")); + + // Second page: use page_token from first response + let second_page = namespace + .list_tables(ListTablesRequest { + id: Some(vec![]), + limit: Some(2), + page_token: first_page.page_token.clone(), + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(second_page.tables, vec!["charlie"]); + assert!(second_page.page_token.is_none()); + } + + #[tokio::test] + async fn test_list_tables_pagination_limit_zero() { + let (namespace, _temp_dir) = create_test_namespace().await; + + let schema = create_test_schema(); + let ipc_data = create_test_ipc_data(&schema); + + let mut req = CreateTableRequest::new(); + req.id = Some(vec!["alpha".to_string()]); + namespace + .create_table(req, bytes::Bytes::from(ipc_data)) + .await + .unwrap(); + + let response = namespace + .list_tables(ListTablesRequest { + id: Some(vec![]), + limit: Some(0), + ..Default::default() + }) + .await + .unwrap(); + + assert!(response.tables.is_empty()); + assert!(response.page_token.is_none()); + } + #[tokio::test] async fn test_list_tables_with_namespace_id() { let (namespace, _temp_dir) = create_test_namespace().await; diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index ab850a7e731..4c4381e9165 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -1684,6 +1684,46 @@ impl ManifestNamespace { } } } + + /// Sorts names alphabetically and applies pagination using page_token (start_after) and limit. + /// + /// Returns the next page token (last item in this page) if more results exist beyond the limit, + /// or `None` if this is the last page. + fn apply_pagination( + names: &mut Vec, + page_token: Option, + limit: Option, + ) -> Option { + names.sort(); + + if let Some(start_after) = page_token { + if let Some(index) = names + .iter() + .position(|name| name.as_str() > start_after.as_str()) + { + names.drain(0..index); + } else { + names.clear(); + } + } + + if let Some(limit) = limit + && limit >= 0 + { + let limit = limit as usize; + if names.len() > limit { + let next_page_token = if limit > 0 { + Some(names[limit - 1].clone()) + } else { + None + }; + names.truncate(limit); + return next_page_token; + } + } + + None + } } #[async_trait] @@ -1742,7 +1782,11 @@ impl LanceNamespace for ManifestNamespace { } } - Ok(ListTablesResponse::new(tables)) + let next_page_token = + Self::apply_pagination(&mut tables, request.page_token, request.limit); + let mut response = ListTablesResponse::new(tables); + response.page_token = next_page_token; + Ok(response) } async fn describe_table(&self, request: DescribeTableRequest) -> Result { @@ -2086,7 +2130,11 @@ impl LanceNamespace for ManifestNamespace { } } - Ok(ListNamespacesResponse::new(namespaces)) + let next_page_token = + Self::apply_pagination(&mut namespaces, request.page_token, request.limit); + let mut response = ListNamespacesResponse::new(namespaces); + response.page_token = next_page_token; + Ok(response) } async fn describe_namespace( @@ -2683,6 +2731,37 @@ mod tests { assert_eq!(response.tables.len(), 0); } + #[tokio::test] + async fn test_list_tables_pagination_limit_zero() { + let temp_dir = TempStdDir::default(); + let temp_path = temp_dir.to_str().unwrap(); + + let dir_namespace = DirectoryNamespaceBuilder::new(temp_path) + .build() + .await + .unwrap(); + + let buffer = create_test_ipc_data(); + let mut create_request = CreateTableRequest::new(); + create_request.id = Some(vec!["alpha".to_string()]); + dir_namespace + .create_table(create_request, Bytes::from(buffer)) + .await + .unwrap(); + + let response = dir_namespace + .list_tables(ListTablesRequest { + id: Some(vec![]), + limit: Some(0), + ..Default::default() + }) + .await + .unwrap(); + + assert!(response.tables.is_empty()); + assert!(response.page_token.is_none()); + } + #[rstest] #[case::with_optimization(true)] #[case::without_optimization(false)] @@ -3552,4 +3631,59 @@ mod tests { describe_result ); } + + // --- apply_pagination unit tests --- + + fn names(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_apply_pagination_no_token_no_limit() { + let mut n = names(&["b", "a", "c"]); + let next = ManifestNamespace::apply_pagination(&mut n, None, None); + assert_eq!(n, names(&["a", "b", "c"])); + assert_eq!(next, None); + } + + #[test] + fn test_apply_pagination_limit_truncates_and_returns_token() { + let mut n = names(&["c", "a", "b"]); + let next = ManifestNamespace::apply_pagination(&mut n, None, Some(2)); + assert_eq!(n, names(&["a", "b"])); + assert_eq!(next, Some("b".to_string())); + } + + #[test] + fn test_apply_pagination_limit_zero_returns_empty_no_token() { + let mut n = names(&["a", "b", "c"]); + let next = ManifestNamespace::apply_pagination(&mut n, None, Some(0)); + assert!(n.is_empty()); + assert_eq!(next, None); + } + + #[test] + fn test_apply_pagination_page_token_in_list() { + // "b" is in the list; should start from "c" (strict >) + let mut n = names(&["a", "b", "c", "d"]); + let next = ManifestNamespace::apply_pagination(&mut n, Some("b".to_string()), None); + assert_eq!(n, names(&["c", "d"])); + assert_eq!(next, None); + } + + #[test] + fn test_apply_pagination_page_token_past_all_items() { + let mut n = names(&["a", "b", "c"]); + let next = ManifestNamespace::apply_pagination(&mut n, Some("z".to_string()), None); + assert!(n.is_empty()); + assert_eq!(next, None); + } + + #[test] + fn test_apply_pagination_token_and_limit_combined() { + let mut n = names(&["a", "b", "c", "d", "e"]); + let next = ManifestNamespace::apply_pagination(&mut n, Some("b".to_string()), Some(2)); + assert_eq!(n, names(&["c", "d"])); + assert_eq!(next, Some("d".to_string())); + } } From c04c585c0b32c896e3e49ce00d8e8e395615344c Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Wed, 8 Apr 2026 13:29:01 -0700 Subject: [PATCH 158/206] fix: return error instead of OOM on corrupt binary page offsets (#6392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - When reading a corrupt lance file where binary page offsets are non-monotonic (e.g. due to multipart upload part reordering), the subtraction `val - prev` in `IndicesNormalizer::extend` would underflow, producing a huge value used as a byte-range length, triggering an OOM allocation that kills the process. - Use `checked_sub` to detect the underflow and return a descriptive error message instead, indicating the file data has been corrupted. ## Context This was discovered investigating a real-world corruption where two adjacent 5MB multipart upload parts were swapped when writing to an S3 Express directory bucket. The swapped parts caused the binary (string) column's cumulative offset indices to be non-monotonic at the part boundary, leading to the underflow. ## Test plan - [x] Verified the fix compiles cleanly (`cargo check -p lance-encoding`) - [x] Verified against a real corrupt lance file: returns a clear error instead of OOM - [ ] Existing tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- .../src/previous/encodings/physical/binary.rs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/rust/lance-encoding/src/previous/encodings/physical/binary.rs b/rust/lance-encoding/src/previous/encodings/physical/binary.rs index 960f7583828..0bd4d96b45e 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/binary.rs +++ b/rust/lance-encoding/src/previous/encodings/physical/binary.rs @@ -57,7 +57,7 @@ impl IndicesNormalizer { } } - fn extend(&mut self, new_indices: &PrimitiveArray, is_start: bool) { + fn extend(&mut self, new_indices: &PrimitiveArray, is_start: bool) -> Result<()> { let mut last = *self.indices.last().unwrap(); if is_start { let (is_valid, val) = self.normalize(new_indices.value(0)); @@ -66,14 +66,25 @@ impl IndicesNormalizer { last += val; } let mut prev = self.normalize(*new_indices.values().first().unwrap()).1; - for w in new_indices.values().windows(2) { + for (i, w) in new_indices.values().windows(2).enumerate() { let (is_valid, val) = self.normalize(w[1]); - let next = val - prev + last; + let next = match val.checked_sub(prev) { + Some(delta) => delta + last, + None => { + return Err(lance_core::Error::invalid_input(format!( + "corrupt binary page: normalized offset {} is less than previous offset {} \ + at index {}, null_adjustment={}, raw values were [{}, {}]. \ + This usually indicates the file data has been corrupted.", + val, prev, i, self.null_adjustment, w[0], w[1] + ))); + } + }; self.indices.push(next); self.validity.append(is_valid); prev = val; last = next; } + Ok(()) } fn into_parts(mut self) -> (Vec, BooleanBuffer) { @@ -201,11 +212,12 @@ impl PageScheduler for BinaryPageScheduler { let last = indices_builder .normalize(*curr_indices.values().last().unwrap()) .1; + if first != last { bytes_ranges.push(first..last); } - indices_builder.extend(&curr_indices, row_start == 0); + indices_builder.extend(&curr_indices, row_start == 0)?; } let (indices, validity) = indices_builder.into_parts(); From a7aba1f51667b0b11e1fafc157a09921ae4a306f Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Wed, 8 Apr 2026 13:30:19 -0700 Subject: [PATCH 159/206] fix(namespace): remove trailing empty query in table uri (#6415) Before this fix, it is possible for table uri to have a trailing ? because we do not clear the query component. --- .../lance-namespace-impls/src/dir/manifest.rs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index 4c4381e9165..7046f9cfc44 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -481,7 +481,7 @@ impl ManifestNamespace { base_url.set_path(&format!("{}/", base_url.path())); } - let full_url = base_url.join(relative_location).map_err(|e| { + let mut full_url = base_url.join(relative_location).map_err(|e| { lance_core::Error::from(NamespaceError::InvalidInput { message: format!( "Failed to join URI '{}' with '{}': {:?}", @@ -490,6 +490,11 @@ impl ManifestNamespace { }) })?; + // Clear any query string to avoid trailing "?" in the URL. + // Use set_query(None) instead of set_query("") because the latter + // would still add a trailing '?' to the URL when serialized. + full_url.set_query(None); + Ok(full_url.to_string()) } @@ -3551,6 +3556,25 @@ mod tests { trailing_slash_result, "s3://bucket/path/subdir/table.lance", "URL with existing trailing slash should still work" ); + + // Test that URLs with empty query string don't include trailing "?" + // This is important because URL::to_string() can add "?" for empty queries + let empty_query_result = + ManifestNamespace::construct_full_uri("s3://bucket/path?", "table.lance").unwrap(); + assert_eq!( + empty_query_result, "s3://bucket/path/table.lance", + "URL with empty query string should not include trailing '?'" + ); + + // Test that URLs with actual query parameters have them stripped + // (query parameters are not meaningful for storage paths) + let query_param_result = + ManifestNamespace::construct_full_uri("s3://bucket/path?param=value", "table.lance") + .unwrap(); + assert_eq!( + query_param_result, "s3://bucket/path/table.lance", + "URL with query parameters should have them stripped" + ); } /// Test that concurrent create_table calls for the same table name don't From 46650e6f47a37977940e25062f239df1f41d2e16 Mon Sep 17 00:00:00 2001 From: Pratik Dey Date: Thu, 9 Apr 2026 03:52:01 +0530 Subject: [PATCH 160/206] fix(merge_insert): use sentinel column for NULL-safe source row detection (#6439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source rows with NULL ON key columns were silently dropped because the action assignment logic used `ON_col IS NOT NULL` as a proxy for "source row is present in the join output". This conflates a legitimate NULL key with a NULL introduced by the outer join on the target side. Fix by injecting a `lit(true)` sentinel column into the source DataFrame before the join. After the join the sentinel is non-null for every source row and null only for target-only rows, making source row detection independent of ON column values. Strip the sentinel in `prepare_stream_schema` before writing and propagate it through projection pushdown in `necessary_children_exprs`. Before the join, inject a constant `lit(true)` column (`__merge_source_sentinel`) into every source row. After the join: - Source rows (whether matched or unmatched) → sentinel = true - Target-only rows (no source match) → sentinel = NULL (outer join NULL-fill) [assign_action](https://github.com/lance-format/lance/blob/6112a34bfe38618f07c099217dc3d89fd39ca6bb/rust/lance/src/dataset/write/merge_insert/assign_action.rs#L77) now uses sentinel IS NOT NULL to detect source row presence, making it correct regardless of what values the ON columns hold. The sentinel is a pure logical column — it never touches disk. It's stripped in [prepare_stream_schema](https://github.com/lance-format/lance/blob/6112a34bfe38618f07c099217dc3d89fd39ca6bb/rust/lance/src/dataset/write/merge_insert/exec/write.rs#L383) before any data is written, and [necessary_children_exprs](https://github.com/lance-format/lance/blob/6112a34bfe38618f07c099217dc3d89fd39ca6bb/rust/lance/src/dataset/write/merge_insert/logical_plan.rs#L148) is updated to propagate it through DataFusion's projection pushdown. Example that was broken before: ``` Target: (id=1, record_type="A") and (id=0, record_type=NULL) Source: (id=2, record_type=NULL) — new row, should be inserted ON: ["id", "record_type"] Old behavior: source row silently dropped (Action::Nothing) New behavior: source row correctly inserted (Action::Insert) ``` Fixes: https://github.com/lance-format/lance/issues/4644 --------- Signed-off-by: Pratik Co-authored-by: Will Jones Co-authored-by: Claude Sonnet 4.6 --- python/python/lance/dataset.py | 31 +-- rust/lance/src/dataset/write/merge_insert.rs | 259 ++++++++++++++++-- .../write/merge_insert/assign_action.rs | 39 +-- .../dataset/write/merge_insert/exec/write.rs | 8 +- .../write/merge_insert/logical_plan.rs | 8 +- 5 files changed, 275 insertions(+), 70 deletions(-) diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index 377ac546c3a..84293226eb3 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -298,12 +298,12 @@ def explain_plan( >>> print(plan) # doctest: +ELLIPSIS MergeInsert: on=[id], when_matched=UpdateAll, when_not_matched=InsertAll, ... CoalescePartitionsExec - ProjectionExec: expr=[_rowid@1 as _rowid, _rowaddr@2 as _rowaddr, ...] - ProjectionExec: expr=[id@2 IS NOT NULL as __common_expr_1, ...] - HashJoinExec: mode=CollectLeft, join_type=Right, ... - CooperativeExec - LanceRead: uri=test_dataset/data, projection=[id], ... - RepartitionExec: ... + ProjectionExec: expr=[...] + HashJoinExec: mode=CollectLeft, join_type=Right, ... + CooperativeExec + LanceRead: uri=test_dataset/data, projection=[id], ... + RepartitionExec: ... + ProjectionExec: expr=[..., true as __merge_source_sentinel] StreamingTableExec: partition_sizes=1, ... @@ -317,10 +317,9 @@ def explain_plan( >>> print(plan) # doctest: +ELLIPSIS MergeInsert: on=[id], when_matched=UpdateAll, when_not_matched=InsertAll, ... CoalescePartitionsExec - ProjectionExec: expr=[_rowid@1 as _rowid, _rowaddr@2 as _rowaddr, ...] - ProjectionExec: expr=[id@2 IS NOT NULL as __common_expr_1, ...] - HashJoinExec: mode=CollectLeft, join_type=Right, ... - ... + ProjectionExec: expr=[...] + HashJoinExec: mode=CollectLeft, join_type=Right, ... + ... """ return super(MergeInsertBuilder, self).explain_plan(schema, verbose=verbose) @@ -381,12 +380,12 @@ def analyze_plan( >>> print(analysis) # doctest: +ELLIPSIS MergeInsert: elapsed=..., on=[id], ..., metrics=[..., bytes_written=..., ...] CoalescePartitionsExec, elapsed=..., metrics=[output_rows=..., elapsed_compute=...] - ProjectionExec: elapsed=..., expr=[_rowid@1 as _rowid, ...], metrics=[...] - ProjectionExec: elapsed=..., expr=[id@2 IS NOT NULL as __common_expr_1, ...], metrics=[...] - HashJoinExec: elapsed=..., mode=CollectLeft, join_type=Right, ... - CooperativeExec, elapsed=..., metrics=[] - LanceRead: elapsed=..., ..., metrics=[..., bytes_read=..., ...] - RepartitionExec: ... + ProjectionExec: elapsed=..., expr=[...], metrics=[...] + HashJoinExec: elapsed=..., mode=CollectLeft, join_type=Right, ... + CooperativeExec, elapsed=..., metrics=[] + LanceRead: elapsed=..., ..., metrics=[..., bytes_read=..., ...] + RepartitionExec: ... + ProjectionExec: elapsed=..., expr=[..., true as __merge_source_sentinel], metrics=[...] StreamingTableExec: ..., metrics=[] The two key parts of the plan analysis are LanceRead and MergeInsert. diff --git a/rust/lance/src/dataset/write/merge_insert.rs b/rust/lance/src/dataset/write/merge_insert.rs index 290a83fb707..89b578126fa 100644 --- a/rust/lance/src/dataset/write/merge_insert.rs +++ b/rust/lance/src/dataset/write/merge_insert.rs @@ -18,6 +18,23 @@ // Internal column name for the merge action. Using "__action" to avoid collisions with user columns. const MERGE_ACTION_COLUMN: &str = "__action"; +// ## NULL-safe source row detection via sentinel column +// +// The merge join uses standard SQL equality for ON columns, which means NULL != NULL. +// After an outer join we need to know for each output row whether it came from the +// source side, the target side, or both. The naive approach — checking whether an ON +// column IS NOT NULL — is wrong: a source row whose ON column is legitimately NULL is +// indistinguishable from a NULL introduced by the outer join on the target side. +// +// Solution: inject a `lit(true)` sentinel into every source row *before* the join. +// After the join: +// - source rows (matched or unmatched) → sentinel = true (never NULL) +// - target-only rows → sentinel = NULL (outer-join fill) +// +// `assign_action` then uses `sentinel IS NOT NULL` instead of key-column IS NOT NULL +// to determine which side each row came from. The sentinel is stripped by +// `prepare_stream_schema` and never written to the dataset. +pub(super) const MERGE_SOURCE_SENTINEL: &str = "__merge_source_sentinel"; pub mod inserted_rows; @@ -1336,6 +1353,13 @@ impl MergeInsertJob { .collect::>(); let on_cols_refs = on_cols.iter().map(|s| s.as_str()).collect::>(); let source_df = session_ctx.read_one_shot(source)?; + // Inject a sentinel literal column so we can reliably determine, after the join, + // whether the source side contributed a row. This is NULL-safe: even when every + // ON column is NULL the sentinel lets us distinguish a source-only row from a + // target-only row (where the sentinel is filled with NULL by the outer join). + let source_df = source_df + .with_column(MERGE_SOURCE_SENTINEL, logical_expr::lit(true)) + .map_err(crate::Error::from)?; let source_df_aliased = source_df.alias("source")?; let scan_aliased = scan.alias("target")?; let join_type = self.create_plan_join_type(); @@ -2236,7 +2260,6 @@ mod tests { assert_plan_node_equals, assert_string_matches, }, }; - use arrow_array::RecordBatch; use arrow_array::builder::{ListBuilder, StringBuilder}; use arrow_array::types::Float32Type; use arrow_array::{ @@ -2244,6 +2267,7 @@ mod tests { RecordBatchIterator, RecordBatchReader, StringArray, StructArray, UInt32Array, types::{Int32Type, UInt32Type}, }; + use arrow_array::{RecordBatch, record_batch}; use arrow_buffer::{OffsetBuffer, ScalarBuffer}; use arrow_schema::{DataType, Field, Schema}; use arrow_select::concat::concat_batches; @@ -4209,21 +4233,22 @@ mod tests { // Assert the plan structure using portable plan matching // The optimized plan should have: // 1. FullSchemaMergeInsertExec at the top - // 2. ProjectionExec that creates action with key validation (source.key IS NOT NULL) - // 3. ProjectionExec that creates the common expression for key validation - // 4. HashJoin with projection optimization - // 5. LanceScan that only reads the key column (projection pushdown working!) + // 2. ProjectionExec that creates action based on _rowaddr nullness (sentinel is constant + // true so DataFusion folds `sentinel IS NOT NULL` away from the CASE expression) + // 3. HashJoin with projection that includes the sentinel column + // 4. LanceScan that only reads the key column (projection pushdown working!) + // 5. ProjectionExec on the source side that materializes the sentinel literal assert_plan_node_equals( plan, "MergeInsert: on=[key], when_matched=UpdateAll, when_not_matched=InsertAll, when_not_matched_by_source=Keep CoalescePartitionsExec - ProjectionExec: expr=[_rowid@1 as _rowid, _rowaddr@2 as _rowaddr, value@3 as value, key@4 as key, CASE WHEN __common_expr_1@0 AND _rowaddr@2 IS NULL THEN 2 WHEN __common_expr_1@0 AND _rowaddr@2 IS NOT NULL THEN 1 ELSE 0 END as __action] - ProjectionExec: expr=[key@3 IS NOT NULL as __common_expr_1, _rowid@0 as _rowid, _rowaddr@1 as _rowaddr, value@2 as value, key@3 as key] - HashJoinExec: mode=CollectLeft, join_type=Right, on=[(key@0, key@1)], projection=[_rowid@1, _rowaddr@2, value@3, key@4] - CooperativeExec - LanceRead: uri=..., projection=[key], num_fragments=1, range_before=None, range_after=None, \ - row_id=true, row_addr=true, full_filter=--, refine_filter=-- - RepartitionExec: partitioning=RoundRobinBatch(...), input_partitions=1 + ProjectionExec: expr=[_rowid@0 as _rowid, _rowaddr@1 as _rowaddr, value@2 as value, key@3 as key, __merge_source_sentinel@4 as __merge_source_sentinel, CASE WHEN _rowaddr@1 IS NULL THEN 2 WHEN _rowaddr@1 IS NOT NULL THEN 1 ELSE 0 END as __action] + HashJoinExec: mode=CollectLeft, join_type=Right, on=[(key@0, key@1)], projection=[_rowid@1, _rowaddr@2, value@3, key@4, __merge_source_sentinel@5] + CooperativeExec + LanceRead: uri=..., projection=[key], num_fragments=1, range_before=None, range_after=None, \ + row_id=true, row_addr=true, full_filter=--, refine_filter=-- + RepartitionExec: partitioning=RoundRobinBatch(...), input_partitions=1 + ProjectionExec: expr=[value@0 as value, key@1 as key, true as __merge_source_sentinel] StreamingTableExec: partition_sizes=1, projection=[value, key]" ).await.unwrap(); } @@ -4259,18 +4284,20 @@ mod tests { // This should use the fast path (execute_uncommitted_v2) let plan = merge_insert_job.create_plan(new_data_stream).await.unwrap(); - // The optimized plan should use Inner join instead of Right join - // since we're not inserting unmatched rows + // The optimized plan should use Inner join instead of Right join since we're not + // inserting unmatched rows. The sentinel IS NOT NULL condition is folded away by + // DataFusion because the sentinel is lit(true), so the CASE only checks _rowaddr. assert_plan_node_equals( plan, "MergeInsert: on=[key], when_matched=UpdateAll, when_not_matched=DoNothing, when_not_matched_by_source=Keep CoalescePartitionsExec - ProjectionExec: expr=[_rowid@0 as _rowid, _rowaddr@1 as _rowaddr, value@2 as value, key@3 as key, CASE WHEN key@3 IS NOT NULL AND _rowaddr@1 IS NOT NULL THEN 1 ELSE 0 END as __action] - HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(key@0, key@1)], projection=[_rowid@1, _rowaddr@2, value@3, key@4] + ProjectionExec: expr=[_rowid@0 as _rowid, _rowaddr@1 as _rowaddr, value@2 as value, key@3 as key, __merge_source_sentinel@4 as __merge_source_sentinel, CASE WHEN _rowaddr@1 IS NOT NULL THEN 1 ELSE 0 END as __action] + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(key@0, key@1)], projection=[_rowid@1, _rowaddr@2, value@3, key@4, __merge_source_sentinel@5] CooperativeExec LanceRead: uri=..., projection=[key], num_fragments=1, range_before=None, range_after=None, row_id=true, row_addr=true, full_filter=--, refine_filter=-- RepartitionExec... - StreamingTableExec: partition_sizes=1, projection=[value, key]" + ProjectionExec: expr=[value@0 as value, key@1 as key, true as __merge_source_sentinel] + StreamingTableExec: partition_sizes=1, projection=[value, key]" ).await.unwrap(); } @@ -4306,17 +4333,19 @@ mod tests { let plan = merge_insert_job.create_plan(new_data_stream).await.unwrap(); - // The optimized plan should use Inner join and include the UpdateIf condition + // The optimized plan should use Inner join and include the UpdateIf condition. + // The sentinel IS NOT NULL condition is folded away (sentinel is lit(true)). assert_plan_node_equals( plan, "MergeInsert: on=[key], when_matched=UpdateIf(source.value > 20), when_not_matched=DoNothing, when_not_matched_by_source=Keep CoalescePartitionsExec - ProjectionExec: expr=[_rowid@0 as _rowid, _rowaddr@1 as _rowaddr, value@2 as value, key@3 as key, CASE WHEN key@3 IS NOT NULL AND _rowaddr@1 IS NOT NULL AND value@2 > 20 THEN 1 ELSE 0 END as __action] - HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(key@0, key@1)], projection=[_rowid@1, _rowaddr@2, value@3, key@4] + ProjectionExec: expr=[_rowid@0 as _rowid, _rowaddr@1 as _rowaddr, value@2 as value, key@3 as key, __merge_source_sentinel@4 as __merge_source_sentinel, CASE WHEN _rowaddr@1 IS NOT NULL AND value@2 > 20 THEN 1 ELSE 0 END as __action] + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(key@0, key@1)], projection=[_rowid@1, _rowaddr@2, value@3, key@4, __merge_source_sentinel@5] CooperativeExec LanceRead: uri=..., projection=[key], num_fragments=1, range_before=None, range_after=None, row_id=true, row_addr=true, full_filter=--, refine_filter=-- RepartitionExec... - StreamingTableExec: partition_sizes=1, projection=[value, key]" + ProjectionExec: expr=[value@0 as value, key@1 as key, true as __merge_source_sentinel] + StreamingTableExec: partition_sizes=1, projection=[value, key]" ).await.unwrap(); } @@ -6445,6 +6474,194 @@ MergeInsert: on=[id], when_matched=UpdateAll, when_not_matched=InsertAll, when_n ); } + /// Test case for Issue #4644: merge_insert should NOT skip source rows whose ON + /// columns contain NULL. + /// + /// With standard SQL equality NULL != NULL, so a source row with a NULL key will + /// never match any target row. It must therefore be treated as "not matched" and + /// inserted when `when_not_matched = InsertAll`. The previous implementation + /// incorrectly required all ON columns to be non-null before even considering the + /// row, causing it to be silently dropped (Action::Nothing). + #[tokio::test] + async fn test_merge_insert_null_on_column_inserts() { + // Initial dataset: one row with a NULL record_type. + let initial_data = record_batch!( + ("id", Int32, [0]), + ("record_type", Utf8, [Option::<&str>::None]), + ("value", Int32, [10]) + ) + .unwrap(); + + let dataset = Dataset::write( + RecordBatchIterator::new(vec![Ok(initial_data.clone())], initial_data.schema()), + "memory://test_null_on_column", + None, + ) + .await + .unwrap(); + + // New data: a row with a different id AND a NULL record_type. + // Because id differs (2 vs 0) no match should be found even with NULL-safe + // semantics, so this row must be INSERTED. + let new_data = record_batch!( + ("id", Int32, [Some(2)]), + ("record_type", Utf8, [Option::<&str>::None]), + ("value", Int32, [Some(99)]) + ) + .unwrap(); + + let (merged_dataset, stats) = MergeInsertBuilder::try_new( + Arc::new(dataset), + vec!["id".to_string(), "record_type".to_string()], + ) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::InsertAll) + .try_build() + .unwrap() + .execute_reader(Box::new(RecordBatchIterator::new( + vec![Ok(new_data.clone())], + new_data.schema(), + ))) + .await + .unwrap(); + + // The source row (id=2, record_type=NULL) must be inserted, NOT silently skipped. + assert_eq!( + stats.num_inserted_rows, 1, + "row with NULL ON column should be inserted" + ); + assert_eq!(stats.num_updated_rows, 0, "no row should be updated"); + + let count = merged_dataset.count_rows(None).await.unwrap(); + assert_eq!( + count, 2, + "dataset should have the original row plus the newly inserted row" + ); + } + + /// Partial composite key match: the non-null part of the ON key (id) matches an + /// existing target row, but the second ON column (record_type) is NULL in the source. + /// Standard SQL equality treats NULL != NULL, so the composite key does NOT match + /// and the source row must be inserted, not updated and not silently dropped. + #[tokio::test] + async fn test_merge_insert_partial_composite_key_null() { + // Target: one row where id=1 and record_type="A". + let initial_data = record_batch!( + ("id", Int32, [Some(1)]), + ("record_type", Utf8, [Some("A")]), + ("value", Int32, [Some(10)]) + ) + .unwrap(); + + let dataset = Dataset::write( + RecordBatchIterator::new(vec![Ok(initial_data.clone())], initial_data.schema()), + "memory://test_partial_composite_null", + None, + ) + .await + .unwrap(); + + // Source: one row where id=1 (matches target) but record_type=NULL. + // The composite key (1, NULL) does NOT match (1, "A") under standard equality, + // so this is a "not matched" row that should be inserted. + let new_data = record_batch!( + ("id", Int32, [Some(1)]), + ("record_type", Utf8, [Option::<&str>::None]), + ("value", Int32, [Some(99)]) + ) + .unwrap(); + + let (merged_dataset, stats) = MergeInsertBuilder::try_new( + Arc::new(dataset), + vec!["id".to_string(), "record_type".to_string()], + ) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::InsertAll) + .try_build() + .unwrap() + .execute_reader(Box::new(RecordBatchIterator::new( + vec![Ok(new_data.clone())], + new_data.schema(), + ))) + .await + .unwrap(); + + // Source row (id=1, record_type=NULL) must be inserted, not updated and not dropped. + assert_eq!( + stats.num_inserted_rows, 1, + "row with partial NULL composite key should be inserted" + ); + assert_eq!( + stats.num_updated_rows, 0, + "existing (id=1, record_type=A) row must not be updated" + ); + + // Dataset: original (1, "A") row + newly inserted (1, NULL) row = 2 rows. + let count = merged_dataset.count_rows(None).await.unwrap(); + assert_eq!( + count, 2, + "both the original and the new row must be present" + ); + } + + /// Variant of test_merge_insert_null_on_column_inserts with a single ON column + /// that is entirely NULL, and a target row that also has a NULL in that column. + /// Since standard SQL equality treats NULL != NULL, the source row must not match + /// the existing target row and must be inserted separately. + #[tokio::test] + async fn test_merge_insert_null_single_on_column() { + // Dataset with a single row where id is NULL. + let initial_data = record_batch!( + ("id", Int32, [Option::::None]), + ("value", Int32, [Some(1)]) + ) + .unwrap(); + + let dataset = Dataset::write( + RecordBatchIterator::new(vec![Ok(initial_data.clone())], initial_data.schema()), + "memory://test_null_single_on_column", + None, + ) + .await + .unwrap(); + + // Source has two rows: one with id=NULL and one with id=5. + // id=NULL should not match the existing id=NULL row (standard equality), so it + // gets inserted. id=5 is a brand-new key and also gets inserted. + let new_data = record_batch!( + ("id", Int32, [Option::::None, Some(5)]), + ("value", Int32, [Some(99), Some(50)]) + ) + .unwrap(); + + let (merged_dataset, stats) = + MergeInsertBuilder::try_new(Arc::new(dataset), vec!["id".to_string()]) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::InsertAll) + .try_build() + .unwrap() + .execute_reader(Box::new(RecordBatchIterator::new( + vec![Ok(new_data.clone())], + new_data.schema(), + ))) + .await + .unwrap(); + + // Both source rows must be inserted (not silently dropped). + assert_eq!( + stats.num_inserted_rows, 2, + "both rows with NULL ON column should be inserted" + ); + assert_eq!(stats.num_updated_rows, 0); + + // Dataset now has: original NULL-id row + 2 newly inserted rows = 3 total. + let count = merged_dataset.count_rows(None).await.unwrap(); + assert_eq!(count, 3); + } + /// Test case for Issue #3634: merge_insert should provide a helpful error /// message when a subschema with a mismatched type is provided. #[tokio::test] diff --git a/rust/lance/src/dataset/write/merge_insert/assign_action.rs b/rust/lance/src/dataset/write/merge_insert/assign_action.rs index 75d8932a70c..46bda38873f 100644 --- a/rust/lance/src/dataset/write/merge_insert/assign_action.rs +++ b/rust/lance/src/dataset/write/merge_insert/assign_action.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use super::{MergeInsertParams, WhenNotMatchedBySource}; +use super::{MERGE_SOURCE_SENTINEL, MergeInsertParams, WhenNotMatchedBySource}; use crate::{Result, dataset::WhenMatched}; use datafusion::common::{ Column, TableReference, @@ -78,36 +78,21 @@ pub fn merge_insert_action( params: &MergeInsertParams, schema: Option<&arrow_schema::Schema>, ) -> Result { - // Check that at least one key column is non-null in the source - // This ensures we only process rows that have valid join keys - // Note: Column names are wrapped in double quotes to preserve case - // (DataFusion's col() function lowercases unquoted identifiers) - let source_has_key: Expr = if params.on.len() == 1 { - // Single key column case - check if the source key column is not null - // Need to qualify the column to avoid ambiguity between target.key and source.key - col(format!("source.\"{}\"", ¶ms.on[0])).is_not_null() - } else { - // Multiple key columns - require that ALL key columns are non-null - // This is a stricter requirement than "at least one" to ensure proper joins - let key_conditions: Vec = params - .on - .iter() - .map(|key| col(format!("source.\"{}\"", key)).is_not_null()) - .collect(); - - // Use AND to combine all key column checks (all must be non-null) - key_conditions - .into_iter() - .reduce(|acc, expr| acc.and(expr)) - .unwrap_or_else(|| datafusion_expr::lit(false)) - }; + // Use a sentinel column to detect whether the source side contributed a row to the + // join output. This is NULL-safe: the sentinel is `true` for every source row and + // is NULL-filled by the outer join for target-only rows, regardless of whether any + // ON column contains NULL. Using ON key columns for this purpose is incorrect + // because a key column that is legitimately NULL is indistinguishable from a NULL + // introduced by the outer join on the target side. + let source_has_row = col(format!("source.\"{}\"", MERGE_SOURCE_SENTINEL)).is_not_null(); let target_has_row = col("target._rowaddr").is_not_null(); - let matched = source_has_key.clone().and(target_has_row.clone()); + let matched = source_has_row.clone().and(target_has_row.clone()); - let source_only = source_has_key.clone().and(col("target._rowaddr").is_null()); + let source_only = source_has_row.and(col("target._rowaddr").is_null()); - let target_only = target_has_row.and(source_has_key.is_not_true()); + let target_only = + target_has_row.and(col(format!("source.\"{}\"", MERGE_SOURCE_SENTINEL)).is_null()); let mut cases = vec![]; diff --git a/rust/lance/src/dataset/write/merge_insert/exec/write.rs b/rust/lance/src/dataset/write/merge_insert/exec/write.rs index c9ac51e7116..703d4e1f6c8 100644 --- a/rust/lance/src/dataset/write/merge_insert/exec/write.rs +++ b/rust/lance/src/dataset/write/merge_insert/exec/write.rs @@ -29,7 +29,8 @@ use crate::dataset::write::merge_insert::inserted_rows::{ KeyExistenceFilter, KeyExistenceFilterBuilder, extract_key_value_from_batch, }; use crate::dataset::write::merge_insert::{ - SourceDedupeBehavior, create_duplicate_row_error, format_key_values_on_columns, + MERGE_SOURCE_SENTINEL, SourceDedupeBehavior, create_duplicate_row_error, + format_key_values_on_columns, }; use crate::{ Dataset, @@ -418,18 +419,19 @@ impl FullSchemaMergeInsertExec { // from the logical join, leaving us with the merged data columns plus special columns let total_fields = input_schema.fields().len(); - // Select all columns that are data columns (not _rowaddr or __action) + // Select all columns that are data columns (not _rowaddr, __action, or the sentinel) // These represent the final merged data values to write let data_column_indices: Vec = (0..total_fields) .filter(|&idx| { let field = input_schema.field(idx); let name = field.name(); - // Skip special columns: _rowaddr and __action + // Skip special columns: _rowaddr, __action, and the source-presence sentinel idx != rowaddr_idx && idx != action_idx && name != ROW_ADDR && name != ROW_ID && name != MERGE_ACTION_COLUMN + && name != MERGE_SOURCE_SENTINEL }) .collect(); diff --git a/rust/lance/src/dataset/write/merge_insert/logical_plan.rs b/rust/lance/src/dataset/write/merge_insert/logical_plan.rs index 84b97290dcf..00bae881e8a 100644 --- a/rust/lance/src/dataset/write/merge_insert/logical_plan.rs +++ b/rust/lance/src/dataset/write/merge_insert/logical_plan.rs @@ -19,7 +19,7 @@ use crate::dataset::write::merge_insert::exec::{ }; use crate::dataset::{WhenMatched, WhenNotMatchedBySource}; -use super::{MERGE_ACTION_COLUMN, MergeInsertParams}; +use super::{MERGE_ACTION_COLUMN, MERGE_SOURCE_SENTINEL, MergeInsertParams}; /// Logical plan node for merge insert write. /// @@ -163,11 +163,13 @@ impl UserDefinedLogicalNodeCore for MergeInsertWriteNode { for (i, (qualifier, field)) in input_schema.iter().enumerate() { let should_include = match qualifier { - // For delete-only: only include source KEY columns (for matching) - // For other ops: include all source columns - they contain the new data to write + // For delete-only: only include source KEY columns (for matching) plus the + // sentinel column needed for action determination. + // For other ops: include all source columns - they contain the new data to write. Some(qualifier) if qualifier.table() == "source" => { if no_upsert { self.params.on.iter().any(|k| k == field.name()) + || field.name() == MERGE_SOURCE_SENTINEL } else { true } From d503b8ebbe3a6e332e63668ae9e8345b1ed15934 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Wed, 8 Apr 2026 15:23:06 -0700 Subject: [PATCH 161/206] ci: add explicit permissions to GitHub Actions workflows (#6440) Most workflows lacked a `permissions` block, causing GitHub security warnings. Added `permissions: contents: read` at the top level for all affected workflows. Special cases: - `benchmark-comment-trigger`: also needs `pull-requests: read` to call the pulls REST API - `nightly_run`: `run` job needs `actions: write` to dispatch `file_verification.yml` - `rust`: `clippy` job-level permissions updated to include `contents: read` alongside `checks: write` - `cargo-publish`: `build` job updated to include `contents: read` alongside `id-token: write` Workflows already having correct permissions (`claude.yml`, `claude-code-review.yml`, `pr-title.yml`, `stale.yml`, `rust-benchmark.yml`, `docs-deploy.yml`, `codex-fix-ci.yml`, `codex-backport-pr.yml`, `file_verification.yml`, `cargo-publish.yml`) were left unchanged or minimally updated. --- .github/workflows/approve-rc.yml | 3 +++ .github/workflows/benchmark-comment-trigger.yml | 4 ++++ .github/workflows/benchmarks.yml | 3 +++ .github/workflows/buf-publish.yml | 3 +++ .github/workflows/cargo-publish.yml | 4 ++++ .github/workflows/ci-benchmarks.yml | 3 +++ .github/workflows/create-rc.yml | 3 +++ .github/workflows/create-release-branch.yml | 3 +++ .github/workflows/docs-check.yml | 3 +++ .github/workflows/java-publish.yml | 3 +++ .github/workflows/java.yml | 3 +++ .github/workflows/license-header-check.yml | 3 +++ .github/workflows/nightly_run.yml | 5 +++++ .github/workflows/notebook.yml | 3 +++ .github/workflows/publish-beta.yml | 3 +++ .github/workflows/pypi-publish.yml | 3 +++ .github/workflows/python.yml | 3 +++ .github/workflows/recurring-tests.yml | 3 +++ .github/workflows/rust.yml | 4 ++++ .github/workflows/typos.yml | 3 +++ 20 files changed, 65 insertions(+) diff --git a/.github/workflows/approve-rc.yml b/.github/workflows/approve-rc.yml index 82a1045d2aa..1ffff04c913 100644 --- a/.github/workflows/approve-rc.yml +++ b/.github/workflows/approve-rc.yml @@ -13,6 +13,9 @@ on: default: false type: boolean +permissions: + contents: read + jobs: approve-rc: runs-on: ubuntu-latest diff --git a/.github/workflows/benchmark-comment-trigger.yml b/.github/workflows/benchmark-comment-trigger.yml index ebda5ba8d2f..30aed29711d 100644 --- a/.github/workflows/benchmark-comment-trigger.yml +++ b/.github/workflows/benchmark-comment-trigger.yml @@ -13,6 +13,10 @@ on: issue_comment: types: [created] +permissions: + contents: read + pull-requests: read + jobs: forward-to-bench: # Only process comments on PRs that mention @bench-bot and contain 'benchmark' diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 0a543dd116d..7874d6fdee2 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -3,6 +3,9 @@ name: Run benchmarks on: workflow_dispatch: +permissions: + contents: read + jobs: dataset: timeout-minutes: 30 diff --git a/.github/workflows/buf-publish.yml b/.github/workflows/buf-publish.yml index 4546b9b6b60..b7b1acd8579 100644 --- a/.github/workflows/buf-publish.yml +++ b/.github/workflows/buf-publish.yml @@ -11,6 +11,9 @@ on: default: '' type: string +permissions: + contents: read + jobs: push-module: runs-on: ubuntu-latest diff --git a/.github/workflows/cargo-publish.yml b/.github/workflows/cargo-publish.yml index a46f686ec34..0ca2877475f 100644 --- a/.github/workflows/cargo-publish.yml +++ b/.github/workflows/cargo-publish.yml @@ -24,11 +24,15 @@ env: CARGO_INCREMENTAL: "0" RUSTFLAGS: "-C debuginfo=0" +permissions: + contents: read + jobs: build: # Needs additional disk space for the full build. runs-on: ubuntu-24.04-8x permissions: + contents: read id-token: write timeout-minutes: 60 env: diff --git a/.github/workflows/ci-benchmarks.yml b/.github/workflows/ci-benchmarks.yml index 3ed477f26d5..009c13fcbbd 100644 --- a/.github/workflows/ci-benchmarks.yml +++ b/.github/workflows/ci-benchmarks.yml @@ -6,6 +6,9 @@ on: branches: - main +permissions: + contents: read + jobs: bench_regress: timeout-minutes: 120 diff --git a/.github/workflows/create-rc.yml b/.github/workflows/create-rc.yml index d70e06f7ab3..1dee4c260b6 100644 --- a/.github/workflows/create-rc.yml +++ b/.github/workflows/create-rc.yml @@ -13,6 +13,9 @@ on: default: false type: boolean +permissions: + contents: read + jobs: create-rc: runs-on: ubuntu-latest diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml index 8b033913b6b..1a6fb9b78fb 100644 --- a/.github/workflows/create-release-branch.yml +++ b/.github/workflows/create-release-branch.yml @@ -14,6 +14,9 @@ on: default: false type: boolean +permissions: + contents: read + jobs: create-release-branch: runs-on: ubuntu-latest diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 903826bbf37..2eda3de66b4 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -13,6 +13,9 @@ on: - docs/** - .github/workflows/docs-check.yml +permissions: + contents: read + env: RUSTFLAGS: "-C debuginfo=0" # according to: https://matklad.github.io/2021/09/04/fast-rust-builds.html diff --git a/.github/workflows/java-publish.yml b/.github/workflows/java-publish.yml index 38dedf098ff..a51cf969a87 100644 --- a/.github/workflows/java-publish.yml +++ b/.github/workflows/java-publish.yml @@ -24,6 +24,9 @@ on: required: false type: string +permissions: + contents: read + jobs: linux-arm64: name: Build on Linux Arm64 diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index d6af4f6dff9..83403988244 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -20,6 +20,9 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: "1" +permissions: + contents: read + jobs: rust-clippy-fmt: runs-on: ubuntu-24.04 diff --git a/.github/workflows/license-header-check.yml b/.github/workflows/license-header-check.yml index b190136f6f1..488ca65e585 100644 --- a/.github/workflows/license-header-check.yml +++ b/.github/workflows/license-header-check.yml @@ -12,6 +12,9 @@ on: - rust/** - python/** - protos/** +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/nightly_run.yml b/.github/workflows/nightly_run.yml index 6806962a387..955dcec3860 100644 --- a/.github/workflows/nightly_run.yml +++ b/.github/workflows/nightly_run.yml @@ -5,10 +5,15 @@ on: - cron: "0 0 * * *" # Runs every day at midnight UTC workflow_dispatch: +permissions: + contents: read + jobs: run: runs-on: ubuntu-24.04 if: github.repository == 'lancedb/lance' + permissions: + actions: write steps: - name: Nightly Run File Verification Workflow uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1 diff --git a/.github/workflows/notebook.yml b/.github/workflows/notebook.yml index d4863b5c805..d428d7d8b58 100644 --- a/.github/workflows/notebook.yml +++ b/.github/workflows/notebook.yml @@ -15,6 +15,9 @@ on: - protos/** - notebooks/** +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/publish-beta.yml b/.github/workflows/publish-beta.yml index e7fe350ab0f..a3f92f4a8e0 100644 --- a/.github/workflows/publish-beta.yml +++ b/.github/workflows/publish-beta.yml @@ -14,6 +14,9 @@ on: default: false type: boolean +permissions: + contents: read + jobs: publish-beta: runs-on: ubuntu-latest diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 51c20a45bed..b2bfe284fb5 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -26,6 +26,9 @@ on: - ".github/workflows/build_windows_wheel/**" - ".github/workflows/upload_wheel/**" +permissions: + contents: read + jobs: linux: timeout-minutes: 60 diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index e0867ea9ef9..c60d8d4e8ce 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -19,6 +19,9 @@ on: - .github/workflows/build_mac_wheel/** - .github/workflows/run_tests/** +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/recurring-tests.yml b/.github/workflows/recurring-tests.yml index 57e619a0092..dd8205a9567 100644 --- a/.github/workflows/recurring-tests.yml +++ b/.github/workflows/recurring-tests.yml @@ -5,6 +5,9 @@ on: - cron: "0 0 * * 0" # Runs at 00:00 UTC every Sunday workflow_dispatch: +permissions: + contents: read + jobs: get-pylance-versions: runs-on: ubuntu-latest diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index fb495a68f94..a771a36af4c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -17,6 +17,9 @@ on: - Cargo.lock - deny.toml +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -53,6 +56,7 @@ jobs: clippy: permissions: + contents: read checks: write runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index d8916a34527..52f52e66f39 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -5,6 +5,9 @@ on: - main - release/** +permissions: + contents: read + jobs: run: name: Spell Check with Typos From 05c7ff91110af7931b7e1350addd6f8b8d2a65e3 Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Thu, 9 Apr 2026 13:56:06 +0800 Subject: [PATCH 162/206] feat(python): add blob-aware to_pandas (#6424) ## Feature ### What is the new feature? This PR adds a first-class blob-aware `to_pandas()` API to the Lance Python bindings on `LanceDataset`, `LanceScanner`, and `LanceFragment`. ### Why do we need this feature? Today, pandas export goes through `to_table().to_pandas()`, which is constrained by Arrow blob representations. That means blob columns either surface as descriptor structs or must be eagerly materialized as bytes before pandas conversion. For large blobs, eager materialization is the wrong default because it can pull a large amount of binary data into memory unexpectedly. ### How does it work? The new `to_pandas(*, blob_mode=...)` API keeps Arrow-facing behavior unchanged and adds pandas-specific blob handling: - `blob_mode="lazy"` (default) returns `lance.BlobFile` objects in pandas object columns. - `blob_mode="bytes"` eagerly reads blobs into Python `bytes`. - `blob_mode="descriptions"` preserves the old `to_table().to_pandas()` behavior. Implementation details: - Add Python-side blob helpers to detect top-level blob columns and map direct alias projections back to source blob columns. - Snapshot Python scanner builder options so `LanceScanner.to_pandas()` can reconstruct the same scan with `_rowaddr` and blob descriptions. - Rebuild the scan internally with `with_row_address=True` and `blob_handling="blobs_descriptions"`, convert non-blob columns through Arrow's `to_pandas()`, and backfill blob columns via `take_blobs(..., addresses=...)`. - Preserve Arrow APIs (`to_table()` / `blob_handling`) unchanged. - Raise a clear `NotImplementedError` for transformed blob projections that cannot be mapped back to a single source blob column. ## Testing - `cd python && uv run pytest python/tests/test_blob.py -q` - `cd python && uv run --extra dev ruff check python/lance/dataset.py python/lance/fragment.py python/tests/test_blob.py` --- python/python/lance/dataset.py | 289 ++++++++++++++++++++++++++++++- python/python/lance/fragment.py | 74 +++++++- python/python/tests/test_blob.py | 121 ++++++++++++- 3 files changed, 480 insertions(+), 4 deletions(-) diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index 84293226eb3..db2898851b8 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -30,6 +30,7 @@ Tuple, TypedDict, Union, + cast, ) import pyarrow as pa @@ -90,6 +91,140 @@ Iterable[float], ] LANCE_COMMIT_MESSAGE_KEY = "__lance_commit_message" +_BLOB_PANDAS_MODE_LAZY = "lazy" +_BLOB_PANDAS_MODE_BYTES = "bytes" +_BLOB_PANDAS_MODE_DESCRIPTIONS = "descriptions" +_BLOB_PANDAS_MODES = frozenset( + { + _BLOB_PANDAS_MODE_LAZY, + _BLOB_PANDAS_MODE_BYTES, + _BLOB_PANDAS_MODE_DESCRIPTIONS, + } +) +_BLOB_ROW_ADDR_COLUMN = "_rowaddr" + + +def _field_metadata_value(field: pa.Field, key: str) -> Optional[bytes]: + metadata = field.metadata + if metadata is None: + return None + return metadata.get(key.encode("utf-8")) + + +def _is_blob_field(field: pa.Field) -> bool: + return ( + _field_metadata_value(field, "lance-encoding:blob") == b"true" + or _field_metadata_value(field, "ARROW:extension:name") == b"lance.blob.v2" + ) + + +def _blob_columns_in_schema(schema: pa.Schema) -> set[str]: + return {field.name for field in schema if _is_blob_field(field)} + + +def _normalize_blob_pandas_mode( + blob_mode: str, +) -> Literal["lazy", "bytes", "descriptions"]: + if blob_mode not in _BLOB_PANDAS_MODES: + raise ValueError("blob_mode must be one of: 'lazy', 'bytes', 'descriptions'") + return cast("Literal['lazy', 'bytes', 'descriptions']", blob_mode) + + +def _simple_source_column(expr: str) -> Optional[str]: + expr = expr.strip() + if expr.startswith("`") and expr.endswith("`") and expr.count("`") == 2: + return expr[1:-1] + if "." in expr or any(ch.isspace() for ch in expr): + return None + if not expr: + return None + allowed = set("_$") + if not (expr[0].isalpha() or expr[0] == "_"): + return None + if any(not (ch.isalnum() or ch in allowed) for ch in expr[1:]): + return None + return expr + + +def _blob_column_sources( + schema: pa.Schema, + snapshot: Dict[str, Any], + dataset_schema: pa.Schema, +) -> dict[str, str]: + blob_columns = {} + output_blob_columns = [field.name for field in schema if _is_blob_field(field)] + columns_with_transform = snapshot.get("_columns_with_transform") + if not columns_with_transform: + return {name: name for name in output_blob_columns} + + source_is_blob = {field.name for field in dataset_schema if _is_blob_field(field)} + for name in output_blob_columns: + expr = dict(columns_with_transform).get(name) + source = _simple_source_column(expr) if expr is not None else name + if source is None or source not in source_is_blob: + raise NotImplementedError( + "blob-aware to_pandas only supports direct blob column references " + "for transformed projections" + ) + blob_columns[name] = source + return blob_columns + + +def _snapshot_scanner_builder(builder: "ScannerBuilder") -> Dict[str, Any]: + """Capture Python-side scanner config needed to rebuild the scan later. + + The native scanner object does not preserve the original builder arguments. + We need these values to recreate the same scan when `to_pandas` switches blob + handling modes or injects `_rowaddr` for lazy blob export. + """ + + def snapshot_value(value: Any) -> Any: + try: + return copy.deepcopy(value) + except (TypeError, AttributeError): + return value + + return { + key: snapshot_value(value) + for key, value in vars(builder).items() + if key != "ds" + } + + +def _scanner_from_snapshot( + ds: "LanceDataset", snapshot: Dict[str, Any] +) -> "LanceScanner": + builder = ScannerBuilder(ds) + for key, value in snapshot.items(): + if key == "_columns" and value is not None: + setattr(builder, key, list(value)) + elif key == "_columns_with_transform" and value is not None: + setattr(builder, key, list(value)) + elif key == "_fragments" and value is not None: + setattr(builder, key, list(value)) + elif key == "_orderings" and value is not None: + setattr(builder, key, list(value)) + else: + setattr(builder, key, value) + return builder.to_scanner() + + +def _is_null_blob_description(description: Any) -> bool: + if description is None: + return True + if not isinstance(description, dict): + return False + if description.keys() == {"position", "size"}: + return description["position"] == 1 and description["size"] == 0 + if description.keys() == {"kind", "position", "size", "blob_id", "blob_uri"}: + return ( + description["kind"] == 0 + and description["position"] == 0 + and description["size"] == 0 + and description["blob_id"] == 0 + and description["blob_uri"] == "" + ) + return False class MergeInsertBuilder(_MergeInsertBuilder): @@ -1160,6 +1295,73 @@ def to_table( disable_scoring_autoprojection=disable_scoring_autoprojection, ).to_table() + def to_pandas( + self, + columns: Optional[Union[List[str], Dict[str, str]]] = None, + filter: Optional[Union[str, pa.compute.Expression]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + nearest: Optional[dict] = None, + batch_size: Optional[int] = None, + batch_readahead: Optional[int] = None, + fragment_readahead: Optional[int] = None, + scan_in_order: Optional[bool] = None, + *, + prefilter: Optional[bool] = None, + with_row_id: Optional[bool] = None, + with_row_address: Optional[bool] = None, + use_stats: Optional[bool] = None, + fast_search: Optional[bool] = None, + full_text_query: Optional[Union[str, dict, FullTextQuery]] = None, + io_buffer_size: Optional[int] = None, + late_materialization: Optional[bool | List[str]] = None, + blob_mode: str = _BLOB_PANDAS_MODE_LAZY, + use_scalar_index: Optional[bool] = None, + include_deleted_rows: Optional[bool] = None, + order_by: Optional[List[ColumnOrdering]] = None, + disable_scoring_autoprojection: Optional[bool] = None, + **kwargs, + ) -> "pd.DataFrame": + """Read the data into a :py:class:`pandas.DataFrame`. + + Parameters are the same as :meth:`to_table`, except pandas export uses + ``blob_mode`` instead of Arrow-facing ``blob_handling``. + + Parameters + ---------- + blob_mode: str, default "lazy" + Controls how blob columns are returned. + + - ``"lazy"``: return :class:`lance.BlobFile` objects + - ``"bytes"``: return Python ``bytes`` + - ``"descriptions"``: preserve ``to_table().to_pandas()`` behavior + **kwargs + Forwarded to :meth:`pyarrow.Table.to_pandas` for non-blob columns. + """ + return self.scanner( + columns=columns, + filter=filter, + limit=limit, + offset=offset, + nearest=nearest, + batch_size=batch_size, + io_buffer_size=io_buffer_size, + batch_readahead=batch_readahead, + fragment_readahead=fragment_readahead, + late_materialization=late_materialization, + use_scalar_index=use_scalar_index, + scan_in_order=scan_in_order, + prefilter=prefilter, + with_row_id=with_row_id, + with_row_address=with_row_address, + use_stats=use_stats, + fast_search=fast_search, + full_text_query=full_text_query, + include_deleted_rows=include_deleted_rows, + order_by=order_by, + disable_scoring_autoprojection=disable_scoring_autoprojection, + ).to_pandas(blob_mode=blob_mode, **kwargs) + @property def partition_expression(self): """ @@ -5422,13 +5624,19 @@ def to_scanner(self) -> LanceScanner: self._disable_scoring_autoprojection, self._substrait_aggregate, ) - return LanceScanner(scanner, self.ds) + return LanceScanner(scanner, self.ds, _snapshot_scanner_builder(self)) class LanceScanner(pa.dataset.Scanner): - def __init__(self, scanner: _Scanner, dataset: LanceDataset): + def __init__( + self, + scanner: _Scanner, + dataset: LanceDataset, + snapshot: Optional[Dict[str, Any]] = None, + ): self._scanner = scanner self._ds = dataset + self._snapshot = snapshot def to_table(self) -> pa.Table: """ @@ -5442,6 +5650,83 @@ def to_reader(self) -> pa.RecordBatchReader: def to_batches(self) -> Iterator[RecordBatch]: yield from self.to_reader() + def to_pandas( + self, *, blob_mode: str = _BLOB_PANDAS_MODE_LAZY, **kwargs: Any + ) -> "pd.DataFrame": + """Read the scan results into a :py:class:`pandas.DataFrame`. + + ``blob_mode`` is pandas-specific and does not replace Arrow's + ``blob_handling`` setting used by :meth:`to_table`. + """ + blob_mode = _normalize_blob_pandas_mode(blob_mode) + schema = self.projected_schema + blob_columns = _blob_columns_in_schema(schema) + if not blob_columns or blob_mode == _BLOB_PANDAS_MODE_DESCRIPTIONS: + return self.to_table().to_pandas(**kwargs) + + if self._snapshot is None: + raise NotImplementedError( + "blob-aware to_pandas requires a scanner created from the Python API" + ) + + snapshot = dict(self._snapshot) + if blob_mode == _BLOB_PANDAS_MODE_BYTES: + snapshot["_blob_handling"] = "all_binary" + return ( + _scanner_from_snapshot(self._ds, snapshot) + .to_table() + .to_pandas(**kwargs) + ) + + blob_sources = _blob_column_sources(schema, self._snapshot, self._ds.schema) + snapshot["_with_row_address"] = True + snapshot["_blob_handling"] = "blobs_descriptions" + table = _scanner_from_snapshot(self._ds, snapshot).to_table() + + requested_rowaddr = bool(self._snapshot.get("_with_row_address", False)) + if _BLOB_ROW_ADDR_COLUMN not in table.schema.names: + raise RuntimeError("blob-aware to_pandas expected _rowaddr in scan results") + + row_addrs = table.column(_BLOB_ROW_ADDR_COLUMN).to_pylist() + columns_to_drop = [name for name in blob_columns if name in table.schema.names] + if not requested_rowaddr: + columns_to_drop.append(_BLOB_ROW_ADDR_COLUMN) + non_blob_table = ( + table.drop_columns(columns_to_drop) if columns_to_drop else table + ) + if non_blob_table.num_columns == 0: + dataframe = pd.DataFrame(index=range(table.num_rows)) + else: + dataframe = non_blob_table.to_pandas(**kwargs) + + output_names = [field.name for field in schema] + for index, name in enumerate(output_names): + if name not in blob_columns: + continue + + descriptions = table.column(name).to_pylist() + non_null_positions = [ + pos + for pos, description in enumerate(descriptions) + if not _is_null_blob_description(description) + ] + non_null_addrs = [row_addrs[pos] for pos in non_null_positions] + blob_files = ( + self._ds.take_blobs(blob_sources[name], addresses=non_null_addrs) + if non_null_addrs + else [] + ) + blob_iter = iter(blob_files) + values = [] + for description in descriptions: + if _is_null_blob_description(description): + values.append(None) + continue + values.append(next(blob_iter)) + dataframe.insert(index, name, values) + + return dataframe + @property def projected_schema(self) -> Schema: return self._scanner.schema diff --git a/python/python/lance/fragment.py b/python/python/lance/fragment.py index deeb141a73f..8e762e24fdf 100644 --- a/python/python/lance/fragment.py +++ b/python/python/lance/fragment.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import ( TYPE_CHECKING, + Any, Callable, Dict, Iterator, @@ -531,7 +532,40 @@ def scanner( ) from .dataset import LanceScanner - return LanceScanner(s, self._ds) + snapshot = { + "_limit": limit, + "_filter": filter_str, + "_search_filter": None, + "_substrait_filter": None, + "_prefilter": False, + "_late_materialization": None, + "_blob_handling": blob_handling, + "_offset": offset, + "_columns": tuple(columns) if isinstance(columns, list) else None, + "_columns_with_transform": ( + tuple(columns.items()) if isinstance(columns, dict) else None + ), + "_nearest": None, + "_batch_size": batch_size, + "_io_buffer_size": None, + "_batch_readahead": batch_readahead, + "_fragment_readahead": None, + "_scan_in_order": True, + "_fragments": (self._fragment,), + "_with_row_id": with_row_id, + "_with_row_address": with_row_address, + "_use_stats": True, + "_fast_search": False, + "_full_text_query": None, + "_use_scalar_index": None, + "_include_deleted_rows": None, + "_scan_stats_callback": None, + "_strict_batch_size": False, + "_orderings": tuple(order_by) if order_by is not None else None, + "_disable_scoring_autoprojection": False, + "_substrait_aggregate": None, + } + return LanceScanner(s, self._ds, snapshot=snapshot) def open_session( self, @@ -615,6 +649,44 @@ def to_table( order_by=order_by, ).to_table() + def to_pandas( + self, + columns: Optional[Union[List[str], Dict[str, str]]] = None, + filter: Optional[Union[str, pa.compute.Expression]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + with_row_id: bool = False, + with_row_address: bool = False, + blob_mode: str = "lazy", + order_by: Optional[List[ColumnOrdering]] = None, + **kwargs: Any, + ) -> Any: + """Read this fragment into a :py:class:`pandas.DataFrame`. + + Parameters are the same as :meth:`to_table`, except pandas export uses + ``blob_mode`` instead of Arrow-facing ``blob_handling``. + + Parameters + ---------- + blob_mode: str, default "lazy" + Controls how blob columns are returned. + + - ``"lazy"``: return :class:`lance.BlobFile` objects + - ``"bytes"``: return Python ``bytes`` + - ``"descriptions"``: preserve ``to_table().to_pandas()`` behavior + **kwargs + Forwarded to :meth:`pyarrow.Table.to_pandas` for non-blob columns. + """ + return self.scanner( + columns=columns, + filter=filter, + limit=limit, + offset=offset, + with_row_id=with_row_id, + with_row_address=with_row_address, + order_by=order_by, + ).to_pandas(blob_mode=blob_mode, **kwargs) + def merge( self, data_obj: ReaderLike, diff --git a/python/python/tests/test_blob.py b/python/python/tests/test_blob.py index 4e4633b222f..01374a62b8e 100644 --- a/python/python/tests/test_blob.py +++ b/python/python/tests/test_blob.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors +import importlib import io import subprocess import sys @@ -10,7 +11,9 @@ import lance import pyarrow as pa import pytest -from lance import Blob, BlobColumn, DatasetBasePath +from lance import Blob, BlobColumn, BlobFile, DatasetBasePath + +lance_dataset_module = importlib.import_module("lance.dataset") def test_blob_read_from_binary(): @@ -544,3 +547,119 @@ def test_blob_extension_take_blobs_multi_base(payload, is_dataset_root, tmp_path assert len(blobs) == 1 with blobs[0] as f: assert f.read() == payload + + +@pytest.fixture +def dataset_for_pandas_blob_tests(tmp_path): + table = pa.table( + { + "id": pa.array([1, 2, 3], pa.int64()), + "blob": pa.array([b"hello", None, b"world"], pa.large_binary()), + "bin": pa.array([b"x", b"y", b"z"], pa.large_binary()), + }, + schema=pa.schema( + [ + pa.field("id", pa.int64()), + pa.field( + "blob", pa.large_binary(), metadata={"lance-encoding:blob": "true"} + ), + pa.field("bin", pa.large_binary()), + ] + ), + ) + return lance.write_dataset(table, tmp_path / "blob_pandas_ds") + + +def test_dataset_to_pandas_blob_lazy(dataset_for_pandas_blob_tests): + df = dataset_for_pandas_blob_tests.to_pandas() + + assert list(df.columns) == ["id", "blob", "bin"] + assert isinstance(df.iloc[0]["blob"], BlobFile) + assert df.iloc[1]["blob"] is None + assert isinstance(df.iloc[2]["blob"], BlobFile) + assert df["bin"].tolist() == [b"x", b"y", b"z"] + assert [df.iloc[0]["blob"].readall(), df.iloc[2]["blob"].readall()] == [ + b"hello", + b"world", + ] + + +def test_dataset_to_pandas_blob_bytes(dataset_for_pandas_blob_tests): + df = dataset_for_pandas_blob_tests.to_pandas(blob_mode="bytes") + + assert list(df.columns) == ["id", "blob", "bin"] + assert df["blob"].tolist() == [b"hello", None, b"world"] + assert df["bin"].tolist() == [b"x", b"y", b"z"] + + +def test_dataset_to_pandas_blob_descriptions(dataset_for_pandas_blob_tests): + descriptions_df = dataset_for_pandas_blob_tests.to_pandas(blob_mode="descriptions") + table_df = dataset_for_pandas_blob_tests.to_table().to_pandas() + + assert descriptions_df.equals(table_df) + + +def test_scanner_to_pandas_blob_alias(dataset_for_pandas_blob_tests): + df = dataset_for_pandas_blob_tests.scanner( + columns={"video": "blob", "id": "id"} + ).to_pandas() + + assert list(df.columns) == ["video", "id"] + assert isinstance(df.iloc[0]["video"], BlobFile) + assert df.iloc[1]["video"] is None + assert df.iloc[2]["video"].readall() == b"world" + + +def test_scanner_to_pandas_blob_filter_limit_order(dataset_for_pandas_blob_tests): + df = dataset_for_pandas_blob_tests.scanner( + columns=["id", "blob"], + filter="id > 1", + limit=1, + order_by=["id"], + ).to_pandas(blob_mode="bytes") + + assert list(df.columns) == ["id", "blob"] + assert df["id"].tolist() == [2] + assert df["blob"].tolist() == [None] + + +def test_scanner_to_pandas_blob_empty_result(dataset_for_pandas_blob_tests): + df = dataset_for_pandas_blob_tests.scanner( + columns=["id", "blob"], filter="id > 10" + ).to_pandas() + + assert list(df.columns) == ["id", "blob"] + assert df.empty + + +def test_fragment_to_pandas_blob(dataset_for_pandas_blob_tests): + fragment = dataset_for_pandas_blob_tests.get_fragments()[0] + df = fragment.to_pandas(columns=["id", "blob"], blob_mode="bytes") + + assert list(df.columns) == ["id", "blob"] + assert df["blob"].tolist() == [b"hello", None, b"world"] + + +def test_dataset_to_pandas_invalid_blob_mode(dataset_for_pandas_blob_tests): + with pytest.raises(ValueError, match="blob_mode must be one of"): + dataset_for_pandas_blob_tests.to_pandas(blob_mode="inline") + + +def test_blob_column_sources_rejects_unmappable_transform( + dataset_for_pandas_blob_tests, +): + projected_schema = pa.schema( + [ + pa.field( + "video", + pa.large_binary(), + metadata={"lance-encoding:blob": "true"}, + ) + ] + ) + snapshot = {"_columns_with_transform": (("video", "concat(blob, blob)"),)} + + with pytest.raises(NotImplementedError, match="direct blob column references"): + lance_dataset_module._blob_column_sources( + projected_schema, snapshot, dataset_for_pandas_blob_tests.schema + ) From 9b177ad0b46ddb8c0a3edd9f37227a93a8aba3cd Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Thu, 9 Apr 2026 06:30:06 +0000 Subject: [PATCH 163/206] chore: release beta version 5.0.0-beta.6 --- .bumpversion.toml | 2 +- Cargo.lock | 96 +++++++++++++++++++-------------------- Cargo.toml | 40 ++++++++-------- java/lance-jni/Cargo.lock | 51 +++++++++++---------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 54 +++++++++++----------- python/Cargo.toml | 2 +- 8 files changed, 125 insertions(+), 124 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index f87a7794fc2..4371ccd0489 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "5.0.0-beta.5" +current_version = "5.0.0-beta.6" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index fb4260f4d0b..eeb522dd560 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,9 +181,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -529,9 +529,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -611,7 +611,7 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.3.0", + "fastrand 2.4.1", "hex", "http 1.4.0", "ring", @@ -673,7 +673,7 @@ dependencies = [ "aws-types", "bytes", "bytes-utils", - "fastrand 2.3.0", + "fastrand 2.4.1", "http 0.2.12", "http 1.4.0", "http-body 0.4.6", @@ -701,7 +701,7 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.3.0", + "fastrand 2.4.1", "http 0.2.12", "http 1.4.0", "regex-lite", @@ -729,7 +729,7 @@ dependencies = [ "aws-smithy-xml", "aws-types", "bytes", - "fastrand 2.3.0", + "fastrand 2.4.1", "hex", "hmac", "http 0.2.12", @@ -760,7 +760,7 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.3.0", + "fastrand 2.4.1", "http 0.2.12", "http 1.4.0", "regex-lite", @@ -784,7 +784,7 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.3.0", + "fastrand 2.4.1", "http 0.2.12", "http 1.4.0", "regex-lite", @@ -809,7 +809,7 @@ dependencies = [ "aws-smithy-types", "aws-smithy-xml", "aws-types", - "fastrand 2.3.0", + "fastrand 2.4.1", "http 0.2.12", "http 1.4.0", "regex-lite", @@ -969,7 +969,7 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "bytes", - "fastrand 2.3.0", + "fastrand 2.4.1", "http 0.2.12", "http 1.4.0", "http-body 0.4.6", @@ -1215,7 +1215,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "fastrand 2.3.0", + "fastrand 2.4.1", "gloo-timers", "tokio", ] @@ -3213,9 +3213,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" @@ -3348,7 +3348,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "lance-datagen", @@ -3442,7 +3442,7 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand 2.3.0", + "fastrand 2.4.1", "futures-core", "futures-io", "parking", @@ -4580,7 +4580,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "all_asserts", "approx", @@ -4676,7 +4676,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", @@ -4726,7 +4726,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrayref", "paste", @@ -4735,7 +4735,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", @@ -4775,7 +4775,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -4809,7 +4809,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -4829,7 +4829,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", @@ -4877,7 +4877,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "all_asserts", "arrow", @@ -4903,7 +4903,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", @@ -4946,7 +4946,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "datafusion", "geo-traits", @@ -4960,7 +4960,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "approx", "arrow", @@ -5041,7 +5041,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-arith", @@ -5092,7 +5092,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "approx", "arrow-array", @@ -5113,7 +5113,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "async-trait", @@ -5128,7 +5128,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -5146,7 +5146,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-ipc", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "proc-macro2", "quote", @@ -5260,7 +5260,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "arrow-schema", @@ -5271,7 +5271,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "clap", "lance-core", @@ -6551,7 +6551,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "fastrand 2.3.0", + "fastrand 2.4.1", "phf_shared 0.13.1", ] @@ -6612,7 +6612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", - "fastrand 2.3.0", + "fastrand 2.4.1", "futures-io", ] @@ -8301,9 +8301,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symbolic-common" -version = "12.17.3" +version = "12.17.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ca086c1eb5c7ee74b151ba83c6487d5d33f8c08ad991b86f3f58f6629e68d5" +checksum = "803d14d7cb9c6fa5b95a6f3de8af95b356a528d391998fa45a07d320a5573e51" dependencies = [ "debugid", "memmap2", @@ -8313,9 +8313,9 @@ dependencies = [ [[package]] name = "symbolic-demangle" -version = "12.17.3" +version = "12.17.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa911a28a62823aaf2cc2e074212492a3ee69d0d926cc8f5b12b4a108ff5c0c" +checksum = "39505731ae891b2dde47b0e4ae2ec40a7ced3476ab1129f1bf829e3fba62bb83" dependencies = [ "cpp_demangle", "rustc-demangle", @@ -8560,7 +8560,7 @@ version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand 2.3.0", + "fastrand 2.4.1", "getrandom 0.4.2", "once_cell", "rustix 1.1.4", @@ -8777,9 +8777,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.51.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -8858,9 +8858,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.10+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", diff --git a/Cargo.toml b/Cargo.toml index fe97eb3f5a0..c40fcb2a1ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ resolver = "3" [workspace.package] -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -53,24 +53,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=5.0.0-beta.5", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=5.0.0-beta.5", path = "./rust/lance-arrow" } -lance-core = { version = "=5.0.0-beta.5", path = "./rust/lance-core" } -lance-datafusion = { version = "=5.0.0-beta.5", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=5.0.0-beta.5", path = "./rust/lance-datagen" } -lance-encoding = { version = "=5.0.0-beta.5", path = "./rust/lance-encoding" } -lance-file = { version = "=5.0.0-beta.5", path = "./rust/lance-file" } -lance-geo = { version = "=5.0.0-beta.5", path = "./rust/lance-geo" } -lance-index = { version = "=5.0.0-beta.5", path = "./rust/lance-index" } -lance-io = { version = "=5.0.0-beta.5", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=5.0.0-beta.5", path = "./rust/lance-linalg" } -lance-namespace = { version = "=5.0.0-beta.5", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=5.0.0-beta.5", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=5.0.0-beta.5", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=5.0.0-beta.6", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=5.0.0-beta.6", path = "./rust/lance-arrow" } +lance-core = { version = "=5.0.0-beta.6", path = "./rust/lance-core" } +lance-datafusion = { version = "=5.0.0-beta.6", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=5.0.0-beta.6", path = "./rust/lance-datagen" } +lance-encoding = { version = "=5.0.0-beta.6", path = "./rust/lance-encoding" } +lance-file = { version = "=5.0.0-beta.6", path = "./rust/lance-file" } +lance-geo = { version = "=5.0.0-beta.6", path = "./rust/lance-geo" } +lance-index = { version = "=5.0.0-beta.6", path = "./rust/lance-index" } +lance-io = { version = "=5.0.0-beta.6", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=5.0.0-beta.6", path = "./rust/lance-linalg" } +lance-namespace = { version = "=5.0.0-beta.6", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=5.0.0-beta.6", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=5.0.0-beta.6", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=5.0.0-beta.5", path = "./rust/lance-table" } -lance-test-macros = { version = "=5.0.0-beta.5", path = "./rust/lance-test-macros" } -lance-testing = { version = "=5.0.0-beta.5", path = "./rust/lance-testing" } +lance-table = { version = "=5.0.0-beta.6", path = "./rust/lance-table" } +lance-test-macros = { version = "=5.0.0-beta.6", path = "./rust/lance-test-macros" } +lance-testing = { version = "=5.0.0-beta.6", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -97,7 +97,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=5.0.0-beta.5", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=5.0.0-beta.6", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -138,7 +138,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=5.0.0-beta.5", path = "./rust/compression/fsst" } +fsst = { version = "=5.0.0-beta.6", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 3417984f26b..d24c48ddb48 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -139,9 +139,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -2310,9 +2310,9 @@ checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -2398,7 +2398,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3397,7 +3397,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-arith", @@ -3463,12 +3463,13 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", "arrow-cast", "arrow-data", + "arrow-ipc", "arrow-ord", "arrow-schema", "arrow-select", @@ -3483,7 +3484,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrayref", "paste", @@ -3492,7 +3493,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", @@ -3529,7 +3530,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -3562,7 +3563,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -3580,7 +3581,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", @@ -3617,7 +3618,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", @@ -3649,7 +3650,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "datafusion", "geo-traits", @@ -3663,7 +3664,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-arith", @@ -3731,7 +3732,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-arith", @@ -3775,7 +3776,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -3811,7 +3812,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", @@ -3827,7 +3828,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "async-trait", @@ -3840,7 +3841,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-ipc", @@ -3885,7 +3886,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -6363,9 +6364,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -6434,9 +6435,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.10+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index a20df585e9a..2cffb73e4f2 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index cf04d17547e..769eb369212 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 5.0.0-beta.5 + 5.0.0-beta.6 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index 7b60e0645e8..eccaf1f2c77 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -202,9 +202,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -2728,9 +2728,9 @@ checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" @@ -2827,7 +2827,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3865,7 +3865,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-arith", @@ -3932,7 +3932,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", @@ -3953,7 +3953,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrayref", "paste", @@ -3962,7 +3962,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", @@ -3999,7 +3999,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -4032,7 +4032,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -4050,7 +4050,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", @@ -4087,7 +4087,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", @@ -4119,7 +4119,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "datafusion", "geo-traits", @@ -4133,7 +4133,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-arith", @@ -4204,7 +4204,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-arith", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", @@ -4264,7 +4264,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "async-trait", @@ -4277,7 +4277,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-ipc", @@ -4322,7 +4322,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -4494,9 +4494,9 @@ dependencies = [ [[package]] name = "liblzma-sys" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f2db66f3268487b5033077f266da6777d057949b8f93c8ad82e441df25e6186" +checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" dependencies = [ "cc", "libc", @@ -5686,7 +5686,7 @@ dependencies = [ [[package]] name = "pylance" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -7320,9 +7320,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -7391,9 +7391,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.10+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", diff --git a/python/Cargo.toml b/python/Cargo.toml index 0c6fe92b045..3e03ec20290 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "5.0.0-beta.5" +version = "5.0.0-beta.6" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 5f4bf81a3624865de411cf0e154b55bedbaee564 Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Thu, 9 Apr 2026 17:03:00 +0800 Subject: [PATCH 164/206] feat: add configurable FTS index prewarm options (#6298) ## Summary - add `PrewarmOptions` and `FtsPrewarmOptions` on the Rust side, with dataset plumbing for `prewarm_index_with_options` - add Python `prewarm_index(..., *, with_position=False)` support for FTS indices while keeping the default prewarm path unchanged --- python/python/lance/dataset.py | 8 +- python/python/lance/lance/__init__.pyi | 2 +- python/python/tests/test_scalar_index.py | 33 +++- python/src/dataset.rs | 20 +- rust/lance-index/src/scalar/inverted/index.rs | 158 ++++++++++++++-- rust/lance-index/src/traits.rs | 26 +++ rust/lance/src/dataset/tests/dataset_index.rs | 178 ++++++++++++++++++ rust/lance/src/index.rs | 39 +++- rust/lance/src/index/api.rs | 13 +- 9 files changed, 448 insertions(+), 29 deletions(-) diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index db2898851b8..b4aaa89ff2c 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -3719,7 +3719,7 @@ def drop_index(self, name: str): """ return self._ds.drop_index(name) - def prewarm_index(self, name: str): + def prewarm_index(self, name: str, *, with_position: bool = False): """ Prewarm an index @@ -3731,8 +3731,12 @@ def prewarm_index(self, name: str): ---------- name: str The name of the index to prewarm. + with_position: bool, default False + This is only supported for ``INVERTED`` indices. If True, positions are + also loaded into the cache during prewarm so phrase queries do not need a + separate lazy positions read. """ - return self._ds.prewarm_index(name) + return self._ds.prewarm_index(name, with_position=with_position) def merge_index_metadata( self, diff --git a/python/python/lance/lance/__init__.pyi b/python/python/lance/lance/__init__.pyi index 834b2eef7d8..60398f9a3ae 100644 --- a/python/python/lance/lance/__init__.pyi +++ b/python/python/lance/lance/__init__.pyi @@ -361,7 +361,7 @@ class _Dataset: kwargs: Optional[Dict[str, Any]] = None, ): ... def drop_index(self, name: str): ... - def prewarm_index(self, name: str): ... + def prewarm_index(self, name: str, *, with_position: bool = False): ... def merge_index_metadata( self, index_uuid: str, diff --git a/python/python/tests/test_scalar_index.py b/python/python/tests/test_scalar_index.py index 4b7cdc4b7fd..ce338660118 100644 --- a/python/python/tests/test_scalar_index.py +++ b/python/python/tests/test_scalar_index.py @@ -293,7 +293,7 @@ def test_create_inverted_index_progress_callback(tmp_path): tokenize_progress = stage_progress_values(progress_recorder.events, "tokenize_docs") assert tokenize_progress - assert tokenize_progress[-1] == ds.count_rows() + assert max(tokenize_progress) == ds.count_rows() assert "progress:copy_partitions" in tags assert "progress:write_metadata" in tags @@ -2467,6 +2467,37 @@ def scan_stats_callback(stats: lance.ScanStatistics): ).to_table() assert scan_stats.parts_loaded == 0 + phrase_path = tmp_path / "phrase" + phrase_table = pa.table( + { + "fts": ["word word" for _ in range(test_table_size)], + } + ) + ds = lance.write_dataset(phrase_table, phrase_path) + ds.create_scalar_index("fts", index_type="INVERTED", with_position=True) + + ds = lance.dataset(phrase_path) + ds.prewarm_index("fts_idx") + cache_entries_after_prewarm = ds._ds.index_cache_entry_count() + results = ds.to_table(full_text_query=PhraseQuery("word word", "fts")) + assert results.num_rows == test_table_size + cache_entries_after_query = ds._ds.index_cache_entry_count() + assert cache_entries_after_query > cache_entries_after_prewarm + + ds = lance.dataset(phrase_path) + ds.prewarm_index("fts_idx", with_position=True) + cache_entries_after_prewarm = ds._ds.index_cache_entry_count() + results = ds.to_table(full_text_query=PhraseQuery("word word", "fts")) + assert results.num_rows == test_table_size + cache_entries_after_query = ds._ds.index_cache_entry_count() + assert cache_entries_after_query == cache_entries_after_prewarm + + with pytest.raises( + TypeError, + match="takes 2 positional arguments", + ): + ds.prewarm_index("fts_idx", True) + def test_btree_prewarm(tmp_path: Path): scan_stats = None diff --git a/python/src/dataset.rs b/python/src/dataset.rs index 8838b89bc0a..249e2b1476e 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -69,7 +69,7 @@ use lance_index::scalar::inverted::query::{ BooleanQuery, BoostQuery, FtsQuery, MatchQuery, MultiMatchQuery, Operator, PhraseQuery, }; use lance_index::{ - IndexParams, IndexType, + FtsPrewarmOptions, IndexParams, IndexType, PrewarmOptions, optimize::OptimizeOptions, progress::{IndexBuildProgress, NoopIndexBuildProgress}, scalar::{FullTextSearchQuery, InvertedIndexParams, ScalarIndexParams}, @@ -2092,9 +2092,21 @@ impl Dataset { Ok(()) } - fn prewarm_index(&self, name: &str) -> PyResult<()> { - rt().block_on(None, self.ds.prewarm_index(name))? - .infer_error() + #[pyo3(signature = (name, *, with_position = false))] + fn prewarm_index(&self, name: &str, with_position: bool) -> PyResult<()> { + rt().block_on(None, async { + if with_position { + self.ds + .prewarm_index_with_options( + name, + &PrewarmOptions::Fts(FtsPrewarmOptions::new().with_position(true)), + ) + .await + } else { + self.ds.prewarm_index(name).await + } + })? + .infer_error() } #[pyo3(signature = (index_uuid, index_type, batch_readhead=None, progress_callback=None))] diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index 783e7671dc0..df8bba37056 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -62,7 +62,6 @@ use super::{ builder::{InnerBuilder, PositionRecorder}, iter::CompressedPostingListIterator, }; -use crate::Index; use crate::frag_reuse::FragReuseIndex; use crate::pbold; use crate::scalar::inverted::lance_tokenizer::TextTokenizer; @@ -72,6 +71,7 @@ use crate::scalar::{ AnyQuery, BuiltinIndexType, CreatedIndex, IndexReader, IndexStore, MetricsCollector, ScalarIndex, ScalarIndexParams, SearchResult, TokenQuery, UpdateCriteria, }; +use crate::{FtsPrewarmOptions, Index}; use crate::{prefilter::PreFilter, scalar::inverted::iter::take_fst_keys}; use std::str::FromStr; @@ -761,13 +761,31 @@ impl Index for InvertedIndex { } async fn prewarm(&self) -> Result<()> { + self.prewarm_with_options(&FtsPrewarmOptions::default()) + .await + } + + fn index_type(&self) -> crate::IndexType { + crate::IndexType::Inverted + } + + async fn calculate_included_frags(&self) -> Result { + unimplemented!() + } +} + +impl InvertedIndex { + pub async fn prewarm_with_options(&self, options: &FtsPrewarmOptions) -> Result<()> { + let with_position = options.with_position; let io_parallelism = self.store.io_parallelism(); let prewarm_futures = self .partitions .iter() .map(Arc::clone) .map(|part| async move { - part.inverted_list.prewarm().await?; + part.inverted_list + .prewarm_posting_lists(with_position) + .await?; Result::Ok(()) }); stream::iter(prewarm_futures) @@ -776,17 +794,6 @@ impl Index for InvertedIndex { .await?; Ok(()) } - - fn index_type(&self) -> crate::IndexType { - crate::IndexType::Inverted - } - - async fn calculate_included_frags(&self) -> Result { - unimplemented!() - } -} - -impl InvertedIndex { /// Search docs match the input text. async fn do_search(&self, text: &str) -> Result { let params = FtsSearchParams::new(); @@ -1670,7 +1677,7 @@ impl PostingListReader { .as_ref() .clone(); - if is_phrase_query { + if is_phrase_query && !posting.has_position() { // hit the cache and when the cache was populated, the positions column was not loaded let positions = self.read_positions(token_id).await?; posting.set_positions(positions); @@ -1748,9 +1755,15 @@ impl PostingListReader { Ok(posting_lists) } - async fn prewarm(&self) -> Result<()> { + async fn prewarm_posting_lists(&self, with_position: bool) -> Result<()> { + if with_position && !self.has_positions() { + return Err(Error::invalid_input( + "cannot prewarm positions for an inverted index that was built without positions; recreate the index with with_position=true".to_owned(), + )); + } + let read_batch_start = Instant::now(); - let batch = self.read_batch(false).await?; + let batch = self.read_batch(with_position).await?; let read_batch_elapsed = read_batch_start.elapsed(); let legacy_layout = self.offsets.is_some(); @@ -1774,7 +1787,12 @@ impl PostingListReader { "Failed to build prewarm posting lists in blocking task: {err}" )) })??; - for (token_id, posting_list) in posting_lists { + for (token_id, mut posting_list) in posting_lists { + if with_position && let Some(positions) = posting_list.take_positions() { + self.index_cache + .insert_with_key(&PositionKey { token_id }, Arc::new(Positions(positions))) + .await; + } self.index_cache .insert_with_key(&PostingListKey { token_id }, Arc::new(posting_list)) .await; @@ -1783,6 +1801,7 @@ impl PostingListReader { info!( legacy_layout, + with_position, token_count = self.len(), read_batch_ms = read_batch_elapsed.as_secs_f64() * 1000.0, post_read_loop_ms = populate_elapsed.as_secs_f64() * 1000.0, @@ -2087,6 +2106,16 @@ impl PostingList { } } + pub fn take_positions(&mut self) -> Option { + match self { + Self::Plain(posting) => posting + .positions + .take() + .map(CompressedPositionStorage::LegacyPerDoc), + Self::Compressed(posting) => posting.positions.take(), + } + } + pub fn max_score(&self) -> Option { match self { Self::Plain(posting) => posting.max_score, @@ -4827,7 +4856,7 @@ mod tests { "test should use modern posting layout" ); - inverted_list.prewarm().await.unwrap(); + inverted_list.prewarm_posting_lists(false).await.unwrap(); let alpha = inverted_list .index_cache @@ -4853,6 +4882,99 @@ mod tests { "prewarm should not leave cached posting lists sharing the same values buffer" ); } + + #[tokio::test] + async fn test_prewarm_with_positions_populates_separate_position_cache() { + let tmpdir = TempObjDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + tmpdir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + let mut builder = InnerBuilder::new_with_format_version( + 0, + true, + TokenSetFormat::default(), + InvertedListFormatVersion::V1, + ); + builder.tokens.add("hello".to_owned()); + builder.tokens.add("world".to_owned()); + builder + .posting_lists + .push(PostingListBuilder::new_with_posting_tail_codec( + true, + PostingTailCodec::Fixed32, + )); + builder + .posting_lists + .push(PostingListBuilder::new_with_posting_tail_codec( + true, + PostingTailCodec::Fixed32, + )); + builder.posting_lists[0].add(0, PositionRecorder::Position(vec![0].into())); + builder.posting_lists[1].add(0, PositionRecorder::Position(vec![1].into())); + builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into())); + builder.posting_lists[1].add(1, PositionRecorder::Position(vec![2].into())); + builder.docs.append(100, 2); + builder.docs.append(101, 2); + builder.write(store.as_ref()).await.unwrap(); + + let metadata = std::collections::HashMap::from_iter(vec![ + ( + "partitions".to_owned(), + serde_json::to_string(&vec![0_u64]).unwrap(), + ), + ( + "params".to_owned(), + serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(), + ), + ( + TOKEN_SET_FORMAT_KEY.to_owned(), + TokenSetFormat::default().to_string(), + ), + ]); + let mut writer = store + .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty())) + .await + .unwrap(); + writer.finish_with_metadata(metadata).await.unwrap(); + + let cache = Arc::new(LanceCache::with_capacity(4096)); + let index = InvertedIndex::load(store.clone(), None, cache.as_ref()) + .await + .unwrap(); + + index + .prewarm_with_options(&FtsPrewarmOptions::new().with_position(true)) + .await + .unwrap(); + + let inverted_list = &index.partitions[0].inverted_list; + let posting = inverted_list + .index_cache + .get_with_key(&PostingListKey { token_id: 0 }) + .await + .unwrap(); + assert!( + !posting.has_position(), + "posting cache should remain positions-free after prewarm" + ); + + let positions = inverted_list + .index_cache + .get_with_key(&PositionKey { token_id: 0 }) + .await + .unwrap(); + assert!( + matches!( + positions.as_ref().0, + CompressedPositionStorage::LegacyPerDoc(_) + ), + "positions should be stored in the dedicated position cache" + ); + } + #[test] fn test_block_max_scores_capacity_matches_block_count() { let mut docs = DocSet::default(); diff --git a/rust/lance-index/src/traits.rs b/rust/lance-index/src/traits.rs index 0b99954a727..5cb5c830666 100644 --- a/rust/lance-index/src/traits.rs +++ b/rust/lance-index/src/traits.rs @@ -52,6 +52,32 @@ impl<'a> IndexCriteria<'a> { #[deprecated(since = "0.39.0", note = "Use IndexCriteria instead")] pub type ScalarIndexCriteria<'a> = IndexCriteria<'a>; +/// Options for prewarming an inverted index. +#[non_exhaustive] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FtsPrewarmOptions { + /// If true, prewarm positions along with posting lists. + pub with_position: bool, +} + +impl FtsPrewarmOptions { + pub fn new() -> Self { + Self::default() + } + + pub fn with_position(mut self, with_position: bool) -> Self { + self.with_position = with_position; + self + } +} + +/// Options for prewarming an index. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrewarmOptions { + Fts(FtsPrewarmOptions), +} + /// Additional information about an index /// /// Note that a single index might consist of multiple segments. Each segment has its own diff --git a/rust/lance/src/dataset/tests/dataset_index.rs b/rust/lance/src/dataset/tests/dataset_index.rs index abde3995984..10a3203e669 100644 --- a/rust/lance/src/dataset/tests/dataset_index.rs +++ b/rust/lance/src/dataset/tests/dataset_index.rs @@ -6,9 +6,11 @@ use std::sync::Arc; use std::vec; use crate::dataset::ROW_ID; +use crate::dataset::builder::DatasetBuilder; use crate::dataset::tests::dataset_migrations::scan_dataset; use crate::dataset::tests::dataset_transactions::{assert_results, execute_sql}; use crate::index::vector::VectorIndexParams; +use crate::session::Session; use crate::{Dataset, Error, Result}; use lance_arrow::FixedSizeListArrayExt; @@ -37,6 +39,7 @@ use lance_index::scalar::inverted::{ query::{BooleanQuery, MatchQuery, Occur, Operator, PhraseQuery}, tokenizer::InvertedIndexParams, }; +use lance_index::{FtsPrewarmOptions, PrewarmOptions}; use lance_index::{IndexType, scalar::ScalarIndexParams, vector::DIST_COL}; use lance_io::scheduler::{ScanScheduler, SchedulerConfig}; use lance_io::utils::CachedFileSize; @@ -1776,6 +1779,181 @@ async fn test_fts_phrase_query() { assert_eq!(result.num_rows(), 0); } +async fn open_dataset_with_fresh_session(uri: &str) -> Dataset { + DatasetBuilder::from_uri(uri) + .with_session(Arc::new(Session::new(1 << 20, 1 << 20, Default::default()))) + .load() + .await + .unwrap() +} + +#[tokio::test] +async fn test_fts_prewarm_with_position_controls_phrase_query_cache() { + let tmpdir = TempStrDir::default(); + let uri = tmpdir.to_owned(); + drop(tmpdir); + + let doc_col: Arc = Arc::new(GenericStringArray::::from(vec![ + "lance search", + "lance search with tail", + "phrase query", + ])); + let ids = UInt64Array::from_iter_values(0..doc_col.len() as u64); + let batch = RecordBatch::try_new( + arrow_schema::Schema::new(vec![ + arrow_schema::Field::new("doc", doc_col.data_type().to_owned(), true), + arrow_schema::Field::new("id", DataType::UInt64, false), + ]) + .into(), + vec![Arc::new(doc_col) as ArrayRef, Arc::new(ids) as ArrayRef], + ) + .unwrap(); + let schema = batch.schema(); + let batches = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema); + let mut dataset = Dataset::write(batches, &uri, None).await.unwrap(); + dataset + .create_index( + &["doc"], + IndexType::Inverted, + Some("fts_idx".to_owned()), + &InvertedIndexParams::default().with_position(true), + true, + ) + .await + .unwrap(); + + let dataset = open_dataset_with_fresh_session(&uri).await; + dataset.prewarm_index("fts_idx").await.unwrap(); + let cache_entries_after_prewarm = dataset.index_cache_entry_count().await; + let result = dataset + .scan() + .project(&["id"]) + .unwrap() + .full_text_search(FullTextSearchQuery::new_query( + PhraseQuery::new("lance search".to_owned()).into(), + )) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!(result.num_rows(), 2); + let cache_entries_after_query = dataset.index_cache_entry_count().await; + assert!( + cache_entries_after_query > cache_entries_after_prewarm, + "phrase query should populate positions cache when prewarm skipped positions" + ); + + let dataset = open_dataset_with_fresh_session(&uri).await; + dataset + .prewarm_index_with_options( + "fts_idx", + &PrewarmOptions::Fts(FtsPrewarmOptions::new().with_position(true)), + ) + .await + .unwrap(); + let cache_entries_after_prewarm = dataset.index_cache_entry_count().await; + let result = dataset + .scan() + .project(&["id"]) + .unwrap() + .full_text_search(FullTextSearchQuery::new_query( + PhraseQuery::new("lance search".to_owned()).into(), + )) + .unwrap() + .try_into_batch() + .await + .unwrap(); + assert_eq!(result.num_rows(), 2); + let cache_entries_after_query = dataset.index_cache_entry_count().await; + assert_eq!( + cache_entries_after_query, cache_entries_after_prewarm, + "phrase query should not add cache entries after prewarming positions" + ); +} + +#[tokio::test] +async fn test_prewarm_index_with_position_validation() { + let tmpdir = TempStrDir::default(); + let uri = tmpdir.to_owned(); + drop(tmpdir); + + let doc_col: Arc = Arc::new(GenericStringArray::::from(vec![ + "lance search", + "phrase query", + ])); + let ids = UInt64Array::from_iter_values(0..doc_col.len() as u64); + let batch = RecordBatch::try_new( + arrow_schema::Schema::new(vec![ + arrow_schema::Field::new("doc", doc_col.data_type().to_owned(), true), + arrow_schema::Field::new("id", DataType::UInt64, false), + ]) + .into(), + vec![Arc::new(doc_col) as ArrayRef, Arc::new(ids) as ArrayRef], + ) + .unwrap(); + let schema = batch.schema(); + let batches = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema); + let mut dataset = Dataset::write(batches, &uri, None).await.unwrap(); + dataset + .create_index( + &["doc"], + IndexType::Inverted, + Some("fts_idx".to_owned()), + &InvertedIndexParams::default().with_position(false), + true, + ) + .await + .unwrap(); + + let dataset = open_dataset_with_fresh_session(&uri).await; + let err = dataset + .prewarm_index_with_options( + "fts_idx", + &PrewarmOptions::Fts(FtsPrewarmOptions::new().with_position(true)), + ) + .await + .unwrap_err() + .to_string(); + assert_contains!( + err, + "cannot prewarm positions for an inverted index that was built without positions" + ); + + let tmpdir = TempStrDir::default(); + let uri = tmpdir.to_owned(); + drop(tmpdir); + + let batch = RecordBatch::try_from_iter(vec![( + "id", + Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef, + )]) + .unwrap(); + let schema = batch.schema(); + let batches = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema); + let mut dataset = Dataset::write(batches, &uri, None).await.unwrap(); + dataset + .create_index( + &["id"], + IndexType::BTree, + Some("id_idx".to_owned()), + &ScalarIndexParams::default(), + true, + ) + .await + .unwrap(); + + let dataset = open_dataset_with_fresh_session(&uri).await; + let err = dataset + .prewarm_index_with_options("id_idx", &PrewarmOptions::Fts(FtsPrewarmOptions::default())) + .await + .unwrap_err() + .to_string(); + assert_contains!( + err, + "FTS prewarm options are only supported for inverted indices" + ); +} + #[tokio::test] async fn test_fts_phrase_query_with_removed_stop_words() { let tmpdir = TempStrDir::default(); diff --git a/rust/lance/src/index.rs b/rust/lance/src/index.rs index 400a1be9244..40788265172 100644 --- a/rust/lance/src/index.rs +++ b/rust/lance/src/index.rs @@ -33,7 +33,7 @@ pub use lance_index::progress::{IndexBuildProgress, NoopIndexBuildProgress}; use lance_index::scalar::expression::{ IndexInformationProvider, MultiQueryParser, ScalarQueryParser, }; -use lance_index::scalar::inverted::InvertedIndexPlugin; +use lance_index::scalar::inverted::{InvertedIndex, InvertedIndexPlugin}; use lance_index::scalar::lance_format::LanceIndexStore; use lance_index::scalar::registry::{TrainingCriteria, TrainingOrdering}; use lance_index::scalar::{CreatedIndex, ScalarIndex}; @@ -44,7 +44,7 @@ use lance_index::vector::pq::ProductQuantizer; use lance_index::vector::quantizer::Quantization; use lance_index::vector::sq::ScalarQuantizer; use lance_index::vector::v3::subindex::IvfSubIndex; -use lance_index::{INDEX_FILE_NAME, Index, IndexType, pb, vector::VectorIndex}; +use lance_index::{INDEX_FILE_NAME, Index, IndexType, PrewarmOptions, pb, vector::VectorIndex}; use lance_index::{ IndexCriteria, is_system_index, metrics::{MetricsCollector, NoOpMetricsCollector}, @@ -782,6 +782,41 @@ impl DatasetIndexExt for Dataset { Ok(()) } + async fn prewarm_index_with_options(&self, name: &str, options: &PrewarmOptions) -> Result<()> { + let indices = self.load_indices_by_name(name).await?; + if indices.is_empty() { + return Err(Error::index_not_found(format!("name={}", name))); + } + + for index_meta in indices { + let index = self + .open_generic_index(name, &index_meta.uuid.to_string(), &NoOpMetricsCollector) + .await?; + + match options { + PrewarmOptions::Fts(fts_options) => { + let inverted = index + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Error::invalid_input(format!( + "FTS prewarm options are only supported for inverted indices, got {:?}", + index.index_type() + )) + })?; + inverted.prewarm_with_options(fts_options).await?; + } + _ => { + return Err(Error::not_supported( + "unsupported prewarm options for this lance version".to_owned(), + )); + } + } + } + + Ok(()) + } + async fn describe_indices<'a, 'b>( &'a self, criteria: Option>, diff --git a/rust/lance/src/index/api.rs b/rust/lance/src/index/api.rs index b42c10ff1dc..2b61cdfe12b 100644 --- a/rust/lance/src/index/api.rs +++ b/rust/lance/src/index/api.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use async_trait::async_trait; use datafusion::execution::SendableRecordBatchStream; -use lance_index::{IndexParams, IndexType, optimize::OptimizeOptions}; +use lance_index::{IndexParams, IndexType, PrewarmOptions, optimize::OptimizeOptions}; use lance_table::format::IndexMetadata; use crate::{Error, Result}; @@ -50,6 +50,17 @@ pub trait DatasetIndexExt { /// This will load the index into memory and cache it. async fn prewarm_index(&self, name: &str) -> Result<()>; + /// Prewarm an index by name with additional options. + async fn prewarm_index_with_options( + &self, + _name: &str, + _options: &PrewarmOptions, + ) -> Result<()> { + Err(Error::not_supported( + "prewarm options are not supported by this dataset implementation".to_owned(), + )) + } + /// Read all indices of this Dataset version. /// /// The indices are lazy loaded and cached in memory within the `Dataset` instance. From 7c39a31c1fe35001ae64dda24ca6980707ad7878 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Thu, 9 Apr 2026 16:04:08 +0000 Subject: [PATCH 165/206] chore: bump main to 5.1.0-beta.0 Unreleased version after creating v5.0.0-rc.1 --- .bumpversion.toml | 2 +- Cargo.lock | 42 +++++++++++++++++++-------------------- Cargo.toml | 40 ++++++++++++++++++------------------- java/lance-jni/Cargo.lock | 34 +++++++++++++++---------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 34 +++++++++++++++---------------- python/Cargo.toml | 2 +- 8 files changed, 79 insertions(+), 79 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 4371ccd0489..2602cc444fd 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "5.0.0-beta.6" +current_version = "5.1.0-beta.0" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index eeb522dd560..b9195b24bb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3348,7 +3348,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "lance-datagen", @@ -4580,7 +4580,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "all_asserts", "approx", @@ -4676,7 +4676,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -4726,7 +4726,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrayref", "paste", @@ -4735,7 +4735,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -4775,7 +4775,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -4809,7 +4809,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -4829,7 +4829,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -4877,7 +4877,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "all_asserts", "arrow", @@ -4903,7 +4903,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -4946,7 +4946,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "datafusion", "geo-traits", @@ -4960,7 +4960,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "approx", "arrow", @@ -5041,7 +5041,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -5092,7 +5092,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "approx", "arrow-array", @@ -5113,7 +5113,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "async-trait", @@ -5128,7 +5128,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -5146,7 +5146,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-ipc", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "proc-macro2", "quote", @@ -5260,7 +5260,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "arrow-schema", @@ -5271,7 +5271,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "clap", "lance-core", diff --git a/Cargo.toml b/Cargo.toml index c40fcb2a1ed..763ec09ed08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ resolver = "3" [workspace.package] -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -53,24 +53,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=5.0.0-beta.6", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=5.0.0-beta.6", path = "./rust/lance-arrow" } -lance-core = { version = "=5.0.0-beta.6", path = "./rust/lance-core" } -lance-datafusion = { version = "=5.0.0-beta.6", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=5.0.0-beta.6", path = "./rust/lance-datagen" } -lance-encoding = { version = "=5.0.0-beta.6", path = "./rust/lance-encoding" } -lance-file = { version = "=5.0.0-beta.6", path = "./rust/lance-file" } -lance-geo = { version = "=5.0.0-beta.6", path = "./rust/lance-geo" } -lance-index = { version = "=5.0.0-beta.6", path = "./rust/lance-index" } -lance-io = { version = "=5.0.0-beta.6", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=5.0.0-beta.6", path = "./rust/lance-linalg" } -lance-namespace = { version = "=5.0.0-beta.6", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=5.0.0-beta.6", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=5.0.0-beta.6", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=5.1.0-beta.0", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=5.1.0-beta.0", path = "./rust/lance-arrow" } +lance-core = { version = "=5.1.0-beta.0", path = "./rust/lance-core" } +lance-datafusion = { version = "=5.1.0-beta.0", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=5.1.0-beta.0", path = "./rust/lance-datagen" } +lance-encoding = { version = "=5.1.0-beta.0", path = "./rust/lance-encoding" } +lance-file = { version = "=5.1.0-beta.0", path = "./rust/lance-file" } +lance-geo = { version = "=5.1.0-beta.0", path = "./rust/lance-geo" } +lance-index = { version = "=5.1.0-beta.0", path = "./rust/lance-index" } +lance-io = { version = "=5.1.0-beta.0", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=5.1.0-beta.0", path = "./rust/lance-linalg" } +lance-namespace = { version = "=5.1.0-beta.0", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=5.1.0-beta.0", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=5.1.0-beta.0", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=5.0.0-beta.6", path = "./rust/lance-table" } -lance-test-macros = { version = "=5.0.0-beta.6", path = "./rust/lance-test-macros" } -lance-testing = { version = "=5.0.0-beta.6", path = "./rust/lance-testing" } +lance-table = { version = "=5.1.0-beta.0", path = "./rust/lance-table" } +lance-test-macros = { version = "=5.1.0-beta.0", path = "./rust/lance-test-macros" } +lance-testing = { version = "=5.1.0-beta.0", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -97,7 +97,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=5.0.0-beta.6", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=5.1.0-beta.0", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -138,7 +138,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=5.0.0-beta.6", path = "./rust/compression/fsst" } +fsst = { version = "=5.1.0-beta.0", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index d24c48ddb48..c4a4d7c29c3 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -2398,7 +2398,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3397,7 +3397,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -3463,7 +3463,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -3484,7 +3484,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrayref", "paste", @@ -3493,7 +3493,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -3530,7 +3530,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -3563,7 +3563,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -3581,7 +3581,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -3618,7 +3618,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -3650,7 +3650,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "datafusion", "geo-traits", @@ -3664,7 +3664,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -3732,7 +3732,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -3776,7 +3776,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -3812,7 +3812,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -3828,7 +3828,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "async-trait", @@ -3841,7 +3841,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-ipc", @@ -3886,7 +3886,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 2cffb73e4f2..45be8eed4ec 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 769eb369212..05e14c4af00 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 5.0.0-beta.6 + 5.1.0-beta.0 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index eccaf1f2c77..4ea4a84e3f3 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2827,7 +2827,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3865,7 +3865,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -3932,7 +3932,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -3953,7 +3953,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrayref", "paste", @@ -3962,7 +3962,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -3999,7 +3999,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -4032,7 +4032,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -4050,7 +4050,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -4087,7 +4087,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-arith", "arrow-array", @@ -4119,7 +4119,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "datafusion", "geo-traits", @@ -4133,7 +4133,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -4204,7 +4204,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-arith", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow-array", "arrow-buffer", @@ -4264,7 +4264,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "async-trait", @@ -4277,7 +4277,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-ipc", @@ -4322,7 +4322,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", @@ -5686,7 +5686,7 @@ dependencies = [ [[package]] name = "pylance" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" dependencies = [ "arrow", "arrow-array", diff --git a/python/Cargo.toml b/python/Cargo.toml index 3e03ec20290..ea7bb705483 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "5.0.0-beta.6" +version = "5.1.0-beta.0" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From bec9491b891ceb594b4d0817c46633f5e8cdb989 Mon Sep 17 00:00:00 2001 From: YangJie Date: Fri, 10 Apr 2026 01:04:15 +0800 Subject: [PATCH 166/206] fix: warn and clamp LANCE_INITIAL_UPLOAD_SIZE instead of panicking (#6389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace `panic!()` in `initial_upload_size()` with a warn-and-clamp fallback when `LANCE_INITIAL_UPLOAD_SIZE` is set outside the valid `[5MB, 5GB]` range, so misconfiguration can't crash the process - Extract `MAX_UPLOAD_PART_SIZE` constant for the 5GB upper bound - Extract `clamp_initial_upload_size` as a pure helper and add boundary unit tests ## Motivation Setting `LANCE_INITIAL_UPLOAD_SIZE` to a value outside the valid range previously crashed the entire process via `panic!()` — a disproportionate response to a perf-tuning env var misconfiguration. Per review feedback (#6389 (comment)), a crash (or even a propagated `Result`) forces every caller to handle a purely operator-side mistake. Clamping to the valid range and emitting a single warning lets the workload proceed and surfaces the misconfiguration to operators. This also matches the silent-fallback behavior of the sibling env vars `LANCE_UPLOAD_CONCURRENCY` and `LANCE_CONN_RESET_RETRIES`. ## What Changed **`initial_upload_size()`**: Return type stays `usize`. Out-of-range values are clamped into `[5MB, 5GB]` and a single `tracing::warn!` is emitted with `requested` and `clamped` fields. The existing `OnceLock` cache guarantees the warning fires at most once per process, so no separate rate-limiting logic is needed. Non-numeric and unset values continue to fall back silently to the 5MB default. **`clamp_initial_upload_size(raw) -> (usize, bool)`**: Pure helper extracted for testability. Returns the clamped value and whether clamping occurred. **`MAX_UPLOAD_PART_SIZE`**: New constant for the 5GB upper bound. ## Behavioral Equivalence | Input | Before | After | |-------|--------|-------| | Env not set | 5MB default | 5MB default | | Non-numeric (e.g. `"abc"`) | 5MB default | 5MB default | | Valid integer in `[5MB, 5GB]` | Returns the value | Returns the value | | Integer `< 5MB` | **`panic!()`** | Clamped to 5MB + `warn!` (once) | | Integer `> 5GB` | **`panic!()`** | Clamped to 5GB + `warn!` (once) | No API changes — `ObjectWriter::new()` signature is unchanged. ## Test plan - [x] New boundary unit tests: below min, min boundary, in-range, max boundary, above max, `usize::MAX` - [x] `cargo test -p lance-io --lib object_writer` — 7 tests pass - [x] `cargo clippy -p lance-io --tests -- -D warnings` — clean - [x] `cargo fmt -p lance-io -- --check` — clean - [x] `cargo check --workspace --tests` — full workspace compiles --- rust/lance-io/src/object_writer.rs | 70 +++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/rust/lance-io/src/object_writer.rs b/rust/lance-io/src/object_writer.rs index 24b0e589d4e..878687be91e 100644 --- a/rust/lance-io/src/object_writer.rs +++ b/rust/lance-io/src/object_writer.rs @@ -47,22 +47,35 @@ fn max_conn_reset_retries() -> u16 { }) } +/// Maximum part size in GCS and S3: 5GB. +const MAX_UPLOAD_PART_SIZE: usize = 1024 * 1024 * 1024 * 5; + +/// Clamps a requested upload part size to the valid [5MB, 5GB] range. +/// Returns the clamped value and whether clamping was necessary. +fn clamp_initial_upload_size(raw: usize) -> (usize, bool) { + let clamped = raw.clamp(INITIAL_UPLOAD_STEP, MAX_UPLOAD_PART_SIZE); + (clamped, clamped != raw) +} + fn initial_upload_size() -> usize { static LANCE_INITIAL_UPLOAD_SIZE: OnceLock = OnceLock::new(); *LANCE_INITIAL_UPLOAD_SIZE.get_or_init(|| { - std::env::var("LANCE_INITIAL_UPLOAD_SIZE") + let Some(raw) = std::env::var("LANCE_INITIAL_UPLOAD_SIZE") .ok() .and_then(|s| s.parse::().ok()) - .inspect(|size| { - if *size < INITIAL_UPLOAD_STEP { - // Minimum part size in GCS and S3 - panic!("LANCE_INITIAL_UPLOAD_SIZE must be at least 5MB"); - } else if *size > 1024 * 1024 * 1024 * 5 { - // Maximum part size in GCS and S3 - panic!("LANCE_INITIAL_UPLOAD_SIZE must be at most 5GB"); - } - }) - .unwrap_or(INITIAL_UPLOAD_STEP) + else { + return INITIAL_UPLOAD_STEP; + }; + let (clamped, was_clamped) = clamp_initial_upload_size(raw); + if was_clamped { + // OnceLock caches the result, so this warning fires at most once per process. + tracing::warn!( + requested = raw, + clamped, + "LANCE_INITIAL_UPLOAD_SIZE must be between 5MB and 5GB; clamping to valid range" + ); + } + clamped }) } @@ -820,4 +833,39 @@ mod tests { assert!(!temp_file_path.exists()); assert!(!file_path.exists()); } + + #[test] + fn clamp_initial_upload_size_below_min_is_clamped_up() { + assert_eq!(clamp_initial_upload_size(0), (INITIAL_UPLOAD_STEP, true)); + assert_eq!( + clamp_initial_upload_size(INITIAL_UPLOAD_STEP - 1), + (INITIAL_UPLOAD_STEP, true) + ); + } + + #[test] + fn clamp_initial_upload_size_within_range_is_unchanged() { + assert_eq!( + clamp_initial_upload_size(INITIAL_UPLOAD_STEP), + (INITIAL_UPLOAD_STEP, false) + ); + assert_eq!( + clamp_initial_upload_size(MAX_UPLOAD_PART_SIZE), + (MAX_UPLOAD_PART_SIZE, false) + ); + let mid = INITIAL_UPLOAD_STEP * 8; // 40MB, in range + assert_eq!(clamp_initial_upload_size(mid), (mid, false)); + } + + #[test] + fn clamp_initial_upload_size_above_max_is_clamped_down() { + assert_eq!( + clamp_initial_upload_size(MAX_UPLOAD_PART_SIZE + 1), + (MAX_UPLOAD_PART_SIZE, true) + ); + assert_eq!( + clamp_initial_upload_size(usize::MAX), + (MAX_UPLOAD_PART_SIZE, true) + ); + } } From f2db129dd5670edcf6a0a179084aab82f1f7b229 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Thu, 9 Apr 2026 15:28:15 -0700 Subject: [PATCH 167/206] chore: upgrade Rust toolchain to 1.94.0 (#6460) Upgrades the pinned Rust toolchain from 1.91.0 to 1.94.0. The only code change needed was boxing two futures in `build_partial_fixture` in the `distributed_vector_build` bench, where 1.94's stricter layout computation overflowed the default recursion limit. Boxing makes the awaited futures' sizes constant (a pointer), breaking the recursion. --------- Co-authored-by: Claude Sonnet 4.6 --- java/lance-jni/src/session.rs | 1 - rust-toolchain.toml | 2 +- rust/lance/benches/distributed_vector_build.rs | 5 +++-- rust/lance/src/dataset/write.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/java/lance-jni/src/session.rs b/java/lance-jni/src/session.rs index 48f0f423478..17b59c12ecb 100644 --- a/java/lance-jni/src/session.rs +++ b/java/lance-jni/src/session.rs @@ -11,7 +11,6 @@ use lance::session::Session as LanceSession; use lance_io::object_store::ObjectStoreRegistry; use crate::error::{Error, Result}; -use crate::ok_or_throw_with_return; /// Creates a new Session and returns a handle to it. /// diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 089a799280d..5699bd4d536 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ # We keep this pinned to keep clippy and rustfmt in sync between local and CI. # Feel free to upgrade to bring in new lints. [toolchain] -channel = "1.91.0" +channel = "1.94.0" components = ["rustfmt", "clippy", "rust-analyzer"] diff --git a/rust/lance/benches/distributed_vector_build.rs b/rust/lance/benches/distributed_vector_build.rs index e32cf4e7e48..768abd5aba2 100644 --- a/rust/lance/benches/distributed_vector_build.rs +++ b/rust/lance/benches/distributed_vector_build.rs @@ -281,7 +281,8 @@ async fn build_partial_fixture(dataset: &mut Dataset, bench_case: BenchCase) -> } let fragment_groups = contiguous_fragment_groups(dataset, bench_case.num_shards); - let (ivf_params, pq_params) = train_shared_ivf_pq(dataset, bench_case.num_partitions).await; + let (ivf_params, pq_params) = + Box::pin(train_shared_ivf_pq(dataset, bench_case.num_partitions)).await; let params = VectorIndexParams::with_ivf_pq_params(DistanceType::L2, ivf_params, pq_params); for fragments in fragment_groups { @@ -290,7 +291,7 @@ async fn build_partial_fixture(dataset: &mut Dataset, bench_case: BenchCase) -> .name("distributed_merge_only".to_string()) .fragments(fragments) .index_uuid(fixture_uuid.to_string()); - builder.execute_uncommitted().await.unwrap(); + Box::pin(builder.execute_uncommitted()).await.unwrap(); } MergeFixture { diff --git a/rust/lance/src/dataset/write.rs b/rust/lance/src/dataset/write.rs index 46c4e47c81d..bcbe9aa120c 100644 --- a/rust/lance/src/dataset/write.rs +++ b/rust/lance/src/dataset/write.rs @@ -428,7 +428,7 @@ impl WriteParams { /// IDs can be assigned after writing is complete. #[deprecated( since = "0.20.0", - note = "Use [`InsertBuilder::write_uncommitted_stream`] instead" + note = "Use [`InsertBuilder::execute_uncommitted_stream`] instead" )] pub async fn write_fragments( dest: impl Into>, From a57ec8142ed85d0f06fd3f90209bee006e91dd85 Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Fri, 10 Apr 2026 22:49:43 +0800 Subject: [PATCH 168/206] feat(index): support float16 and float64 in IVF_FLAT (#6476) ## Feature ### What is the new feature? This PR adds native `float16` and `float64` support for `IVF_FLAT` and `IVF_HNSW_FLAT`. ### Why do we need this feature? Flat IVF indexing previously only worked end to end for `float32`. That meant users could not build, merge, reload, and query flat IVF indexes on `float16` or `float64` vectors without running into `Float32`-specific assumptions in flat storage, writer initialization, and merge/query paths. ### How does it work? The implementation makes flat IVF paths dispatch on the actual Arrow element type from stored flat data instead of assuming `Float32`. - `FlatFloatStorage` now dispatches distance calculators for `float16`, `float32`, and `float64`. - Query/training helpers that previously special-cased `Float32` now accept the native float dtype where needed. - Tests now cover flat storage distance, partition serde roundtrip, IVF create/query/remap, and distributed merge behavior for `float16` / `float64`. ## Validation - `cargo fmt --all` - `cargo check -p lance-index --lib` - `cargo check -p lance --lib` - `cargo test -p lance-index test_flat_float_storage_distance_f16 -- --nocapture` - `cargo test -p lance-index test_merge_ivf_flat_preserves_float64_schema -- --nocapture` - `cargo test -p lance test_build_ivf_flat -- --nocapture` - `cargo test -p lance test_create_ivf_hnsw_flat -- --nocapture` - `cargo test -p lance test_create_ivf_flat_f16 -- --nocapture` ## Benchmark Note I also benchmarked float32 `IVF_FLAT` before vs after, no obvious performance diffs --- .../src/vector/distributed/index_merger.rs | 195 ++++++++++++++---- rust/lance-index/src/vector/flat/storage.rs | 167 ++++++++++++++- rust/lance-index/src/vector/utils.rs | 12 +- rust/lance/src/index/vector/builder.rs | 77 +++++-- rust/lance/src/index/vector/ivf.rs | 105 +++++++++- .../src/index/vector/ivf/partition_serde.rs | 69 ++++++- rust/lance/src/index/vector/ivf/v2.rs | 20 +- 7 files changed, 556 insertions(+), 89 deletions(-) diff --git a/rust/lance-index/src/vector/distributed/index_merger.rs b/rust/lance-index/src/vector/distributed/index_merger.rs index 74721f94c62..46d819379db 100755 --- a/rust/lance-index/src/vector/distributed/index_merger.rs +++ b/rust/lance-index/src/vector/distributed/index_merger.rs @@ -176,6 +176,7 @@ pub async fn init_writer_for_flat( object_store: &lance_io::object_store::ObjectStore, aux_out: &object_store::path::Path, d0: usize, + item_type: &DataType, dt: DistanceType, format_version: LanceFileVersion, ) -> Result { @@ -184,7 +185,7 @@ pub async fn init_writer_for_flat( Field::new( crate::vector::flat::storage::FLAT_COLUMN, DataType::FixedSizeList( - Arc::new(Field::new("item", DataType::Float32, true)), + Arc::new(Field::new("item", item_type.clone(), true)), d0 as i32, ), true, @@ -1129,9 +1130,13 @@ pub async fn merge_partial_vector_auxiliary_files( .iter() .find(|f| f.name() == crate::vector::flat::storage::FLAT_COLUMN) .ok_or_else(|| Error::index("FLAT column missing".to_string()))?; - let d0 = match flat_field.data_type() { - DataType::FixedSizeList(_, sz) => *sz as usize, - _ => 0, + let (d0, item_type) = match flat_field.data_type() { + DataType::FixedSizeList(item, sz) => (*sz as usize, item.data_type().clone()), + _ => { + return Err(Error::index( + "FLAT column is not a FixedSizeList in shard schema".to_string(), + )); + } }; dim.get_or_insert(d0); if let Some(dprev) = dim @@ -1140,53 +1145,29 @@ pub async fn merge_partial_vector_auxiliary_files( return Err(Error::index("Dimension mismatch across shards".to_string())); } if v2w_opt.is_none() { - let w = init_writer_for_flat(object_store, &aux_out, d0, dt, fv).await?; + let w = init_writer_for_flat(object_store, &aux_out, d0, &item_type, dt, fv) + .await?; v2w_opt = Some(w); } } SupportedIvfIndexType::IvfHnswFlat => { - // Treat HNSW_FLAT storage the same as FLAT: create schema with ROW_ID + flat vectors - // Determine dimension from shard schema (flat column) or fallback to STORAGE_METADATA_KEY + // Treat HNSW_FLAT storage the same as FLAT and preserve the actual flat item dtype. let schema_arrow: ArrowSchema = reader.schema().as_ref().into(); - // Try to find flat column and derive dim - let d0 = if let Some(flat_field) = schema_arrow + let Some(flat_field) = schema_arrow .fields .iter() .find(|f| f.name() == crate::vector::flat::storage::FLAT_COLUMN) - { - match flat_field.data_type() { - DataType::FixedSizeList(_, sz) => *sz as usize, - _ => 0, - } - } else { - // Fallback to STORAGE_METADATA_KEY FlatMetadata - if let Some(storage_meta_json) = reader - .metadata() - .file_schema - .metadata - .get(STORAGE_METADATA_KEY) - { - let storage_metadata_vec: Vec = - serde_json::from_str(storage_meta_json).map_err(|e| { - Error::index(format!("Failed to parse storage metadata: {}", e)) - })?; - if let Some(first_meta) = storage_metadata_vec.first() { - if let Ok(flat_meta) = serde_json::from_str::(first_meta) - { - flat_meta.dim - } else { - return Err(Error::index( - "FLAT metadata missing in storage metadata".to_string(), - )); - } - } else { - return Err(Error::index( - "FLAT metadata missing in storage metadata".to_string(), - )); - } - } else { + else { + return Err(Error::index( + "FLAT column missing from IVF_HNSW_FLAT shard schema".to_string(), + )); + }; + let (d0, item_type) = match flat_field.data_type() { + DataType::FixedSizeList(item, sz) => (*sz as usize, item.data_type().clone()), + _ => { return Err(Error::index( - "FLAT column missing and no storage metadata".to_string(), + "FLAT column is not a FixedSizeList in IVF_HNSW_FLAT shard schema" + .to_string(), )); } }; @@ -1197,7 +1178,8 @@ pub async fn merge_partial_vector_auxiliary_files( return Err(Error::index("Dimension mismatch across shards".to_string())); } if v2w_opt.is_none() { - let w = init_writer_for_flat(object_store, &aux_out, d0, dt, fv).await?; + let w = init_writer_for_flat(object_store, &aux_out, d0, &item_type, dt, fv) + .await?; v2w_opt = Some(w); } } @@ -1523,7 +1505,9 @@ pub async fn merge_partial_vector_auxiliary_files( mod tests { use super::*; - use arrow_array::{FixedSizeListArray, Float32Array, RecordBatch, UInt8Array, UInt64Array}; + use arrow_array::{ + FixedSizeListArray, Float32Array, Float64Array, RecordBatch, UInt8Array, UInt64Array, + }; use arrow_schema::Field; use bytes::Bytes; use futures::StreamExt; @@ -1614,6 +1598,71 @@ mod tests { Ok(total_rows) } + async fn write_flat_partial_aux_f64( + store: &ObjectStore, + aux_path: &Path, + dim: i32, + lengths: &[u32], + base_row_id: u64, + distance_type: DistanceType, + ) -> Result { + let arrow_schema = ArrowSchema::new(vec![ + (*ROW_ID_FIELD).clone(), + Field::new( + crate::vector::flat::storage::FLAT_COLUMN, + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float64, true)), dim), + true, + ), + ]); + + let writer = store.create(aux_path).await?; + let mut v2w = V2Writer::try_new( + writer, + lance_core::datatypes::Schema::try_from(&arrow_schema)?, + V2WriterOptions::default(), + )?; + v2w.add_schema_metadata(DISTANCE_TYPE_KEY, distance_type.to_string()); + + let ivf_meta = pb::Ivf { + centroids: Vec::new(), + offsets: Vec::new(), + lengths: lengths.to_vec(), + centroids_tensor: None, + loss: None, + }; + let buf = Bytes::from(ivf_meta.encode_to_vec()); + let pos = v2w.add_global_buffer(buf).await?; + v2w.add_schema_metadata(IVF_METADATA_KEY, pos.to_string()); + + let total_rows: usize = lengths.iter().map(|v| *v as usize).sum(); + let mut row_ids = Vec::with_capacity(total_rows); + let mut values = Vec::with_capacity(total_rows * dim as usize); + + let mut current_row_id = base_row_id; + for (pid, len) in lengths.iter().enumerate() { + for _ in 0..*len { + row_ids.push(current_row_id); + current_row_id += 1; + for d in 0..dim { + values.push(pid as f64 + d as f64 * 0.01); + } + } + } + + let row_id_arr = UInt64Array::from(row_ids); + let value_arr = Float64Array::from(values); + let fsl = FixedSizeListArray::try_new_from_values(value_arr, dim).unwrap(); + let batch = RecordBatch::try_new( + Arc::new(arrow_schema), + vec![Arc::new(row_id_arr), Arc::new(fsl)], + ) + .unwrap(); + + v2w.write_batch(&batch).await?; + v2w.finish().await?; + Ok(total_rows) + } + #[tokio::test] async fn test_merge_ivf_flat_success_basic() { let object_store = ObjectStore::memory(); @@ -1831,6 +1880,64 @@ mod tests { } } + #[tokio::test] + async fn test_merge_ivf_flat_preserves_float64_schema() { + let object_store = ObjectStore::memory(); + let index_dir = Path::from("index/float64_uuid"); + + let partial0 = index_dir.child("partial_0"); + let partial1 = index_dir.child("partial_1"); + let aux0 = partial0.child(INDEX_AUXILIARY_FILE_NAME); + let aux1 = partial1.child(INDEX_AUXILIARY_FILE_NAME); + + let lengths = vec![2_u32, 2_u32]; + let dim = 3_i32; + + write_flat_partial_aux_f64(&object_store, &aux0, dim, &lengths, 0, DistanceType::L2) + .await + .unwrap(); + write_flat_partial_aux_f64(&object_store, &aux1, dim, &lengths, 100, DistanceType::L2) + .await + .unwrap(); + + merge_partial_vector_auxiliary_files( + &object_store, + &[aux0.clone(), aux1.clone()], + &index_dir, + Arc::new(RecordingProgress::default()), + ) + .await + .unwrap(); + + let aux_out = index_dir.child(INDEX_AUXILIARY_FILE_NAME); + let sched = ScanScheduler::new( + Arc::new(object_store.clone()), + SchedulerConfig::max_bandwidth(&object_store), + ); + let fh = sched + .open_file(&aux_out, &CachedFileSize::unknown()) + .await + .unwrap(); + let reader = V2Reader::try_open( + fh, + None, + Arc::default(), + &lance_core::cache::LanceCache::no_cache(), + V2ReaderOptions::default(), + ) + .await + .unwrap(); + + let flat_field = reader + .schema() + .field(crate::vector::flat::storage::FLAT_COLUMN) + .unwrap(); + let DataType::FixedSizeList(item, _) = flat_field.data_type() else { + panic!("flat column should be a fixed size list"); + }; + assert_eq!(item.data_type(), &DataType::Float64); + } + #[allow(clippy::too_many_arguments)] async fn write_pq_partial_aux( store: &ObjectStore, diff --git a/rust/lance-index/src/vector/flat/storage.rs b/rust/lance-index/src/vector/flat/storage.rs index 8f774b6e76d..4f8efc67e62 100644 --- a/rust/lance-index/src/vector/flat/storage.rs +++ b/rust/lance-index/src/vector/flat/storage.rs @@ -10,18 +10,18 @@ use crate::vector::storage::{DistCalculator, VectorStore}; use crate::vector::utils::do_prefetch; use arrow::array::AsArray; use arrow::compute::concat_batches; -use arrow::datatypes::UInt8Type; +use arrow::datatypes::{Float16Type, Float64Type, UInt8Type}; use arrow_array::ArrowPrimitiveType; use arrow_array::{ Array, ArrayRef, FixedSizeListArray, RecordBatch, UInt64Array, types::{Float32Type, UInt64Type}, }; -use arrow_schema::SchemaRef; +use arrow_schema::{DataType, SchemaRef}; use deepsize::DeepSizeOf; use lance_core::{Error, ROW_ID, Result}; use lance_file::previous::reader::FileReader as PreviousFileReader; -use lance_linalg::distance::DistanceType; use lance_linalg::distance::hamming::hamming; +use lance_linalg::distance::{Cosine, DistanceType, Dot, L2}; pub const FLAT_COLUMN: &str = "flat"; @@ -126,7 +126,7 @@ impl FlatFloatStorage { } impl VectorStore for FlatFloatStorage { - type DistanceCalculator<'a> = FlatDistanceCal<'a, Float32Type>; + type DistanceCalculator<'a> = FlatFloatDistanceCalc<'a>; fn to_batches(&self) -> Result> { Ok([self.batch.clone()].into_iter()) @@ -136,6 +136,20 @@ impl VectorStore for FlatFloatStorage { // TODO: use chunked storage let new_batch = concat_batches(&batch.schema(), vec![&self.batch, &batch].into_iter())?; let mut storage = self.clone(); + storage.row_ids = Arc::new( + new_batch + .column_by_name(ROW_ID) + .ok_or(Error::schema(format!("column {} not found", ROW_ID)))? + .as_primitive::() + .clone(), + ); + storage.vectors = Arc::new( + new_batch + .column_by_name(FLAT_COLUMN) + .ok_or(Error::schema("column flat not found".to_string()))? + .as_fixed_size_list() + .clone(), + ); storage.batch = new_batch; Ok(storage) } @@ -288,6 +302,20 @@ impl VectorStore for FlatBinStorage { // TODO: use chunked storage let new_batch = concat_batches(&batch.schema(), vec![&self.batch, &batch].into_iter())?; let mut storage = self.clone(); + storage.row_ids = Arc::new( + new_batch + .column_by_name(ROW_ID) + .ok_or(Error::schema(format!("column {} not found", ROW_ID)))? + .as_primitive::() + .clone(), + ); + storage.vectors = Arc::new( + new_batch + .column_by_name(FLAT_COLUMN) + .ok_or(Error::schema("column flat not found".to_string()))? + .as_fixed_size_list() + .clone(), + ); storage.batch = new_batch; Ok(storage) } @@ -317,11 +345,11 @@ impl VectorStore for FlatBinStorage { } fn dist_calculator(&self, query: ArrayRef, _dist_q_c: f32) -> Self::DistanceCalculator<'_> { - Self::DistanceCalculator::new(self.vectors.as_ref(), query, self.distance_type) + Self::DistanceCalculator::new_binary(self.vectors.as_ref(), query, self.distance_type) } fn dist_calculator_from_id(&self, id: u32) -> Self::DistanceCalculator<'_> { - Self::DistanceCalculator::new( + Self::DistanceCalculator::new_binary( self.vectors.as_ref(), self.vectors.value(id as usize), self.distance_type, @@ -337,15 +365,18 @@ pub struct FlatDistanceCal<'a, T: ArrowPrimitiveType> { distance_fn: fn(&[T::Native], &[T::Native]) -> f32, } -impl<'a> FlatDistanceCal<'a, Float32Type> { +impl<'a, T> FlatDistanceCal<'a, T> +where + T: ArrowPrimitiveType, + T::Native: L2 + Cosine + Dot, +{ fn new(vectors: &'a FixedSizeListArray, query: ArrayRef, distance_type: DistanceType) -> Self { // Gained significant performance improvement by using strong typed primitive slice. - // TODO: to support other data types other than `f32`, make FlatDistanceCal a generic struct. - let flat_array = vectors.values().as_primitive::(); + let flat_array = vectors.values().as_primitive::(); let dimension = vectors.value_length() as usize; Self { vectors: flat_array.values(), - query: query.as_primitive::().values().to_vec(), + query: query.as_primitive::().values().to_vec(), dimension, distance_fn: distance_type.func(), } @@ -353,7 +384,11 @@ impl<'a> FlatDistanceCal<'a, Float32Type> { } impl<'a> FlatDistanceCal<'a, UInt8Type> { - fn new(vectors: &'a FixedSizeListArray, query: ArrayRef, _distance_type: DistanceType) -> Self { + fn new_binary( + vectors: &'a FixedSizeListArray, + query: ArrayRef, + _distance_type: DistanceType, + ) -> Self { // Gained significant performance improvement by using strong typed primitive slice. // TODO: to support other data types other than `f32`, make FlatDistanceCal a generic struct. let flat_array = vectors.values().as_primitive::(); @@ -395,3 +430,113 @@ impl DistCalculator for FlatDistanceCal<'_, T> { do_prefetch(vector.as_ptr_range()) } } + +pub enum FlatFloatDistanceCalc<'a> { + Float16(FlatDistanceCal<'a, Float16Type>), + Float32(FlatDistanceCal<'a, Float32Type>), + Float64(FlatDistanceCal<'a, Float64Type>), +} + +impl<'a> FlatFloatDistanceCalc<'a> { + fn new(vectors: &'a FixedSizeListArray, query: ArrayRef, distance_type: DistanceType) -> Self { + match vectors.value_type() { + DataType::Float16 => Self::Float16(FlatDistanceCal::::new( + vectors, + query, + distance_type, + )), + DataType::Float32 => Self::Float32(FlatDistanceCal::::new( + vectors, + query, + distance_type, + )), + DataType::Float64 => Self::Float64(FlatDistanceCal::::new( + vectors, + query, + distance_type, + )), + dt => panic!("flat float storage does not support data type {dt}"), + } + } +} + +impl DistCalculator for FlatFloatDistanceCalc<'_> { + fn distance(&self, id: u32) -> f32 { + match self { + Self::Float16(calc) => calc.distance(id), + Self::Float32(calc) => calc.distance(id), + Self::Float64(calc) => calc.distance(id), + } + } + + fn distance_all(&self, k_hint: usize) -> Vec { + match self { + Self::Float16(calc) => calc.distance_all(k_hint), + Self::Float32(calc) => calc.distance_all(k_hint), + Self::Float64(calc) => calc.distance_all(k_hint), + } + } + + fn prefetch(&self, id: u32) { + match self { + Self::Float16(calc) => calc.prefetch(id), + Self::Float32(calc) => calc.prefetch(id), + Self::Float64(calc) => calc.prefetch(id), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use arrow_array::{Float16Array, Float64Array}; + use half::f16; + use lance_arrow::FixedSizeListArrayExt; + + fn make_f16_storage() -> FlatFloatStorage { + let values = Float16Array::from(vec![ + f16::from_f32(1.0), + f16::from_f32(2.0), + f16::from_f32(4.0), + f16::from_f32(6.0), + ]); + let vectors = FixedSizeListArray::try_new_from_values(values, 2).unwrap(); + FlatFloatStorage::new(vectors, DistanceType::L2) + } + + fn make_f64_storage() -> FlatFloatStorage { + let values = Float64Array::from(vec![1.0, 2.0, 4.0, 6.0]); + let vectors = FixedSizeListArray::try_new_from_values(values, 2).unwrap(); + FlatFloatStorage::new(vectors, DistanceType::L2) + } + + #[test] + fn test_flat_float_storage_distance_f16() { + let storage = make_f16_storage(); + let query: ArrayRef = Arc::new(Float16Array::from(vec![ + f16::from_f32(1.0), + f16::from_f32(2.0), + ])); + + let calc = storage.dist_calculator(query, 0.0); + let distances = calc.distance_all(2); + + assert_eq!(distances.len(), 2); + assert_eq!(distances[0], 0.0); + assert!((distances[1] - 25.0).abs() < 1e-4); + } + + #[test] + fn test_flat_float_storage_distance_f64() { + let storage = make_f64_storage(); + let query: ArrayRef = Arc::new(Float64Array::from(vec![1.0, 2.0])); + + let calc = storage.dist_calculator(query, 0.0); + let distances = calc.distance_all(2); + + assert_eq!(distances.len(), 2); + assert_eq!(distances[0], 0.0); + assert!((distances[1] - 25.0).abs() < 1e-6); + } +} diff --git a/rust/lance-index/src/vector/utils.rs b/rust/lance-index/src/vector/utils.rs index 299444976bb..1e56370613e 100644 --- a/rust/lance-index/src/vector/utils.rs +++ b/rust/lance-index/src/vector/utils.rs @@ -90,10 +90,8 @@ impl SimpleIndex { } let store = match (centroids.data_type(), distance_type) { - (DataType::Float16 | DataType::Float32, _) => { - let f32_centroids = cast(¢roids, &DataType::Float32) - .map_err(|e| Error::index(e.to_string()))?; - let fsl = FixedSizeListArray::try_new_from_values(f32_centroids, dimension as i32)?; + (DataType::Float16 | DataType::Float32 | DataType::Float64, _) => { + let fsl = FixedSizeListArray::try_new_from_values(centroids, dimension as i32)?; SimpleStore::Float(FlatFloatStorage::new(fsl, distance_type)) } (DataType::UInt8, DistanceType::Hamming) => { @@ -113,11 +111,7 @@ impl SimpleIndex { dist_q_c: 0.0, }; let res = match &self.store { - SimpleStore::Float(store) => { - let query = - cast(&query, &DataType::Float32).map_err(|e| Error::index(e.to_string()))?; - self.index.search_basic(query, 1, ¶ms, None, store)? - } + SimpleStore::Float(store) => self.index.search_basic(query, 1, ¶ms, None, store)?, SimpleStore::Binary(store) => { let query = if query.data_type() == &DataType::UInt8 { query diff --git a/rust/lance/src/index/vector/builder.rs b/rust/lance/src/index/vector/builder.rs index 8354f80416c..96bca9daef3 100644 --- a/rust/lance/src/index/vector/builder.rs +++ b/rust/lance/src/index/vector/builder.rs @@ -1024,29 +1024,35 @@ impl IvfIndexBuilder )); }; - let is_pq = Q::quantization_type() == QuantizationType::Product; - let is_rq = Q::quantization_type() == QuantizationType::Rabit; + let quantization_type = Q::quantization_type(); + let is_pq = quantization_type == QuantizationType::Product; + let is_rq = quantization_type == QuantizationType::Rabit; + let is_flat = quantization_type == QuantizationType::Flat; // prepare the final writers let storage_path = self.index_dir.child(INDEX_AUXILIARY_FILE_NAME); let index_path = self.index_dir.child(INDEX_FILE_NAME); - let mut fields = vec![ROW_ID_FIELD.clone(), quantizer.field()]; - fields.extend(quantizer.extra_fields()); - let storage_schema: Schema = (&arrow_schema::Schema::new(fields)).try_into()?; let writer_options = FileWriterOptions { format_version: Some(self.format_version), ..Default::default() }; - let mut storage_writer = FileWriter::try_new( - self.store.create(&storage_path).await?, - storage_schema.clone(), - writer_options.clone(), - )?; + let mut storage_writer = if is_flat { + None + } else { + let mut fields = vec![ROW_ID_FIELD.clone(), quantizer.field()]; + fields.extend(quantizer.extra_fields()); + let storage_schema: Schema = (&arrow_schema::Schema::new(fields)).try_into()?; + Some(FileWriter::try_new( + self.store.create(&storage_path).await?, + storage_schema, + writer_options.clone(), + )?) + }; let mut index_writer = FileWriter::try_new( self.store.create(&index_path).await?, S::schema().as_ref().try_into()?, - writer_options, + writer_options.clone(), )?; // maintain the IVF partitions @@ -1109,7 +1115,19 @@ impl IvfIndexBuilder batch = batch.replace_column_by_name(RABIT_CODE_COLUMN, unpacked)?; } - storage_writer.write_batch(&batch).await?; + if storage_writer.is_none() { + let storage_schema: Schema = batch.schema_ref().as_ref().try_into()?; + storage_writer = Some(FileWriter::try_new( + self.store.create(&storage_path).await?, + storage_schema, + writer_options.clone(), + )?); + } + storage_writer + .as_mut() + .expect("storage writer must be initialized before write") + .write_batch(&batch) + .await?; storage_ivf.add_partition(batch.num_rows() as u32); } } @@ -1145,14 +1163,45 @@ impl IvfIndexBuilder } } + if storage_writer.is_none() { + let Some(centroids) = ivf.centroids.as_ref() else { + return Err(Error::invalid_input( + "flat storage writer could not infer schema from empty partitions without IVF centroids", + )); + }; + let flat_schema = arrow_schema::Schema::new(vec![ + ROW_ID_FIELD.as_ref().clone(), + arrow_schema::Field::new( + lance_index::vector::flat::storage::FLAT_COLUMN, + DataType::FixedSizeList( + Arc::new(arrow_schema::Field::new( + "item", + centroids.value_type(), + true, + )), + centroids.value_length(), + ), + true, + ), + ]); + let storage_schema: Schema = (&flat_schema).try_into()?; + storage_writer = Some(FileWriter::try_new( + self.store.create(&storage_path).await?, + storage_schema, + writer_options.clone(), + )?); + } + + let storage_writer = storage_writer + .as_mut() + .expect("storage writer must be initialized before final metadata write"); let storage_ivf_pb = pb::Ivf::try_from(&storage_ivf)?; storage_writer.add_schema_metadata(DISTANCE_TYPE_KEY, self.distance_type.to_string()); let ivf_buffer_pos = storage_writer .add_global_buffer(storage_ivf_pb.encode_to_vec().into()) .await?; storage_writer.add_schema_metadata(IVF_METADATA_KEY, ivf_buffer_pos.to_string()); - let quant_type = Q::quantization_type(); - let transposed = match quant_type { + let transposed = match quantization_type { QuantizationType::Product | QuantizationType::Rabit => self.transpose_codes, _ => false, }; diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 9f226a42db5..383ca87bc6e 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -2438,11 +2438,12 @@ mod tests { use arrow_array::types::UInt64Type; use arrow_array::{ - FixedSizeListArray, Float32Array, RecordBatch, RecordBatchIterator, RecordBatchReader, - UInt64Array, make_array, + FixedSizeListArray, Float16Array, Float32Array, RecordBatch, RecordBatchIterator, + RecordBatchReader, UInt64Array, make_array, }; use arrow_buffer::{BooleanBuffer, NullBuffer}; use arrow_schema::{DataType, Field, Schema}; + use half::f16; use itertools::Itertools; use lance_core::ROW_ID; use lance_core::utils::address::RowAddress; @@ -3526,6 +3527,106 @@ mod tests { ); } + #[tokio::test] + async fn test_create_ivf_flat_f16() { + let test_dir = TempStrDir::default(); + let test_uri = test_dir.as_str(); + + const DIM: usize = 32; + let schema = Arc::new(Schema::new(vec![Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float16, true)), + DIM as i32, + ), + true, + )])); + + let arr = generate_random_array_with_seed::(1000 * DIM, [22; 32]); + let fsl = FixedSizeListArray::try_new_from_values(arr, DIM as i32).unwrap(); + let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(fsl)]).unwrap(); + let batches = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone()); + let mut dataset = Dataset::write(batches, test_uri, None).await.unwrap(); + + let params = VectorIndexParams::ivf_flat(2, MetricType::L2); + dataset + .create_index(&["vector"], IndexType::Vector, None, ¶ms, false) + .await + .unwrap(); + + let query = Float16Array::from_iter_values(repeat_n(f16::from_f32(0.5), DIM)); + let results = dataset + .scan() + .nearest("vector", &query, 5) + .unwrap() + .try_into_stream() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].num_rows(), 5); + let schema = results[0].schema(); + let field = schema.field(0); + let DataType::FixedSizeList(item, _) = field.data_type() else { + panic!("vector column should remain fixed size list"); + }; + assert_eq!(item.data_type(), &DataType::Float16); + } + + #[tokio::test] + async fn test_create_ivf_hnsw_flat_f16() { + let test_dir = TempStrDir::default(); + let test_uri = test_dir.as_str(); + + const DIM: usize = 32; + let schema = Arc::new(Schema::new(vec![Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float16, true)), + DIM as i32, + ), + true, + )])); + + let arr = generate_random_array_with_seed::(1000 * DIM, [22; 32]); + let fsl = FixedSizeListArray::try_new_from_values(arr, DIM as i32).unwrap(); + let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(fsl)]).unwrap(); + let batches = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone()); + let mut dataset = Dataset::write(batches, test_uri, None).await.unwrap(); + + let params = VectorIndexParams::ivf_hnsw( + MetricType::L2, + IvfBuildParams::new(2), + HnswBuildParams::default(), + ); + dataset + .create_index(&["vector"], IndexType::Vector, None, ¶ms, false) + .await + .unwrap(); + + let query = Float16Array::from_iter_values(repeat_n(f16::from_f32(0.5), DIM)); + let results = dataset + .scan() + .nearest("vector", &query, 5) + .unwrap() + .try_into_stream() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].num_rows(), 5); + let schema = results[0].schema(); + let field = schema.field(0); + let DataType::FixedSizeList(item, _) = field.data_type() else { + panic!("vector column should remain fixed size list"); + }; + assert_eq!(item.data_type(), &DataType::Float16); + } + #[tokio::test] async fn test_create_ivf_pq_with_invalid_num_sub_vectors() { let test_dir = TempStrDir::default(); diff --git a/rust/lance/src/index/vector/ivf/partition_serde.rs b/rust/lance/src/index/vector/ivf/partition_serde.rs index 97f53708a21..f8d13a2f0b5 100644 --- a/rust/lance/src/index/vector/ivf/partition_serde.rs +++ b/rust/lance/src/index/vector/ivf/partition_serde.rs @@ -530,10 +530,11 @@ mod tests { use arrow_array::cast::AsArray; use arrow_array::{ - Float32Array, UInt8Array, UInt64Array, + Float16Array, Float32Array, Float64Array, UInt8Array, UInt64Array, types::{Float32Type, UInt8Type}, }; use arrow_schema::{DataType, Field, Schema}; + use half::f16; use lance_arrow::FixedSizeListArrayExt; use lance_index::vector::bq::storage::RABIT_CODE_COLUMN; use lance_index::vector::bq::transform::{ADD_FACTORS_COLUMN, SCALE_FACTORS_COLUMN}; @@ -721,6 +722,22 @@ mod tests { FlatFloatStorage::new(vectors, DistanceType::L2) } + fn make_flat_storage_f16(num_rows: usize, dim: usize) -> FlatFloatStorage { + let values: Vec = (0..num_rows * dim) + .map(|i| f16::from_f32(i as f32 * 0.01)) + .collect(); + let values_array = Float16Array::from(values); + let vectors = FixedSizeListArray::try_new_from_values(values_array, dim as i32).unwrap(); + FlatFloatStorage::new(vectors, DistanceType::L2) + } + + fn make_flat_storage_f64(num_rows: usize, dim: usize) -> FlatFloatStorage { + let values: Vec = (0..num_rows * dim).map(|i| i as f64 * 0.01).collect(); + let values_array = Float64Array::from(values); + let vectors = FixedSizeListArray::try_new_from_values(values_array, dim as i32).unwrap(); + FlatFloatStorage::new(vectors, DistanceType::L2) + } + // ----- Flat tests ------------------------------------------------------- #[test] @@ -770,6 +787,56 @@ mod tests { } } + #[test] + fn test_roundtrip_flat_flat_f16() { + let storage = make_flat_storage_f16(8, 16); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + let restored = + PartitionEntry::::deserialize(&bytes::Bytes::from(bytes)) + .unwrap(); + + let restored_batch = restored.storage.to_batches().unwrap().next().unwrap(); + let schema = restored_batch.schema(); + let field = schema + .field_with_name(lance_index::vector::flat::storage::FLAT_COLUMN) + .unwrap(); + let DataType::FixedSizeList(item, _) = field.data_type() else { + panic!("flat column should be fixed size list"); + }; + assert_eq!(item.data_type(), &DataType::Float16); + } + + #[test] + fn test_roundtrip_flat_flat_f64() { + let storage = make_flat_storage_f64(8, 16); + let entry = PartitionEntry:: { + index: FlatIndex::default(), + storage, + }; + + let mut bytes = Vec::new(); + entry.serialize(&mut bytes).unwrap(); + let restored = + PartitionEntry::::deserialize(&bytes::Bytes::from(bytes)) + .unwrap(); + + let restored_batch = restored.storage.to_batches().unwrap().next().unwrap(); + let schema = restored_batch.schema(); + let field = schema + .field_with_name(lance_index::vector::flat::storage::FLAT_COLUMN) + .unwrap(); + let DataType::FixedSizeList(item, _) = field.data_type() else { + panic!("flat column should be fixed size list"); + }; + assert_eq!(item.data_type(), &DataType::Float64); + } + // ----- SQ helpers ------------------------------------------------------- fn make_sq_storage( diff --git a/rust/lance/src/index/vector/ivf/v2.rs b/rust/lance/src/index/vector/ivf/v2.rs index 4ccb65f086a..7d38aa312c5 100644 --- a/rust/lance/src/index/vector/ivf/v2.rs +++ b/rust/lance/src/index/vector/ivf/v2.rs @@ -3021,13 +3021,7 @@ mod tests { ) .await; - let index_type = params.index_type(); - // *_FLAT doesn't support float16/float64 - if !(index_type == IndexType::IvfFlat - || (index_type == IndexType::IvfHnswFlat && params.stages.len() == 2)) // IVF_HNSW_FLAT - && dataset.is_none() - // if dataset is provided, it has been created, so the data type is already determined, no need to test float64 - { + if dataset.is_none() { test_index_impl::( params, nlist, @@ -3106,13 +3100,23 @@ mod tests { .await; } _ => { + let index_type = params.index_type(); Box::pin(test_remap_impl::( - params, + params.clone(), nlist, recall_requirement, 0.0..1.0, )) .await; + if matches!(index_type, IndexType::IvfFlat | IndexType::IvfHnswFlat) { + Box::pin(test_remap_impl::( + params, + nlist, + recall_requirement, + 0.0..1.0, + )) + .await; + } } } } From 103e947aef451e4b88da03fe47512558d333c29c Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Fri, 10 Apr 2026 14:53:52 +0000 Subject: [PATCH 169/206] chore: release beta version 5.1.0-beta.1 --- .bumpversion.toml | 2 +- Cargo.lock | 72 +++++++++++++++++++++------------------ Cargo.toml | 40 +++++++++++----------- java/lance-jni/Cargo.lock | 58 +++++++++++++++++-------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 64 ++++++++++++++++++---------------- python/Cargo.toml | 2 +- 8 files changed, 130 insertions(+), 112 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 2602cc444fd..eff9efe9f82 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "5.1.0-beta.0" +current_version = "5.1.0-beta.1" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index b9195b24bb2..f1fee157c4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1495,9 +1495,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.59" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -3348,7 +3348,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "lance-datagen", @@ -3805,6 +3805,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heapless" version = "0.8.0" @@ -4281,12 +4287,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -4580,7 +4586,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "all_asserts", "approx", @@ -4676,7 +4682,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4726,7 +4732,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrayref", "paste", @@ -4735,7 +4741,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4775,7 +4781,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4809,7 +4815,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4829,7 +4835,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4877,7 +4883,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "all_asserts", "arrow", @@ -4903,7 +4909,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4946,7 +4952,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -4960,7 +4966,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "approx", "arrow", @@ -5041,7 +5047,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -5092,7 +5098,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "approx", "arrow-array", @@ -5113,7 +5119,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -5128,7 +5134,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -5146,7 +5152,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -5205,7 +5211,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -5251,7 +5257,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "proc-macro2", "quote", @@ -5260,7 +5266,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "arrow-schema", @@ -5271,7 +5277,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "clap", "lance-core", @@ -5419,14 +5425,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags 2.11.0", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.4", ] [[package]] @@ -7267,9 +7273,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ "bitflags 2.11.0", ] @@ -7651,9 +7657,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" dependencies = [ "aws-lc-rs", "ring", diff --git a/Cargo.toml b/Cargo.toml index 763ec09ed08..d6190526bb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ resolver = "3" [workspace.package] -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -53,24 +53,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=5.1.0-beta.0", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=5.1.0-beta.0", path = "./rust/lance-arrow" } -lance-core = { version = "=5.1.0-beta.0", path = "./rust/lance-core" } -lance-datafusion = { version = "=5.1.0-beta.0", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=5.1.0-beta.0", path = "./rust/lance-datagen" } -lance-encoding = { version = "=5.1.0-beta.0", path = "./rust/lance-encoding" } -lance-file = { version = "=5.1.0-beta.0", path = "./rust/lance-file" } -lance-geo = { version = "=5.1.0-beta.0", path = "./rust/lance-geo" } -lance-index = { version = "=5.1.0-beta.0", path = "./rust/lance-index" } -lance-io = { version = "=5.1.0-beta.0", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=5.1.0-beta.0", path = "./rust/lance-linalg" } -lance-namespace = { version = "=5.1.0-beta.0", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=5.1.0-beta.0", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=5.1.0-beta.0", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=5.1.0-beta.1", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=5.1.0-beta.1", path = "./rust/lance-arrow" } +lance-core = { version = "=5.1.0-beta.1", path = "./rust/lance-core" } +lance-datafusion = { version = "=5.1.0-beta.1", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=5.1.0-beta.1", path = "./rust/lance-datagen" } +lance-encoding = { version = "=5.1.0-beta.1", path = "./rust/lance-encoding" } +lance-file = { version = "=5.1.0-beta.1", path = "./rust/lance-file" } +lance-geo = { version = "=5.1.0-beta.1", path = "./rust/lance-geo" } +lance-index = { version = "=5.1.0-beta.1", path = "./rust/lance-index" } +lance-io = { version = "=5.1.0-beta.1", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=5.1.0-beta.1", path = "./rust/lance-linalg" } +lance-namespace = { version = "=5.1.0-beta.1", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=5.1.0-beta.1", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=5.1.0-beta.1", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=5.1.0-beta.0", path = "./rust/lance-table" } -lance-test-macros = { version = "=5.1.0-beta.0", path = "./rust/lance-test-macros" } -lance-testing = { version = "=5.1.0-beta.0", path = "./rust/lance-testing" } +lance-table = { version = "=5.1.0-beta.1", path = "./rust/lance-table" } +lance-test-macros = { version = "=5.1.0-beta.1", path = "./rust/lance-test-macros" } +lance-testing = { version = "=5.1.0-beta.1", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -97,7 +97,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=5.1.0-beta.0", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=5.1.0-beta.1", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -138,7 +138,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=5.1.0-beta.0", path = "./rust/compression/fsst" } +fsst = { version = "=5.1.0-beta.1", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index c4a4d7c29c3..2e09f58b0e6 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -1085,9 +1085,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.59" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -2398,7 +2398,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "rand 0.9.2", @@ -2780,6 +2780,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heapless" version = "0.8.0" @@ -3161,12 +3167,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -3397,7 +3403,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3463,7 +3469,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3484,7 +3490,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrayref", "paste", @@ -3493,7 +3499,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3530,7 +3536,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3563,7 +3569,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3581,7 +3587,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -3618,7 +3624,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -3650,7 +3656,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -3664,7 +3670,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3732,7 +3738,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3776,7 +3782,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3812,7 +3818,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3828,7 +3834,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -3841,7 +3847,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -3886,7 +3892,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4013,9 +4019,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] @@ -5496,9 +5502,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" dependencies = [ "aws-lc-rs", "ring", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 45be8eed4ec..37cd77a2d21 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 05e14c4af00..27296cb0cfd 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 5.1.0-beta.0 + 5.1.0-beta.1 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index 4ea4a84e3f3..c081b09b0b2 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -1244,9 +1244,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.59" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -2827,7 +2827,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3218,6 +3218,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heapless" version = "0.8.0" @@ -3632,12 +3638,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -3865,7 +3871,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3932,7 +3938,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3953,7 +3959,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrayref", "paste", @@ -3962,7 +3968,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3999,7 +4005,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4032,7 +4038,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4050,7 +4056,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4087,7 +4093,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4119,7 +4125,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -4133,7 +4139,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -4204,7 +4210,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -4248,7 +4254,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4264,7 +4270,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -4277,7 +4283,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -4322,7 +4328,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4511,14 +4517,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags 2.11.0", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.4", ] [[package]] @@ -5686,7 +5692,7 @@ dependencies = [ [[package]] name = "pylance" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -6073,9 +6079,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ "bitflags 2.11.0", ] @@ -6400,9 +6406,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" dependencies = [ "aws-lc-rs", "ring", diff --git a/python/Cargo.toml b/python/Cargo.toml index ea7bb705483..a9f6216e2e1 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "5.1.0-beta.0" +version = "5.1.0-beta.1" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From e7369fbca3f1ea81f9992b214b446005dc04c387 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Fri, 10 Apr 2026 14:09:22 -0700 Subject: [PATCH 170/206] feat: wire batch_size_bytes to Python and public Rust API (#6428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Stacked on #6388. Please merge that PR first. - Adds `batch_size_bytes: Option` to `FileReaderOptions` and propagates it through all 6 `SchedulerDecoderConfig` creation sites in the file reader - Adds `batch_size_bytes` field + setter to `Scanner`, wired through both `scan_fragments` (via `LanceScanConfig`) and `pushdown_scan` (via `FileReaderOptions` in `ScanConfig`) - Adds `batch_size_bytes` to `LanceScanConfig`, with `try_new_v2` injecting it into `FragReadConfig` via `FileReaderOptions` - Exposes `batch_size_bytes` in the Python API: `LanceDataset.scanner()`, `to_table()`, `to_batches()`, `ScannerBuilder` ## Test plan - [x] `cargo check -p lance-file -p lance --tests` — clean - [x] `cargo clippy -p lance-file -p lance --tests -- -D warnings` — clean - [x] `cargo fmt --all` — applied - [x] `cargo test -p lance-encoding -- byte_sized` — 3/3 pass - [x] `cargo test -p lance -- test_scan` — 38/38 pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- python/python/lance/dataset.py | 41 +++++++++++++++-- python/src/dataset.rs | 6 ++- rust/lance-file/src/reader.rs | 26 ++++++++--- rust/lance/src/dataset.rs | 3 ++ rust/lance/src/dataset/scanner.rs | 58 ++++++++++++++++++++++--- rust/lance/src/io/exec/filtered_read.rs | 20 ++++++++- rust/lance/src/io/exec/scan.rs | 23 +++++++--- 7 files changed, 152 insertions(+), 25 deletions(-) diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index b4aaa89ff2c..3cb783c6536 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -834,6 +834,7 @@ def scanner( offset: Optional[int] = None, nearest: Optional[dict] = None, batch_size: Optional[int] = None, + batch_size_bytes: Optional[int] = None, batch_readahead: Optional[int] = None, fragment_readahead: Optional[int] = None, scan_in_order: Optional[bool] = None, @@ -929,9 +930,16 @@ def scanner( } batch_size: int, default None - The target size of batches returned. In some cases batches can be up to - twice this size (but never larger than this). In some cases batches can - be smaller than this size. + The maximum number of rows per batch. In some cases batches can be + smaller than this size. Note: this can be overridden by + ``batch_size_bytes`` or by a dataset-level ``batch_size_bytes`` + configured via ``FileReaderOptions``. + batch_size_bytes: int, default None + If set, the scanner will produce batches whose total size in bytes + is approximately this value, overriding the row-based ``batch_size``. + This can also be configured at the dataset level via + ``FileReaderOptions``. A scanner-level setting takes precedence + over the dataset-level default. io_buffer_size: int, default None The size of the IO buffer. See ``ScannerBuilder.io_buffer_size`` for more information. @@ -1067,6 +1075,7 @@ def setopt(opt, val): setopt(builder.limit, limit) setopt(builder.offset, offset) setopt(builder.batch_size, batch_size) + setopt(builder.batch_size_bytes, batch_size_bytes) setopt(builder.io_buffer_size, io_buffer_size) setopt(builder.batch_readahead, batch_readahead) setopt(builder.fragment_readahead, fragment_readahead) @@ -1150,6 +1159,7 @@ def to_table( offset: Optional[int] = None, nearest: Optional[dict] = None, batch_size: Optional[int] = None, + batch_size_bytes: Optional[int] = None, batch_readahead: Optional[int] = None, fragment_readahead: Optional[int] = None, scan_in_order: Optional[bool] = None, @@ -1277,6 +1287,7 @@ def to_table( offset=offset, nearest=nearest, batch_size=batch_size, + batch_size_bytes=batch_size_bytes, io_buffer_size=io_buffer_size, batch_readahead=batch_readahead, fragment_readahead=fragment_readahead, @@ -1720,6 +1731,7 @@ def to_batches( offset: Optional[int] = None, nearest: Optional[dict] = None, batch_size: Optional[int] = None, + batch_size_bytes: Optional[int] = None, batch_readahead: Optional[int] = None, fragment_readahead: Optional[int] = None, scan_in_order: Optional[bool] = None, @@ -1756,6 +1768,7 @@ def to_batches( offset=offset, nearest=nearest, batch_size=batch_size, + batch_size_bytes=batch_size_bytes, io_buffer_size=io_buffer_size, batch_readahead=batch_readahead, fragment_readahead=fragment_readahead, @@ -5189,6 +5202,7 @@ def __init__(self, ds: LanceDataset): self._columns_with_transform = None self._nearest = None self._batch_size: Optional[int] = None + self._batch_size_bytes: Optional[int] = None self._io_buffer_size: Optional[int] = None self._batch_readahead: Optional[int] = None self._fragment_readahead: Optional[int] = None @@ -5219,10 +5233,28 @@ def apply_defaults(self, default_opts: Dict[str, Any]) -> ScannerBuilder: return self def batch_size(self, batch_size: int) -> ScannerBuilder: - """Set batch size for Scanner""" + """Set the maximum number of rows per batch. + + Note: this can be overridden by ``batch_size_bytes`` or by a + dataset-level ``batch_size_bytes`` configured via + ``FileReaderOptions``. + """ self._batch_size = batch_size return self + def batch_size_bytes(self, batch_size_bytes: int) -> ScannerBuilder: + """Set the target batch size in bytes. + + When set, the scanner will produce batches whose total size in bytes + is approximately this value, overriding the row-based ``batch_size``. + + This can also be configured at the dataset level via + ``FileReaderOptions``. A scanner-level setting takes precedence + over the dataset-level default. + """ + self._batch_size_bytes = batch_size_bytes + return self + def io_buffer_size(self, io_buffer_size: int) -> ScannerBuilder: """ Set the I/O buffer size for the Scanner @@ -5607,6 +5639,7 @@ def to_scanner(self) -> LanceScanner: self._offset, self._nearest, self._batch_size, + self._batch_size_bytes, self._io_buffer_size, self._batch_readahead, self._fragment_readahead, diff --git a/python/src/dataset.rs b/python/src/dataset.rs index 249e2b1476e..1fb6fee9107 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -800,7 +800,7 @@ impl Dataset { } #[allow(clippy::too_many_arguments)] - #[pyo3(signature=(columns=None, columns_with_transform=None, filter=None, search_filter=None, prefilter=None, limit=None, offset=None, nearest=None, batch_size=None, io_buffer_size=None, batch_readahead=None, fragment_readahead=None, scan_in_order=None, fragments=None, with_row_id=None, with_row_address=None, use_stats=None, substrait_filter=None, fast_search=None, full_text_query=None, late_materialization=None, blob_handling=None, use_scalar_index=None, include_deleted_rows=None, scan_stats_callback=None, strict_batch_size=None, order_by=None, disable_scoring_autoprojection=None, substrait_aggregate=None))] + #[pyo3(signature=(columns=None, columns_with_transform=None, filter=None, search_filter=None, prefilter=None, limit=None, offset=None, nearest=None, batch_size=None, batch_size_bytes=None, io_buffer_size=None, batch_readahead=None, fragment_readahead=None, scan_in_order=None, fragments=None, with_row_id=None, with_row_address=None, use_stats=None, substrait_filter=None, fast_search=None, full_text_query=None, late_materialization=None, blob_handling=None, use_scalar_index=None, include_deleted_rows=None, scan_stats_callback=None, strict_batch_size=None, order_by=None, disable_scoring_autoprojection=None, substrait_aggregate=None))] fn scanner( self_: PyRef<'_, Self>, columns: Option>, @@ -812,6 +812,7 @@ impl Dataset { offset: Option, nearest: Option<&Bound>, batch_size: Option, + batch_size_bytes: Option, io_buffer_size: Option, batch_readahead: Option, fragment_readahead: Option, @@ -956,6 +957,9 @@ impl Dataset { if let Some(batch_size) = batch_size { scanner.batch_size(batch_size); } + if let Some(batch_size_bytes) = batch_size_bytes { + scanner.batch_size_bytes(batch_size_bytes); + } if let Some(io_buffer_size) = io_buffer_size { scanner.io_buffer_size(io_buffer_size); } diff --git a/rust/lance-file/src/reader.rs b/rust/lance-file/src/reader.rs index d41820f75a8..72734491d6b 100644 --- a/rust/lance-file/src/reader.rs +++ b/rust/lance-file/src/reader.rs @@ -342,6 +342,13 @@ pub struct FileReaderOptions { /// will be read in multiple chunks to control memory usage. /// Default: 8MB (DEFAULT_READ_CHUNK_SIZE) pub read_chunk_size: u64, + /// If set, the reader will produce batches whose total size in bytes + /// is approximately this value, overriding the row-based `batch_size`. + /// + /// This can be set at the dataset level (via `ReadParams::file_reader_options`) + /// to provide a default for all scans, or at the scanner level (via + /// `Scanner::batch_size_bytes`) to override per scan. + pub batch_size_bytes: Option, } impl Default for FileReaderOptions { @@ -349,6 +356,7 @@ impl Default for FileReaderOptions { Self { decoder_config: DecoderConfig::default(), read_chunk_size: DEFAULT_READ_CHUNK_SIZE, + batch_size_bytes: None, } } } @@ -871,6 +879,7 @@ impl FileReader { projection: ReaderProjection, filter: FilterExpression, decoder_config: DecoderConfig, + batch_size_bytes: Option, ) -> Result> { debug!( "Reading range {:?} with batch_size {} from file with {} rows and {} columns into schema with {} columns", @@ -887,7 +896,7 @@ impl FileReader { decoder_plugins, io, decoder_config, - batch_size_bytes: None, + batch_size_bytes, }; let requested_rows = RequestedRows::Ranges(vec![range]); @@ -921,6 +930,7 @@ impl FileReader { projection, filter, self.options.decoder_config.clone(), + self.options.batch_size_bytes, ) } @@ -935,6 +945,7 @@ impl FileReader { projection: ReaderProjection, filter: FilterExpression, decoder_config: DecoderConfig, + batch_size_bytes: Option, ) -> Result> { debug!( "Taking {} rows spread across range {}..{} with batch_size {} from columns {:?}", @@ -951,7 +962,7 @@ impl FileReader { decoder_plugins, io, decoder_config, - batch_size_bytes: None, + batch_size_bytes, }; let requested_rows = RequestedRows::Indices(indices); @@ -983,6 +994,7 @@ impl FileReader { projection, FilterExpression::no_filter(), self.options.decoder_config.clone(), + self.options.batch_size_bytes, ) } @@ -997,6 +1009,7 @@ impl FileReader { projection: ReaderProjection, filter: FilterExpression, decoder_config: DecoderConfig, + batch_size_bytes: Option, ) -> Result> { let num_rows = ranges.iter().map(|r| r.end - r.start).sum::(); debug!( @@ -1015,7 +1028,7 @@ impl FileReader { decoder_plugins, io, decoder_config, - batch_size_bytes: None, + batch_size_bytes, }; let requested_rows = RequestedRows::Ranges(ranges); @@ -1047,6 +1060,7 @@ impl FileReader { projection, filter, self.options.decoder_config.clone(), + self.options.batch_size_bytes, ) } @@ -1194,7 +1208,7 @@ impl FileReader { decoder_plugins: self.decoder_plugins.clone(), io: self.scheduler.clone(), decoder_config: self.options.decoder_config.clone(), - batch_size_bytes: None, + batch_size_bytes: self.options.batch_size_bytes, }; let requested_rows = RequestedRows::Indices(indices); @@ -1234,7 +1248,7 @@ impl FileReader { decoder_plugins: self.decoder_plugins.clone(), io: self.scheduler.clone(), decoder_config: self.options.decoder_config.clone(), - batch_size_bytes: None, + batch_size_bytes: self.options.batch_size_bytes, }; let requested_rows = RequestedRows::Ranges(ranges); @@ -1274,7 +1288,7 @@ impl FileReader { decoder_plugins: self.decoder_plugins.clone(), io: self.scheduler.clone(), decoder_config: self.options.decoder_config.clone(), - batch_size_bytes: None, + batch_size_bytes: self.options.batch_size_bytes, }; let requested_rows = RequestedRows::Ranges(vec![range]); diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index c5e5d24e653..b45aee6b814 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -257,6 +257,9 @@ pub struct ReadParams { /// File reader options to use when reading data files. /// /// This allows control over features like caching repetition indices and validation. + /// Options set here act as dataset-level defaults and can be overridden on a + /// per-scan basis via [`Scanner::batch_size_bytes`](crate::dataset::scanner::Scanner::batch_size_bytes) or + /// [`Scanner::with_file_reader_options`](crate::dataset::scanner::Scanner::with_file_reader_options). pub file_reader_options: Option, } diff --git a/rust/lance/src/dataset/scanner.rs b/rust/lance/src/dataset/scanner.rs index b7f5094ac35..5f40c79b4f9 100644 --- a/rust/lance/src/dataset/scanner.rs +++ b/rust/lance/src/dataset/scanner.rs @@ -719,6 +719,10 @@ pub struct Scanner { /// The batch size controls the maximum size of rows to return for each read. batch_size: Option, + /// If set, the scanner will produce batches whose total size in bytes + /// is approximately this value, overriding the row-based `batch_size`. + batch_size_bytes: Option, + /// Number of batches to prefetch batch_readahead: usize, @@ -989,6 +993,7 @@ impl Scanner { filter: LanceFilter::default(), full_text_query: None, batch_size: None, + batch_size_bytes: None, batch_readahead: get_num_compute_intensive_cpus(), fragment_readahead: None, io_buffer_size: None, @@ -1261,12 +1266,29 @@ impl Scanner { Ok(self) } - /// Set the batch size. + /// Set the maximum number of rows per batch. + /// + /// Note: this can be overridden by [`Self::batch_size_bytes`] or by a dataset-level + /// `batch_size_bytes` set via [`ReadParams::file_reader_options`](crate::dataset::ReadParams::file_reader_options). When a byte-based + /// batch size is active, the row-based batch size is used only as an initial estimate. pub fn batch_size(&mut self, batch_size: usize) -> &mut Self { self.batch_size = Some(batch_size); self } + /// Set the target batch size in bytes. + /// + /// When set, the scanner will produce batches whose total size in bytes + /// is approximately this value, overriding the row-based `batch_size`. + /// + /// This can also be configured at the dataset level via + /// [`ReadParams::file_reader_options`](crate::dataset::ReadParams::file_reader_options). A scanner-level setting takes + /// precedence over the dataset-level default. + pub fn batch_size_bytes(&mut self, batch_size_bytes: u64) -> &mut Self { + self.batch_size_bytes = Some(batch_size_bytes); + self + } + /// Include deleted rows /// /// These are rows that have been deleted from the dataset but are still present in the @@ -1688,6 +1710,30 @@ impl Scanner { self } + /// Compute the resolved file reader options, merging the scanner's explicit + /// `file_reader_options`, the dataset-level defaults, and the `batch_size_bytes` + /// setting. + fn resolved_file_reader_options(&self) -> Option { + let base = self + .file_reader_options + .clone() + .or_else(|| self.dataset.file_reader_options.clone()); + match (base, self.batch_size_bytes) { + (Some(mut opts), Some(bsb)) => { + if opts.batch_size_bytes.is_none() { + opts.batch_size_bytes = Some(bsb); + } + Some(opts) + } + (Some(opts), None) => Some(opts), + (None, Some(bsb)) => Some(FileReaderOptions { + batch_size_bytes: Some(bsb), + ..Default::default() + }), + (None, None) => None, + } + } + /// Create a physical expression for a column that may be nested fn create_column_expr( column_name: &str, @@ -2658,6 +2704,10 @@ impl Scanner { read_options = read_options.with_batch_size(batch_size as u32); } + if let Some(file_reader_options) = self.resolved_file_reader_options() { + read_options = read_options.with_file_reader_options(file_reader_options); + } + if let Some(fragment_readahead) = self.fragment_readahead { read_options = read_options.with_fragment_readahead(fragment_readahead); } @@ -4003,6 +4053,7 @@ impl Scanner { with_row_created_at_version, with_make_deletions_null, ordered_output: ordered, + file_reader_options: self.resolved_file_reader_options(), }; Arc::new(LanceScanExec::new( self.dataset.clone(), @@ -4029,10 +4080,7 @@ impl Scanner { with_row_address: self.projection_plan.physical_projection.with_row_addr, make_deletions_null, ordered_output: self.ordered, - file_reader_options: self - .file_reader_options - .clone() - .or_else(|| self.dataset.file_reader_options.clone()), + file_reader_options: self.resolved_file_reader_options(), }; let fragments = if let Some(fragment) = self.fragments.as_ref() { diff --git a/rust/lance/src/io/exec/filtered_read.rs b/rust/lance/src/io/exec/filtered_read.rs index e3837f9ce4f..2360c1a2212 100644 --- a/rust/lance/src/io/exec/filtered_read.rs +++ b/rust/lance/src/io/exec/filtered_read.rs @@ -42,6 +42,7 @@ use lance_datafusion::utils::{ ExecutionPlanMetricsSetExt, FRAGMENTS_SCANNED_METRIC, RANGES_SCANNED_METRIC, ROWS_SCANNED_METRIC, TASK_WAIT_TIME_METRIC, }; +use lance_file::reader::FileReaderOptions; use lance_index::scalar::expression::{FilterPlan, IndexExprResult}; use lance_io::scheduler::{ScanScheduler, SchedulerConfig}; use lance_table::format::Fragment; @@ -118,6 +119,7 @@ struct ScopedFragmentRead { projection: Arc, with_deleted_rows: bool, batch_size: u32, + file_reader_options: Option, // An in-memory filter to apply after reading the fragment (whatever couldn't be // pushed down into the index query) filter: Option, @@ -127,13 +129,17 @@ struct ScopedFragmentRead { impl ScopedFragmentRead { fn frag_read_config(&self) -> FragReadConfig { - FragReadConfig::default() + let mut config = FragReadConfig::default() .with_row_id(self.with_deleted_rows || self.projection.with_row_id) .with_row_address(self.projection.with_row_addr) .with_row_last_updated_at_version(self.projection.with_row_last_updated_at_version) .with_row_created_at_version(self.projection.with_row_created_at_version) .with_scan_scheduler(self.scan_scheduler.clone()) - .with_reader_priority(self.priority) + .with_reader_priority(self.priority); + if let Some(file_reader_options) = &self.file_reader_options { + config = config.with_file_reader_options(file_reader_options.clone()); + } + config } } @@ -669,6 +675,7 @@ impl FilteredReadStream { projection: projection.clone(), with_deleted_rows: options.with_deleted_rows, batch_size: default_batch_size, + file_reader_options: options.file_reader_options.clone(), filter, priority: priority as u32, scan_scheduler: scan_scheduler.clone(), @@ -1243,6 +1250,8 @@ pub struct FilteredReadOptions { pub with_deleted_rows: bool, /// The maximum number of rows per batch pub batch_size: Option, + /// File reader options to use when reading data files. + pub file_reader_options: Option, /// Controls how many fragments to read ahead pub fragment_readahead: Option, /// The fragments to read @@ -1281,6 +1290,7 @@ impl FilteredReadOptions { scan_range_after_filter: None, with_deleted_rows: false, batch_size: None, + file_reader_options: None, fragment_readahead: None, fragments: None, projection, @@ -1369,6 +1379,12 @@ impl FilteredReadOptions { self } + /// Specify the file reader options to use when reading data files. + pub fn with_file_reader_options(mut self, file_reader_options: FileReaderOptions) -> Self { + self.file_reader_options = Some(file_reader_options); + self + } + /// Controls how many fragments to read ahead. /// /// If not set, the default will be 2 * the I/O parallelism. Generally, reading ahead diff --git a/rust/lance/src/io/exec/scan.rs b/rust/lance/src/io/exec/scan.rs index 030016c9d78..c7736eccfbc 100644 --- a/rust/lance/src/io/exec/scan.rs +++ b/rust/lance/src/io/exec/scan.rs @@ -27,6 +27,7 @@ use lance_arrow::SchemaExt; use lance_core::utils::tokio::get_num_compute_intensive_cpus; use lance_core::utils::tracing::StreamTracingExt; use lance_core::{Error, ROW_ADDR_FIELD, ROW_ID_FIELD}; +use lance_file::reader::FileReaderOptions; use lance_io::scheduler::{ScanScheduler, SchedulerConfig}; use lance_table::format::Fragment; use log::debug; @@ -274,25 +275,31 @@ impl LanceStream { let scan_scheduler_clone = scan_scheduler.clone(); + let config_for_stream = config.clone(); let batches = stream::iter(file_fragments.into_iter().enumerate()) .map(move |(priority, file_fragment)| { let project_schema = project_schema.clone(); let scan_scheduler = scan_scheduler.clone(); + let config = config_for_stream.clone(); #[allow(clippy::type_complexity)] let frag_task: BoxFuture< Result>>>>, > = tokio::spawn( (async move { + let mut frag_config = FragReadConfig::default() + .with_row_id(config.with_row_id) + .with_row_address(config.with_row_address) + .with_row_last_updated_at_version( + config.with_row_last_updated_at_version, + ) + .with_row_created_at_version(config.with_row_created_at_version); + if let Some(file_reader_options) = config.file_reader_options { + frag_config = frag_config.with_file_reader_options(file_reader_options); + } let reader = open_file( file_fragment.fragment, project_schema, - FragReadConfig::default() - .with_row_id(config.with_row_id) - .with_row_address(config.with_row_address) - .with_row_last_updated_at_version( - config.with_row_last_updated_at_version, - ) - .with_row_created_at_version(config.with_row_created_at_version), + frag_config, config.with_make_deletions_null, Some((scan_scheduler, priority as u32)), ) @@ -499,6 +506,7 @@ pub struct LanceScanConfig { pub with_row_created_at_version: bool, pub with_make_deletions_null: bool, pub ordered_output: bool, + pub file_reader_options: Option, } // This is mostly for testing purposes, end users are unlikely to create this @@ -516,6 +524,7 @@ impl Default for LanceScanConfig { with_row_created_at_version: false, with_make_deletions_null: false, ordered_output: false, + file_reader_options: None, } } } From 5aca7037bcb538214c1d5da135fff2bcb80a5b95 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Fri, 10 Apr 2026 15:24:18 -0700 Subject: [PATCH 171/206] chore: remove custom cast_with_options in favor of upstream (#6344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `arrow-cast` 57.0.0 added native support for `FixedSizeList → FixedSizeList` casting, which was the only reason `lance_arrow::cast::cast_with_options` existed. This removes the wrapper and updates all call sites to use `arrow_cast::cast_with_options` directly. --------- Co-authored-by: Claude Sonnet 4.6 --- Cargo.lock | 2 ++ python/Cargo.lock | 3 +++ python/Cargo.toml | 1 + python/src/dataset.rs | 16 ++++++------ rust/lance-arrow/src/cast.rs | 30 ---------------------- rust/lance-arrow/src/lib.rs | 1 - rust/lance-datafusion/Cargo.toml | 1 + rust/lance-datafusion/src/planner.rs | 2 +- rust/lance/Cargo.toml | 1 + rust/lance/src/dataset/schema_evolution.rs | 5 ++-- 10 files changed, 20 insertions(+), 42 deletions(-) delete mode 100644 rust/lance-arrow/src/cast.rs diff --git a/Cargo.lock b/Cargo.lock index f1fee157c4a..64837a91a3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4594,6 +4594,7 @@ dependencies = [ "arrow-arith", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ipc", "arrow-ord", "arrow-row", @@ -4786,6 +4787,7 @@ dependencies = [ "arrow", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ord", "arrow-schema", "arrow-select", diff --git a/python/Cargo.lock b/python/Cargo.lock index c081b09b0b2..0bf5676decc 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -3877,6 +3877,7 @@ dependencies = [ "arrow-arith", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ipc", "arrow-ord", "arrow-row", @@ -4010,6 +4011,7 @@ dependencies = [ "arrow", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ord", "arrow-schema", "arrow-select", @@ -5696,6 +5698,7 @@ version = "5.1.0-beta.1" dependencies = [ "arrow", "arrow-array", + "arrow-cast", "arrow-data", "arrow-schema", "async-trait", diff --git a/python/Cargo.toml b/python/Cargo.toml index a9f6216e2e1..a96b0726d71 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["cdylib"] [dependencies] arrow = { version = "57.0.0", features = ["pyarrow"] } arrow-array = "57.0.0" +arrow-cast = "57.0.0" arrow-data = "57.0.0" arrow-schema = "57.0.0" object_store = "0.12.4" diff --git a/python/src/dataset.rs b/python/src/dataset.rs index 1fb6fee9107..e1144788a7f 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -12,6 +12,7 @@ use arrow::ffi_stream::ArrowArrayStreamReader; use arrow::pyarrow::*; use arrow_array::Array; use arrow_array::{RecordBatch, RecordBatchReader, make_array}; +use arrow_cast::cast_with_options; use arrow_data::ArrayData; use arrow_schema::{DataType, Schema as ArrowSchema}; use async_trait::async_trait; @@ -3610,14 +3611,13 @@ fn prepare_vector_index_params( // as the vectors that will be indexed. let mut centroids: Arc = batch.column(0).clone(); if centroids.data_type() != column_type { - centroids = lance_arrow::cast::cast_with_options( - centroids.as_ref(), - column_type, - &Default::default(), - ) - .map_err(|e| { - PyValueError::new_err(format!("Failed to cast centroids to column type: {}", e)) - })?; + centroids = cast_with_options(centroids.as_ref(), column_type, &Default::default()) + .map_err(|e| { + PyValueError::new_err(format!( + "Failed to cast centroids to column type: {}", + e + )) + })?; } let centroids = as_fixed_size_list_array(centroids.as_ref()); diff --git a/rust/lance-arrow/src/cast.rs b/rust/lance-arrow/src/cast.rs deleted file mode 100644 index 0fa4273ceea..00000000000 --- a/rust/lance-arrow/src/cast.rs +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -use std::sync::Arc; - -use arrow_array::{Array, ArrayRef, FixedSizeListArray}; -use arrow_cast::CastOptions; -use arrow_schema::{ArrowError, DataType}; - -/// Customized [`arrow_cast::cast_with_options`] that handles cases not supported upstream yet. -pub fn cast_with_options( - array: &dyn Array, - to_type: &DataType, - cast_options: &CastOptions, -) -> Result { - use DataType::*; - match (array.data_type(), to_type) { - (FixedSizeList(_, size_from), FixedSizeList(to_field, size_to)) if size_from == size_to => { - let array = array.as_any().downcast_ref::().unwrap(); - let values = cast_with_options(array.values(), to_field.data_type(), cast_options)?; - Ok(Arc::new(FixedSizeListArray::try_new( - to_field.clone(), - *size_from, - values, - array.nulls().cloned(), - )?)) - } - _ => arrow_cast::cast_with_options(array, to_type, cast_options), - } -} diff --git a/rust/lance-arrow/src/lib.rs b/rust/lance-arrow/src/lib.rs index f936338e5ba..f27696f64a9 100644 --- a/rust/lance-arrow/src/lib.rs +++ b/rust/lance-arrow/src/lib.rs @@ -30,7 +30,6 @@ pub mod floats; use crate::list::ListArrayExt; pub use floats::*; -pub mod cast; pub mod ipc; pub mod json; pub mod list; diff --git a/rust/lance-datafusion/Cargo.toml b/rust/lance-datafusion/Cargo.toml index 2a79173c0c0..3c370cfa3c4 100644 --- a/rust/lance-datafusion/Cargo.toml +++ b/rust/lance-datafusion/Cargo.toml @@ -13,6 +13,7 @@ version.workspace = true arrow = {workspace = true, features = ["ffi"]} arrow-array = {workspace = true, features = ["ffi"]} arrow-buffer.workspace = true +arrow-cast.workspace = true arrow-ord.workspace = true arrow-schema.workspace = true arrow-select.workspace = true diff --git a/rust/lance-datafusion/src/planner.rs b/rust/lance-datafusion/src/planner.rs index 5d998d0ae6b..1b163963e61 100644 --- a/rust/lance-datafusion/src/planner.rs +++ b/rust/lance-datafusion/src/planner.rs @@ -14,6 +14,7 @@ use crate::sql::{parse_sql_expr, parse_sql_filter}; use arrow::compute::CastOptions; use arrow_array::ListArray; use arrow_buffer::OffsetBuffer; +use arrow_cast::cast_with_options; use arrow_schema::{DataType as ArrowDataType, Field, SchemaRef, TimeUnit}; use arrow_select::concat::concat; use datafusion::common::DFSchema; @@ -45,7 +46,6 @@ use datafusion::{ scalar::ScalarValue, }; use datafusion_functions::core::getfield::GetFieldFunc; -use lance_arrow::cast::cast_with_options; use lance_core::datatypes::Schema; use lance_core::error::LanceOptionExt; diff --git a/rust/lance/Cargo.toml b/rust/lance/Cargo.toml index e2755822b88..afb7886b5cf 100644 --- a/rust/lance/Cargo.toml +++ b/rust/lance/Cargo.toml @@ -30,6 +30,7 @@ lance-geo = { workspace = true, optional = true } arrow-arith = { workspace = true } arrow-array = { workspace = true } arrow-buffer = { workspace = true } +arrow-cast = { workspace = true } arrow-ipc = { workspace = true } arrow-ord = { workspace = true } arrow-row = { workspace = true } diff --git a/rust/lance/src/dataset/schema_evolution.rs b/rust/lance/src/dataset/schema_evolution.rs index 269093fc98b..f5d792979df 100644 --- a/rust/lance/src/dataset/schema_evolution.rs +++ b/rust/lance/src/dataset/schema_evolution.rs @@ -12,6 +12,7 @@ use crate::{Error, Result, io::exec::Planner}; use arrow::compute::CastOptions; use arrow::compute::can_cast_types; use arrow_array::{Array, RecordBatch, RecordBatchReader}; +use arrow_cast::cast_with_options; use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; use datafusion::execution::SendableRecordBatchStream; use futures::stream::{StreamExt, TryStreamExt}; @@ -635,7 +636,7 @@ pub(super) async fn alter_columns( let mut columns = Vec::with_capacity(batch.num_columns()); for (old, new) in &cast_fields { let old_column = batch[&old.name].clone(); - let new_column = lance_arrow::cast::cast_with_options( + let new_column = cast_with_options( &old_column, &new.data_type(), // Safe: false means it will error if the cast is lossy. @@ -1963,7 +1964,7 @@ mod test { Arc::new(Float16Array::from_iter_values( (0..nrows).map(|i| f16::from_f32(i as f32)), )), - lance_arrow::cast::cast_with_options( + cast_with_options( batch["vec"].as_ref(), &DataType::FixedSizeList( Arc::new(ArrowField::new("item", DataType::Float16, true)), From 6db7525152c7785022ef8112e35ac789a504a51f Mon Sep 17 00:00:00 2001 From: Will Jones Date: Fri, 10 Apr 2026 15:24:26 -0700 Subject: [PATCH 172/206] feat: clean up transaction files on failed commits (#6319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when a manifest commit failed (conflict, error, or retry exhaustion), the `.txn` file written to `_transactions/` was left orphaned. These files would accumulate until the GC cleanup interval (7+ days by default). This PR adds `cleanup_transaction_file()` — a best-effort delete helper — and calls it from all three commit failure paths (`do_commit_new_dataset`, `do_commit_detached_transaction`, `commit_transaction`). Failures to delete are logged as warnings and do not surface to the caller. In the retry loop of `commit_transaction`, the previous iteration's transaction file is cleaned up before each retry attempt, since a new transaction file is written on each iteration. Fixes #6125 Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- rust/lance/src/dataset/cleanup.rs | 3 +- rust/lance/src/io/commit.rs | 128 ++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/rust/lance/src/dataset/cleanup.rs b/rust/lance/src/dataset/cleanup.rs index bb7e6fe2ff2..0c6b78253b9 100644 --- a/rust/lance/src/dataset/cleanup.rs +++ b/rust/lance/src/dataset/cleanup.rs @@ -2396,7 +2396,8 @@ mod tests { // deposited a data file assert_eq!(before_count.num_data_files, 2); assert_eq!(before_count.num_manifest_files, 1); - assert_eq!(before_count.num_tx_files, 2); + // Only 1 txn file: the failed commit's txn file was already cleaned up. + assert_eq!(before_count.num_tx_files, 1); // All of our manifests are newer than the threshold but temp files // should still be deleted. diff --git a/rust/lance/src/io/commit.rs b/rust/lance/src/io/commit.rs index 7c35957fd75..fa59454d973 100644 --- a/rust/lance/src/io/commit.rs +++ b/rust/lance/src/io/commit.rs @@ -86,6 +86,29 @@ pub(crate) async fn read_transaction_file( transaction.try_into() } +/// Best-effort delete of a transaction file that is no longer needed. +/// +/// Logs a warning on failure rather than propagating the error, since the +/// primary operation has already failed and the orphaned file will eventually +/// be removed by GC. +async fn cleanup_transaction_file( + object_store: &ObjectStore, + base_path: &Path, + transaction_file: &str, +) { + if transaction_file.is_empty() { + return; + } + let path = base_path.child(TRANSACTIONS_DIR).child(transaction_file); + if let Err(e) = object_store.delete(&path).await { + log::warn!( + "Failed to clean up orphaned transaction file '{}': {}", + transaction_file, + e + ); + } +} + /// Write a transaction to a file and return the relative path. pub(crate) async fn write_transaction_file( object_store: &ObjectStore, @@ -153,7 +176,7 @@ async fn do_commit_new_dataset( ref_path.clone(), new_base_id, branch_name.clone(), - transaction_file, + transaction_file.clone(), ); let updated_indices = if let Some(index_section_pos) = source_manifest.index_section { @@ -252,9 +275,13 @@ async fn do_commit_new_dataset( Ok((manifest, manifest_location)) } Err(CommitError::CommitConflict) => { + cleanup_transaction_file(object_store, base_path, &transaction_file).await; Err(crate::Error::dataset_already_exists(base_path.to_string())) } - Err(CommitError::OtherError(err)) => Err(err), + Err(CommitError::OtherError(err)) => { + cleanup_transaction_file(object_store, base_path, &transaction_file).await; + Err(err) + } } } @@ -812,6 +839,7 @@ pub(crate) async fn do_commit_detached_transaction( } Err(CommitError::OtherError(err)) => { // If other error, return + cleanup_transaction_file(object_store, &dataset.base, &transaction_file).await; return Err(err); } } @@ -819,6 +847,7 @@ pub(crate) async fn do_commit_detached_transaction( // This should be extremely unlikely. There should not be *that* many detached commits. If // this happens then it seems more likely there is a bug in our random u64 generation. + cleanup_transaction_file(object_store, &dataset.base, &transaction_file).await; Err(crate::Error::commit_conflict_source( 0, format!( @@ -904,6 +933,9 @@ pub(crate) async fn commit_transaction( // Other transactions that may have been committed since the read_version. // We keep pair of (version, transaction). No other transactions to check initially let mut other_transactions: Vec<(u64, Arc)>; + // Track the transaction file written in the current loop iteration so we can + // delete it if the commit ultimately fails. + let mut current_transaction_file = String::new(); while backoff.attempt() < num_attempts { // We are pessimistic here and assume there may be other transactions @@ -931,11 +963,12 @@ pub(crate) async fn commit_transaction( transaction = rebase.finish(&dataset).await?; } - let transaction_file = if !write_config.disable_transaction_file() { + current_transaction_file = if !write_config.disable_transaction_file() { write_transaction_file(object_store, &dataset.base, &transaction).await? } else { String::new() }; + let transaction_file = current_transaction_file.as_str(); target_version = dataset.manifest.version + 1; if is_detached_version(target_version) { @@ -952,7 +985,7 @@ pub(crate) async fn commit_transaction( &dataset.base, version, write_config, - &transaction_file, + transaction_file, &dataset.manifest, ) .await? @@ -960,7 +993,7 @@ pub(crate) async fn commit_transaction( _ => transaction.build_manifest( Some(dataset.manifest.as_ref()), dataset.load_indices().await?.as_ref().clone(), - &transaction_file, + transaction_file, write_config, )?, }; @@ -1053,6 +1086,14 @@ pub(crate) async fn commit_transaction( } if next_attempt_i < num_attempts { + // The transaction file from this attempt is now stale; clean it up + // before the next attempt writes a new one (possibly rebased). + cleanup_transaction_file( + object_store, + &dataset.base, + ¤t_transaction_file, + ) + .await; tokio::time::sleep(backoff.next_backoff()).await; continue; } else { @@ -1060,12 +1101,14 @@ pub(crate) async fn commit_transaction( } } Err(CommitError::OtherError(err)) => { - // If other error, return + cleanup_transaction_file(object_store, &dataset.base, ¤t_transaction_file) + .await; return Err(err); } } } + cleanup_transaction_file(object_store, &dataset.base, ¤t_transaction_file).await; Err(crate::Error::commit_conflict_source( target_version, format!( @@ -1092,7 +1135,7 @@ mod tests { use lance_linalg::distance::MetricType; use lance_table::format::{DataFile, DataStorageFormat}; use lance_table::io::commit::{ - CommitLease, CommitLock, RenameCommitHandler, UnsafeCommitHandler, + CommitLease, CommitLock, ManifestWriter, RenameCommitHandler, UnsafeCommitHandler, }; use lance_testing::datagen::generate_random_array; @@ -1700,6 +1743,77 @@ mod tests { assert_eq!(manifest.fragments.as_ref(), &expected_fragments); } + /// A CommitHandler that always fails with OtherError, used to simulate + /// a manifest write failure so we can verify orphaned transaction files + /// are cleaned up. + #[derive(Debug)] + struct FailingCommitHandler; + + #[async_trait::async_trait] + impl CommitHandler for FailingCommitHandler { + async fn commit( + &self, + _manifest: &mut Manifest, + _indices: Option>, + _base_path: &Path, + _object_store: &ObjectStore, + _manifest_writer: ManifestWriter, + _naming_scheme: ManifestNamingScheme, + _transaction: Option, + ) -> std::result::Result { + Err(CommitError::OtherError(lance_core::Error::io( + "simulated commit failure", + ))) + } + } + + fn count_txn_files(uri: &str) -> usize { + let tx_dir = std::path::Path::new(uri).join("_transactions"); + std::fs::read_dir(&tx_dir) + .map(|rd| rd.filter_map(|e| e.ok()).count()) + .unwrap_or(0) + } + + #[tokio::test] + async fn test_transaction_file_cleanup_on_commit_failure() { + let tmp = TempStrDir::default(); + let uri = tmp.as_str(); + + // Create initial dataset with a normal commit handler. + let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new( + "x", + DataType::Int32, + false, + )])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![1, 2, 3]))], + ) + .unwrap(); + let reader = RecordBatchIterator::new(vec![Ok(batch.clone())], schema.clone()); + Dataset::write(reader, uri, None).await.unwrap(); + + let txn_files_before = count_txn_files(uri); + + // Attempt to append with a commit handler that always fails. + let params = WriteParams { + mode: WriteMode::Append, + commit_handler: Some(Arc::new(FailingCommitHandler)), + ..Default::default() + }; + let reader = RecordBatchIterator::new(vec![Ok(batch)], schema); + let result = Dataset::write(reader, uri, Some(params)).await; + assert!(result.is_err(), "expected commit to fail"); + + // The failed commit must not leave any extra transaction files behind. + let txn_files_after = count_txn_files(uri); + assert_eq!( + txn_files_after, + txn_files_before, + "failed commit left {extra} orphaned transaction file(s)", + extra = txn_files_after.saturating_sub(txn_files_before), + ); + } /// Helper to build a simple manifest for check_column_indices tests. fn make_manifest_with_file( schema: Schema, From df61d95cac9ab579e4bc4ff41d1bd749b24af7f0 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Sat, 11 Apr 2026 03:56:39 +0000 Subject: [PATCH 173/206] chore: release beta version 5.1.0-beta.2 --- .bumpversion.toml | 2 +- Cargo.lock | 70 +++++++++++++++++++-------------------- Cargo.toml | 40 +++++++++++----------- java/lance-jni/Cargo.lock | 64 ++++++++++++++++++----------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 62 +++++++++++++++++----------------- python/Cargo.toml | 2 +- 8 files changed, 123 insertions(+), 121 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index eff9efe9f82..f94c9d628c0 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "5.1.0-beta.1" +current_version = "5.1.0-beta.2" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 64837a91a3c..d03bb577111 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3348,7 +3348,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "lance-datagen", @@ -4530,9 +4530,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "cfg-if", "futures-util", @@ -4586,7 +4586,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "all_asserts", "approx", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -4733,7 +4733,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrayref", "paste", @@ -4742,7 +4742,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -4782,7 +4782,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4817,7 +4817,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4837,7 +4837,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4885,7 +4885,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "all_asserts", "arrow", @@ -4911,7 +4911,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4954,7 +4954,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "datafusion", "geo-traits", @@ -4968,7 +4968,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "approx", "arrow", @@ -5049,7 +5049,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -5100,7 +5100,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "approx", "arrow-array", @@ -5121,7 +5121,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "async-trait", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -5154,7 +5154,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-ipc", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -5259,7 +5259,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "proc-macro2", "quote", @@ -5268,7 +5268,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "arrow-schema", @@ -5279,7 +5279,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "clap", "lance-core", @@ -9372,9 +9372,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -9385,9 +9385,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ "js-sys", "wasm-bindgen", @@ -9395,9 +9395,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9405,9 +9405,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -9418,9 +9418,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -9474,9 +9474,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index d6190526bb1..2cc6e020bf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ resolver = "3" [workspace.package] -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -53,24 +53,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=5.1.0-beta.1", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=5.1.0-beta.1", path = "./rust/lance-arrow" } -lance-core = { version = "=5.1.0-beta.1", path = "./rust/lance-core" } -lance-datafusion = { version = "=5.1.0-beta.1", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=5.1.0-beta.1", path = "./rust/lance-datagen" } -lance-encoding = { version = "=5.1.0-beta.1", path = "./rust/lance-encoding" } -lance-file = { version = "=5.1.0-beta.1", path = "./rust/lance-file" } -lance-geo = { version = "=5.1.0-beta.1", path = "./rust/lance-geo" } -lance-index = { version = "=5.1.0-beta.1", path = "./rust/lance-index" } -lance-io = { version = "=5.1.0-beta.1", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=5.1.0-beta.1", path = "./rust/lance-linalg" } -lance-namespace = { version = "=5.1.0-beta.1", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=5.1.0-beta.1", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=5.1.0-beta.1", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=5.1.0-beta.2", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=5.1.0-beta.2", path = "./rust/lance-arrow" } +lance-core = { version = "=5.1.0-beta.2", path = "./rust/lance-core" } +lance-datafusion = { version = "=5.1.0-beta.2", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=5.1.0-beta.2", path = "./rust/lance-datagen" } +lance-encoding = { version = "=5.1.0-beta.2", path = "./rust/lance-encoding" } +lance-file = { version = "=5.1.0-beta.2", path = "./rust/lance-file" } +lance-geo = { version = "=5.1.0-beta.2", path = "./rust/lance-geo" } +lance-index = { version = "=5.1.0-beta.2", path = "./rust/lance-index" } +lance-io = { version = "=5.1.0-beta.2", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=5.1.0-beta.2", path = "./rust/lance-linalg" } +lance-namespace = { version = "=5.1.0-beta.2", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=5.1.0-beta.2", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=5.1.0-beta.2", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=5.1.0-beta.1", path = "./rust/lance-table" } -lance-test-macros = { version = "=5.1.0-beta.1", path = "./rust/lance-test-macros" } -lance-testing = { version = "=5.1.0-beta.1", path = "./rust/lance-testing" } +lance-table = { version = "=5.1.0-beta.2", path = "./rust/lance-table" } +lance-test-macros = { version = "=5.1.0-beta.2", path = "./rust/lance-test-macros" } +lance-testing = { version = "=5.1.0-beta.2", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -97,7 +97,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=5.1.0-beta.1", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=5.1.0-beta.2", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -138,7 +138,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=5.1.0-beta.1", path = "./rust/compression/fsst" } +fsst = { version = "=5.1.0-beta.2", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 2e09f58b0e6..0ab2c52fcd0 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -2398,7 +2398,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3356,9 +3356,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "cfg-if", "futures-util", @@ -3403,12 +3403,13 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ipc", "arrow-ord", "arrow-row", @@ -3469,7 +3470,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3490,7 +3491,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrayref", "paste", @@ -3499,7 +3500,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3536,11 +3537,12 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ord", "arrow-schema", "arrow-select", @@ -3569,7 +3571,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -3587,7 +3589,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -3624,7 +3626,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -3656,7 +3658,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "datafusion", "geo-traits", @@ -3670,7 +3672,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3738,7 +3740,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3782,7 +3784,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -3818,7 +3820,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3834,7 +3836,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "async-trait", @@ -3847,7 +3849,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-ipc", @@ -3892,7 +3894,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -6811,9 +6813,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -6824,9 +6826,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ "js-sys", "wasm-bindgen", @@ -6834,9 +6836,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6844,9 +6846,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -6857,9 +6859,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -6913,9 +6915,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 37cd77a2d21..4b7bfb739b5 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index 27296cb0cfd..ce3b011eea8 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 5.1.0-beta.1 + 5.1.0-beta.2 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index 0bf5676decc..cf91d80d5d0 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2827,7 +2827,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "rand 0.9.2", @@ -3815,9 +3815,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "cfg-if", "futures-util", @@ -3871,7 +3871,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -3939,7 +3939,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrayref", "paste", @@ -3969,7 +3969,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -4006,7 +4006,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4040,7 +4040,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -4058,7 +4058,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-arith", "arrow-array", @@ -4127,7 +4127,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "datafusion", "geo-traits", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -4212,7 +4212,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-arith", @@ -4256,7 +4256,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow-array", "arrow-buffer", @@ -4272,7 +4272,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "async-trait", @@ -4285,7 +4285,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-ipc", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -5694,7 +5694,7 @@ dependencies = [ [[package]] name = "pylance" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" dependencies = [ "arrow", "arrow-array", @@ -7841,9 +7841,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -7854,9 +7854,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ "js-sys", "wasm-bindgen", @@ -7864,9 +7864,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7874,9 +7874,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -7887,9 +7887,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -7943,9 +7943,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/python/Cargo.toml b/python/Cargo.toml index a96b0726d71..ca42417ba55 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "5.1.0-beta.1" +version = "5.1.0-beta.2" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 0634c6a9cb3344728f98a0e1eeed13c3950a6de4 Mon Sep 17 00:00:00 2001 From: BubbleCal Date: Sat, 11 Apr 2026 17:22:08 +0800 Subject: [PATCH 174/206] fix(index): preserve fts prewarm position codec (#6485) ## Bug Fix ### What is the bug? FTS v2 indices built with `with_position=true` can reconstruct cached posting lists with the wrong shared-position codec during prewarm. The prewarm path rebuilds `PostingList` values from projected `RecordBatch` objects and re-infers the positions codec from batch metadata, even though the reader has already resolved the correct partition-level `positions_layout`. ### What issues or incorrect behavior does the bug cause? After `prewarm_index(..., with_position=True)`, phrase queries can fail even though the same query succeeds before prewarm. In practice this shows up as shared position stream decode failures in the cached path because `PackedDelta` data can be interpreted as the legacy codec. ### How does this PR fix the problem? This PR threads `positions_layout` from `PostingListReader` through the prewarm reconstruction path into `CompressedPostingList::from_batch`, so cached postings reuse the already-parsed shared-position codec instead of guessing from projected batch metadata. It also adds a regression test that covers the V2 + positions + tail-remainder case and verifies prewarm preserves correct phrase-query behavior. ## Validation - `cargo test -p lance-index test_prewarm_with_ -- --nocapture` --- rust/lance-index/src/scalar/inverted/index.rs | 139 +++++++++++++++++- 1 file changed, 135 insertions(+), 4 deletions(-) diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index df8bba37056..caa5745f623 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -1691,9 +1691,15 @@ impl PostingListReader { max_score: Option, length: Option, posting_tail_codec: PostingTailCodec, + positions_layout: PositionsLayout, ) -> Result { - let posting_list = - PostingList::from_batch_with_tail_codec(batch, max_score, length, posting_tail_codec)?; + let posting_list = PostingList::from_batch_with_tail_codec_and_positions_layout( + batch, + max_score, + length, + posting_tail_codec, + positions_layout, + )?; Ok(posting_list) } @@ -1711,6 +1717,7 @@ impl PostingListReader { .as_ref() .map(|lengths| lengths[token_id as usize]), self.posting_tail_codec, + self.positions_layout, ) } @@ -1720,6 +1727,7 @@ impl PostingListReader { max_scores: Option>, lengths: Option>, posting_tail_codec: PostingTailCodec, + positions_layout: PositionsLayout, ) -> Result> { let token_count = if let Some(offsets) = offsets.as_ref() { offsets.len() @@ -1748,6 +1756,7 @@ impl PostingListReader { max_scores.as_ref().map(|scores| scores[token_id]), lengths.as_ref().map(|lengths| lengths[token_id]), posting_tail_codec, + positions_layout, )?; posting_lists.push((token_id as u32, posting_list)); } @@ -1771,6 +1780,7 @@ impl PostingListReader { let max_scores = self.max_scores.clone(); let lengths = self.lengths.clone(); let posting_tail_codec = self.posting_tail_codec; + let positions_layout = self.positions_layout; let populate_start = Instant::now(); let posting_lists = spawn_blocking(move || { Self::build_prewarm_posting_lists( @@ -1779,6 +1789,7 @@ impl PostingListReader { max_scores, lengths, posting_tail_codec, + positions_layout, ) }) .await @@ -2060,15 +2071,45 @@ impl PostingList { max_score: Option, length: Option, posting_tail_codec: PostingTailCodec, + ) -> Result { + let positions_layout = if batch.column_by_name(COMPRESSED_POSITION_COL).is_some() { + PositionsLayout::SharedStream(parse_shared_position_codec( + batch.schema_ref().metadata(), + )?) + } else if batch.column_by_name(POSITION_COL).is_some() { + PositionsLayout::LegacyPerDoc + } else { + PositionsLayout::None + }; + Self::from_batch_with_tail_codec_and_positions_layout( + batch, + max_score, + length, + posting_tail_codec, + positions_layout, + ) + } + + fn from_batch_with_tail_codec_and_positions_layout( + batch: &RecordBatch, + max_score: Option, + length: Option, + posting_tail_codec: PostingTailCodec, + positions_layout: PositionsLayout, ) -> Result { match batch.column_by_name(POSTING_COL) { Some(_) => { debug_assert!(max_score.is_some() && length.is_some()); + let shared_position_codec = match positions_layout { + PositionsLayout::SharedStream(codec) => Some(codec), + _ => None, + }; let posting = CompressedPostingList::from_batch( batch, max_score.unwrap(), length.unwrap(), posting_tail_codec, + shared_position_codec, ); Ok(Self::Compressed(posting)) } @@ -2343,6 +2384,7 @@ impl CompressedPostingList { max_score: f32, length: u32, posting_tail_codec: PostingTailCodec, + shared_position_codec: Option, ) -> Self { debug_assert_eq!(batch.num_rows(), 1); let blocks = batch[POSTING_COL] @@ -2358,8 +2400,10 @@ impl CompressedPostingList { .as_primitive::() .values() .to_vec(); - let codec = parse_shared_position_codec(batch.schema_ref().metadata()) - .expect("shared position stream codec metadata should be valid"); + let codec = shared_position_codec.unwrap_or_else(|| { + parse_shared_position_codec(batch.schema_ref().metadata()) + .expect("shared position stream codec metadata should be valid") + }); Some(CompressedPositionStorage::SharedStream( SharedPositionStream::new(codec, block_offsets, bytes), )) @@ -4975,6 +5019,93 @@ mod tests { ); } + #[tokio::test] + async fn test_prewarm_with_v2_positions_preserves_shared_stream_codec() { + let tmpdir = TempObjDir::default(); + let store = Arc::new(LanceIndexStore::new( + ObjectStore::local().into(), + tmpdir.clone(), + Arc::new(LanceCache::no_cache()), + )); + + let format_version = InvertedListFormatVersion::V2; + let posting_tail_codec = format_version.posting_tail_codec(); + let mut builder = InnerBuilder::new_with_format_version( + 0, + true, + TokenSetFormat::default(), + format_version, + ); + builder.tokens.add("body".to_owned()); + + let mut posting_list = + PostingListBuilder::new_with_posting_tail_codec(true, posting_tail_codec); + let expected = (0..(BLOCK_SIZE + 5) as u32) + .map(|doc_id| { + let positions = vec![doc_id % 3, doc_id % 3 + 2, doc_id % 3 + 5]; + posting_list.add(doc_id, PositionRecorder::Position(positions.clone().into())); + builder.docs.append(30_000 + doc_id as u64, 20 + doc_id % 7); + (doc_id, positions.len() as u32, positions) + }) + .collect::>(); + builder.posting_lists.push(posting_list); + builder.write(store.as_ref()).await.unwrap(); + + let metadata = HashMap::from([ + ( + "partitions".to_owned(), + serde_json::to_string(&vec![0_u64]).unwrap(), + ), + ( + "params".to_owned(), + serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(), + ), + ( + TOKEN_SET_FORMAT_KEY.to_owned(), + TokenSetFormat::default().to_string(), + ), + ( + POSTING_TAIL_CODEC_KEY.to_owned(), + posting_tail_codec.as_str().to_owned(), + ), + ( + POSITIONS_LAYOUT_KEY.to_owned(), + POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(), + ), + ( + POSITIONS_CODEC_KEY.to_owned(), + PositionStreamCodec::PackedDelta.as_str().to_owned(), + ), + ]); + let mut writer = store + .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty())) + .await + .unwrap(); + writer.finish_with_metadata(metadata).await.unwrap(); + + let cache = Arc::new(LanceCache::with_capacity(4096)); + let index = InvertedIndex::load(store, None, cache.as_ref()) + .await + .unwrap(); + index + .prewarm_with_options(&FtsPrewarmOptions::new().with_position(true)) + .await + .unwrap(); + + let actual = index.partitions[0] + .inverted_list + .posting_list(0, true, &NoOpMetricsCollector) + .await + .unwrap() + .iter() + .map(|(doc_id, freq, positions)| { + (doc_id as u32, freq, positions.unwrap().collect::>()) + }) + .collect::>(); + + assert_eq!(actual, expected); + } + #[test] fn test_block_max_scores_capacity_matches_block_count() { let mut docs = DocSet::default(); From 8ac0741302052ca51e0ba01827d89744f2a62fe2 Mon Sep 17 00:00:00 2001 From: Enwei Jiao Date: Sat, 11 Apr 2026 07:46:00 -0700 Subject: [PATCH 175/206] fix: include column_metadatas and column_infos in CachedFileMetadata::DeepSizeOf (#6480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fix `CachedFileMetadata::DeepSizeOf` to include `column_metadatas` and `column_infos` — the two largest fields that were previously omitted (marked TODO since initial implementation) - This caused the moka cache weigher to underestimate entry sizes by ~100x, preventing eviction and causing unbounded memory growth on random-access workloads ## Problem `LanceCache` uses moka with a weighted capacity of 1 GB (`DEFAULT_METADATA_CACHE_SIZE`). The weigher calls `DeepSizeOf` on `CachedFileMetadata`, but the implementation only counted `file_schema` and `file_buffers` — ignoring `column_metadatas` (protobuf `ColumnMetadata`) and `column_infos` (`Vec>` containing page encodings). Each cache entry's true size is hundreds of KB, but was reported as ~1 KB. Moka never reached the 1 GB limit, so entries accumulated indefinitely. ## Profiling Evidence Tested on a 221M-row dataset with random `ds.take()` (64 rows per call): | Metric | Before | After | |--------|--------|-------| | RSS growth (30 iters) | **+7,503 MB** | **+535 MB** | | Growth rate | 250 MB/iter (linear, no plateau) | 18 MB/iter (plateaus ~500 MB) | jemalloc heap profiling (debug build, 243K symbols) showed 99.9% of leaked memory in `LanceCache::get_or_insert_with_key` → `FileReader::meta_to_col_infos` and `prost::encoding::message::merge`. ## Approach Since protobuf-generated types (`pbfile::ColumnMetadata`, `pb::ColumnEncoding`, etc.) don't implement `DeepSizeOf`, we use `prost::Message::encoded_len() * 4` as an approximation for in-memory size. The 4x multiplier accounts for heap allocations in repeated/string/bytes fields that are larger in memory than on the wire. ## Test plan - [x] Added `test_deep_size_of_includes_column_metadata` for V2_0 and V2_1 file formats - [x] Verified fix reduces memory growth from 250 MB/iter to 18 MB/iter on a production dataset - [x] `cargo test -p lance-file` passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-file/src/reader.rs | 122 ++++++++++++++++++++++++++++++++-- 1 file changed, 115 insertions(+), 7 deletions(-) diff --git a/rust/lance-file/src/reader.rs b/rust/lance-file/src/reader.rs index 72734491d6b..eeafd54a2ee 100644 --- a/rust/lance-file/src/reader.rs +++ b/rust/lance-file/src/reader.rs @@ -115,14 +115,58 @@ impl CachedFileMetadata { } impl DeepSizeOf for CachedFileMetadata { - // TODO: include size for `column_metadatas` and `column_infos`. fn deep_size_of_children(&self, context: &mut Context) -> usize { - self.file_schema.deep_size_of_children(context) - + self - .file_buffers - .iter() - .map(|file_buffer| file_buffer.deep_size_of_children(context)) - .sum::() + let schema_size = self.file_schema.deep_size_of_children(context); + + let buffers_size: usize = self + .file_buffers + .iter() + .map(|fb| fb.deep_size_of_children(context)) + .sum(); + + // column_metadatas is Vec (protobuf generated, + // does not implement DeepSizeOf). We use prost::Message::encoded_len() + // as a proxy for in-memory size. The decoded representation is typically + // several times larger than the wire format due to heap-allocated + // repeated/string/bytes fields, so we apply a 4x multiplier. + let column_metadatas_size: usize = self + .column_metadatas + .iter() + .map(|cm| cm.encoded_len() * 4) + .sum::() + + std::mem::size_of_val(self.column_metadatas.as_slice()); + + // column_infos is Vec>. Each ColumnInfo contains + // page_infos (with protobuf PageEncoding), buffer offsets, and a + // column-level ColumnEncoding protobuf. + let column_infos_size: usize = self + .column_infos + .iter() + .map(|ci| { + let pages_size: usize = ci + .page_infos + .iter() + .map(|pi| { + let enc_size = match &pi.encoding { + lance_encoding::decoder::PageEncoding::Legacy(e) => e.encoded_len() * 4, + lance_encoding::decoder::PageEncoding::Structural(e) => { + e.encoded_len() * 4 + } + }; + enc_size + + std::mem::size_of_val(pi.buffer_offsets_and_sizes.as_ref()) + + std::mem::size_of::() * 2 // num_rows + priority + }) + .sum(); + pages_size + + std::mem::size_of_val(ci.buffer_offsets_and_sizes.as_ref()) + + ci.encoding.encoded_len() * 4 + + std::mem::size_of::() // index + + std::mem::size_of::() * 2 // Arc overhead + }) + .sum(); + + schema_size + buffers_size + column_metadatas_size + column_infos_size } } @@ -2289,4 +2333,68 @@ mod tests { let buf = file_reader.read_global_buffer(1).await.unwrap(); assert_eq!(buf, test_bytes); } + + #[rstest] + #[tokio::test] + async fn test_deep_size_of_includes_column_metadata( + #[values( + LanceFileVersion::V2_0, + LanceFileVersion::V2_1, + LanceFileVersion::V2_2, + LanceFileVersion::V2_3 + )] + version: LanceFileVersion, + ) { + // Regression test: CachedFileMetadata::deep_size_of must account for + // column_metadatas and column_infos, otherwise the moka cache weigher + // dramatically underestimates entry sizes and never evicts, causing + // unbounded memory growth on random-access workloads. + use deepsize::DeepSizeOf; + + let fs = FsFixture::default(); + let _written = create_some_file(&fs, version).await; + let cache = test_cache(); + let file_scheduler = fs + .scheduler + .open_file(&fs.tmp_path, &CachedFileSize::unknown()) + .await + .unwrap(); + let file_reader = FileReader::try_open( + file_scheduler, + None, + Arc::::default(), + &cache, + FileReaderOptions::default(), + ) + .await + .unwrap(); + + let metadata = file_reader.metadata(); + let deep_size = metadata.deep_size_of(); + + // The file has multiple columns (score, location, categories, binary, + // maybe large_bin). The deep_size_of must be substantially more than + // just the schema — it should include column_metadatas + column_infos. + // A naive implementation that ignores these fields reports < 1 KB; + // a correct one should report at least several KB for this test file. + assert!( + deep_size > 1024, + "deep_size_of ({deep_size}) is suspiciously small — \ + column_metadatas and column_infos may not be accounted for" + ); + + // Verify column_metadatas is non-empty (sanity check). + assert!( + !metadata.column_metadatas.is_empty(), + "Expected non-empty column_metadatas" + ); + + // Verify the size scales with the number of columns: a file with more + // columns should have a larger deep_size_of. + let num_columns = metadata.column_metadatas.len(); + assert!( + deep_size > num_columns * 50, + "deep_size_of ({deep_size}) should scale with column count ({num_columns})" + ); + } } From 7a65e88eb8baee246be08609f0140f995418b4b6 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Sat, 11 Apr 2026 15:30:54 -0700 Subject: [PATCH 176/206] fix: handle FlatBin quantization in optimize_vector_indices_v2 (#6488) ## Summary - Add missing `(SubIndexType::Flat, QuantizationType::FlatBin)` match arm in `optimize_vector_indices_v2` The v2 function handles all other sub-index/quantization combinations but misses the FlatBin case for binary vector IVF_FLAT indices, hitting the catch-all `unimplemented!` panic during incremental indexing (`optimize_indices`). The v1 function already handles this correctly. --- rust/lance/src/index/vector/ivf.rs | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/rust/lance/src/index/vector/ivf.rs b/rust/lance/src/index/vector/ivf.rs index 383ca87bc6e..a38cf3a57de 100644 --- a/rust/lance/src/index/vector/ivf.rs +++ b/rust/lance/src/index/vector/ivf.rs @@ -527,6 +527,26 @@ pub(crate) async fn optimize_vector_indices_v2( .await? } } + // IVF_FLAT (binary vectors) + (SubIndexType::Flat, QuantizationType::FlatBin) => { + IvfIndexBuilder::::new_incremental( + dataset.clone(), + vector_column.to_owned(), + index_dir, + distance_type, + shuffler, + (), + frag_reuse_index, + options.clone(), + )? + .with_ivf(ivf_model.clone()) + .with_quantizer(quantizer.try_into()?) + .with_existing_indices(existing_indices.clone()) + .shuffle_data(unindexed) + .await? + .build() + .await? + } // IVF_PQ (SubIndexType::Flat, QuantizationType::Product) => { IvfIndexBuilder::::new_incremental( @@ -4155,4 +4175,52 @@ mod tests { let stats = dataset.object_store().io_stats_incremental(); assert_io_eq!(stats, read_iops, 0, "second prewarm should not perform IO"); } + + #[tokio::test] + async fn test_optimize_ivf_flat_binary_vectors() { + let test_dir = TempStrDir::default(); + let test_uri = test_dir.as_str(); + + const BIN_DIM: usize = 16; + let schema = Arc::new(Schema::new(vec![Field::new( + "bin_vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::UInt8, true)), + BIN_DIM as i32, + ), + true, + )])); + + let arr = arrow_array::UInt8Array::from_iter_values((0..1000 * BIN_DIM).map(|i| i as u8)); + let fsl = FixedSizeListArray::try_new_from_values(arr, BIN_DIM as i32).unwrap(); + let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(fsl)]).unwrap(); + let batches = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema.clone()); + let mut dataset = Dataset::write(batches, test_uri, None).await.unwrap(); + + let params = VectorIndexParams::ivf_flat(2, MetricType::Hamming); + dataset + .create_index(&["bin_vector"], IndexType::Vector, None, ¶ms, false) + .await + .unwrap(); + + // Append more data so optimize_indices has unindexed fragments to merge + let arr2 = + arrow_array::UInt8Array::from_iter_values((0..500 * BIN_DIM).map(|i| (i + 7) as u8)); + let fsl2 = FixedSizeListArray::try_new_from_values(arr2, BIN_DIM as i32).unwrap(); + let batch2 = RecordBatch::try_new(schema.clone(), vec![Arc::new(fsl2)]).unwrap(); + let mut dataset = InsertBuilder::new(Arc::new(dataset)) + .with_params(&WriteParams { + mode: WriteMode::Append, + ..Default::default() + }) + .execute(vec![batch2]) + .await + .unwrap(); + + // This used to panic with "unsupported index type: FLAT, FLATBIN" + dataset.optimize_indices(&Default::default()).await.unwrap(); + + let indices = dataset.load_indices().await.unwrap(); + assert!(!indices.is_empty(), "should have at least one index"); + } } From 0a0d53b5ef4b0f65bb775ce63bdcabc79d1d14b8 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Sat, 11 Apr 2026 22:32:37 +0000 Subject: [PATCH 177/206] chore: release beta version 5.1.0-beta.3 --- .bumpversion.toml | 2 +- Cargo.lock | 228 +++++++++++++++++++------------------- Cargo.toml | 40 +++---- java/lance-jni/Cargo.lock | 208 +++++++++++++++++----------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 222 ++++++++++++++++++------------------- python/Cargo.toml | 2 +- 8 files changed, 353 insertions(+), 353 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index f94c9d628c0..bbfc55ab73f 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "5.1.0-beta.2" +current_version = "5.1.0-beta.3" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index d03bb577111..dfe4ac91599 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2096,9 +2096,9 @@ dependencies = [ [[package]] name = "datafusion" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c18ba387f9c05ac1f3be32a73f8f3cc6c1cfc43e5d4b7a8e5b0d3a5eb48dc7" +checksum = "7541353e77dc7262b71ca27be07d8393661737e3a73b5d1b1c6f7d814c64fa2a" dependencies = [ "arrow", "arrow-schema", @@ -2136,7 +2136,7 @@ dependencies = [ "object_store", "parking_lot", "parquet", - "rand 0.9.2", + "rand 0.9.3", "regex", "sqlparser", "tempfile", @@ -2147,9 +2147,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c75a4ce672b27fb8423810efb92a3600027717a1664d06a2c307eeeabcec694" +checksum = "9997731f90fa5398ef831ad0e69600f92c861b79c0d38bd1a29b6f0e3a0ce4c8" dependencies = [ "arrow", "async-trait", @@ -2172,9 +2172,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8b9a3795ffb46bf4957a34c67d89a67558b311ae455c8d4295ff2115eeea50" +checksum = "2b30a3dd50dec860c9559275c8d97d9de602e611237a6ecfbda0b3b63b872352" dependencies = [ "arrow", "async-trait", @@ -2195,9 +2195,9 @@ dependencies = [ [[package]] name = "datafusion-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "205dc1e20441973f470e6b7ef87626a3b9187970e5106058fef1b713047f770c" +checksum = "d551054acec0398ca604512310b77ce05c46f66e54b54d48200a686e385cca4e" dependencies = [ "ahash", "arrow", @@ -2218,9 +2218,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf5880c02ff6f5f11fb5bc19211789fb32fd3c53d79b7d6cb2b12e401312ba0" +checksum = "567d40e285f5b79f8737b576605721cd6c1133b5d2b00bdbd5d9838d90d0812f" dependencies = [ "futures", "log", @@ -2229,9 +2229,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc614d6e709450e29b7b032a42c1bdb705f166a6b2edef7bed7c7897eb905499" +checksum = "27d2668f51b3b30befae2207472569e37807fdedd1d14da58acc6f8ca6257eae" dependencies = [ "arrow", "async-trait", @@ -2251,16 +2251,16 @@ dependencies = [ "itertools 0.14.0", "log", "object_store", - "rand 0.9.2", + "rand 0.9.3", "tokio", "url", ] [[package]] name = "datafusion-datasource-arrow" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e497d5fc48dac7ce86f6b4fb09a3a494385774af301ff20ec91aebfae9b05b4" +checksum = "e02e1b3e3a8ec55f1f62de4252b0407c8567363d056078769a197e24fc834a0f" dependencies = [ "arrow", "arrow-ipc", @@ -2282,9 +2282,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dfc250cad940d0327ca2e9109dc98830892d17a3d6b2ca11d68570e872cf379" +checksum = "b559d7bf87d4f900f847baba8509634f838d9718695389e903604cdcccdb01f3" dependencies = [ "arrow", "async-trait", @@ -2305,9 +2305,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91e9677ed62833b0e8129dec0d1a8f3c9bb7590bd6dd714a43e4c3b663e4aa0" +checksum = "250e2d7591ba8b638f063854650faa40bca4e8bd4059b2ece8836f6388d02db4" dependencies = [ "arrow", "async-trait", @@ -2327,9 +2327,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-parquet" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23798383465e0c569bd442d1453b50691261f8ad6511d840c48457b3bf51ae21" +checksum = "0b043149f2c3557ca94abc58de40f68a8d412ff53365c06126ed234f8596399d" dependencies = [ "arrow", "async-trait", @@ -2357,15 +2357,15 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e13e5fe3447baa0584b61ee8644086e007e1ef6e58f4be48bc8a72417854729" +checksum = "b9496cb0db222dbb9a3735760ceca7fc56f35e1d5502c38d0caa77a81e9c1f6a" [[package]] name = "datafusion-execution" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48a6cc03e34899a54546b229235f7b192634c8e832f78a267f0989b18216c56d" +checksum = "dc45d23c516ed8d3637751e44e09e21b45b3f58b473c802dddd1f1ad4fe435ff" dependencies = [ "arrow", "async-trait", @@ -2377,16 +2377,16 @@ dependencies = [ "log", "object_store", "parking_lot", - "rand 0.9.2", + "rand 0.9.3", "tempfile", "url", ] [[package]] name = "datafusion-expr" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3315d87eca7a7df58e52a1fb43b4c4171b545fd30ffc3102945c162a9f6ddb" +checksum = "63dd30526d2db4fda6440806a41e4676334a94bc0596cc9cc2a0efed20ef2c44" dependencies = [ "arrow", "async-trait", @@ -2406,9 +2406,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98c6d83feae0753799f933a2c47dfd15980c6947960cb95ed60f5c1f885548b3" +checksum = "1b486b5f6255d40976b88bb83813b0d035a8333e0ec39864824e78068cf42fa6" dependencies = [ "arrow", "datafusion-common", @@ -2419,9 +2419,9 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b82962015cc3db4d7662459c9f7fcda0591b5edacb8af1cf3bc3031f274800" +checksum = "07356c94118d881130dd0ffbff127540407d969c8978736e324edcd6c41cd48f" dependencies = [ "arrow", "arrow-buffer", @@ -2441,7 +2441,7 @@ dependencies = [ "log", "md-5", "num-traits", - "rand 0.9.2", + "rand 0.9.3", "regex", "sha2", "unicode-segmentation", @@ -2450,9 +2450,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e42c227d9e55a6c8041785d4a8a117e4de531033d480aae10984247ac62e27e" +checksum = "b644f9cf696df9233ce6958b9807666d78563b56f923267474dd6c07795f1f8f" dependencies = [ "ahash", "arrow", @@ -2471,9 +2471,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cead3cfed825b0b688700f4338d281cd7857e4907775a5b9554c083edd5f3f95" +checksum = "c1de2deaaabe8923ce9ea9f29c47bbb4ee14f67ea2fe1ab5398d9bbebcf86e56" dependencies = [ "ahash", "arrow", @@ -2484,9 +2484,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ea99612970aebab8cf864d02eb3d296bbab7f4881e1023d282b57fe431b201" +checksum = "552f8d92e4331ee91d23c02d12bb6acf32cbfd5215117e01c0fb63cd4b15af1a" dependencies = [ "arrow", "arrow-ord", @@ -2507,9 +2507,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83dbf3ab8b9af6f209b068825a7adbd3b88bf276f2a1ec14ba09567b97f5674" +checksum = "970fd0cdd3df8802b9a9975ff600998289ba9d46682a4f7285cba4820c9ada78" dependencies = [ "arrow", "async-trait", @@ -2523,9 +2523,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732edabe07496e2fc5a1e57a284d7a36edcea445a2821119770a0dea624b472c" +checksum = "40b4c21a7c8a986a1866c0a87ab756d0bbf7b5f41f306009fa2d9af79c52ed31" dependencies = [ "arrow", "datafusion-common", @@ -2541,9 +2541,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c6e30e09700799bd52adce8c377ab03dda96e73a623e4803a31ad94fe7ce14" +checksum = "b1210ad73b8b3211aeaf4a42bef9bd7a2b7fce3ec119a478831f18c6ff7f7b93" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -2551,9 +2551,9 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402f2a8ed70fb99a18f71580a1fe338604222a3d32ddeac6e72c5b34feea2d4d" +checksum = "aaa566a963013a38681ad82a727a654bc7feb19632426aea8c3412d415d200c5" dependencies = [ "datafusion-doc", "quote", @@ -2562,9 +2562,9 @@ dependencies = [ [[package]] name = "datafusion-optimizer" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99f32edb8ba12f08138f86c09b80fae3d4a320551262fa06b91d8a8cb3065a5b" +checksum = "ff9aa82b240252a88dee118372f9b9757c545ab9e53c0736bebab2e7da0ef1f2" dependencies = [ "arrow", "chrono", @@ -2581,9 +2581,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "987c5e29e96186589301b42e25aa7d11bbe319a73eb02ef8d755edc55b5b89fc" +checksum = "7d48022b8af9988c1d852644f9e8b5584c490659769a550c5e8d39457a1da0a5" dependencies = [ "ahash", "arrow", @@ -2604,9 +2604,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-adapter" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de89d0afa08b6686697bd8a6bac4ba2cd44c7003356e1bce6114d5a93f94b5c" +checksum = "ae7a8abc0b4fe624000972a9b145b30b7f1b680bffaa950ea53f78d9b21c27c3" dependencies = [ "arrow", "datafusion-common", @@ -2619,9 +2619,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602d1970c0fe87f1c3a36665d131fbfe1c4379d35f8fc5ec43a362229ad2954d" +checksum = "147253ca3e6b9d59c162de64c02800973018660e13340dd1886dd038d17ac429" dependencies = [ "ahash", "arrow", @@ -2636,9 +2636,9 @@ dependencies = [ [[package]] name = "datafusion-physical-optimizer" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b24d704b6385ebe27c756a12e5ba15684576d3b47aeca79cc9fb09480236dc32" +checksum = "689156bb2282107b6239db8d7ef44b4dab10a9b33d3491a0c74acac5e4fedd72" dependencies = [ "arrow", "datafusion-common", @@ -2654,9 +2654,9 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c21d94141ea5043e98793f170798e9c1887095813b8291c5260599341e383a38" +checksum = "68253dc0ee5330aa558b2549c9b0da5af9fc17d753ae73022939014ad616fc28" dependencies = [ "ahash", "arrow", @@ -2685,9 +2685,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a68cce43d18c0dfac95cacd74e70565f7e2fb12b9ed41e2d312f0fa837626b1" +checksum = "0fcad240a54d0b1d3e8f668398900260a53122d522b2102ab57218590decacd6" dependencies = [ "arrow", "datafusion-common", @@ -2702,9 +2702,9 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b4e1c40a0b1896aed4a4504145c2eb7fa9b9da13c2d04b40a4767a09f076199" +checksum = "f58e83a68bb67007a8fcbf005c44cefe441270c7ee7f6dee10c0e0109b556f6d" dependencies = [ "async-trait", "datafusion-common", @@ -2716,9 +2716,9 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f1891e5b106d1d73c7fe403bd8a265d19c3977edc17f60808daf26c2fe65ffb" +checksum = "be53e9eb55db0fbb8980bb6d87f2435b0524acf4c718ed54a57cabbb299b2ab3" dependencies = [ "arrow", "bigdecimal", @@ -2733,9 +2733,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2379388ecab67079eeb1185c953fb9c5ed4b283fa3cb81417538378a30545957" +checksum = "7dadf18107366b3470e47bdde8eeb9294b36444e451830d8e5fc8dc7b4ad9c58" dependencies = [ "async-recursion", "async-trait", @@ -3348,11 +3348,11 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", "lance-datagen", - "rand 0.9.2", + "rand 0.9.3", "rand_xoshiro", "test-log", "tokio", @@ -3853,7 +3853,7 @@ dependencies = [ "log", "native-tls", "num_cpus", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "serde", "serde_json", @@ -4554,7 +4554,7 @@ dependencies = [ "nom 8.0.0", "num-traits", "ordered-float 5.3.0", - "rand 0.9.2", + "rand 0.9.3", "serde", "serde_json", "zmij", @@ -4586,7 +4586,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "all_asserts", "approx", @@ -4659,7 +4659,7 @@ dependencies = [ "pretty_assertions", "prost", "prost-types", - "rand 0.9.2", + "rand 0.9.3", "rand_distr 0.5.1", "roaring", "rstest", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -4699,7 +4699,7 @@ dependencies = [ "half", "jsonb", "num-traits", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -4733,7 +4733,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrayref", "paste", @@ -4742,7 +4742,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -4767,7 +4767,7 @@ dependencies = [ "pin-project", "proptest", "prost", - "rand 0.9.2", + "rand 0.9.3", "roaring", "rstest", "serde_json", @@ -4782,7 +4782,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4817,7 +4817,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4829,7 +4829,7 @@ dependencies = [ "half", "hex", "pprof", - "rand 0.9.2", + "rand 0.9.3", "rand_distr 0.5.1", "rand_xoshiro", "random_word", @@ -4837,7 +4837,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4870,7 +4870,7 @@ dependencies = [ "prost-build", "prost-types", "protobuf-src", - "rand 0.9.2", + "rand 0.9.3", "rand_xoshiro", "rstest", "serial_test", @@ -4885,7 +4885,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "all_asserts", "arrow", @@ -4903,7 +4903,7 @@ dependencies = [ "lance-linalg", "object_store", "parquet", - "rand 0.9.2", + "rand 0.9.3", "tempfile", "tokenizers", "tokio", @@ -4911,7 +4911,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4944,7 +4944,7 @@ dependencies = [ "prost-build", "prost-types", "protobuf-src", - "rand 0.9.2", + "rand 0.9.3", "rstest", "snafu", "test-log", @@ -4954,7 +4954,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "datafusion", "geo-traits", @@ -4968,7 +4968,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "approx", "arrow", @@ -5028,7 +5028,7 @@ dependencies = [ "prost-build", "prost-types", "protobuf-src", - "rand 0.9.2", + "rand 0.9.3", "rand_distr 0.5.1", "rangemap", "rayon", @@ -5049,7 +5049,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -5086,7 +5086,7 @@ dependencies = [ "pin-project", "pprof", "prost", - "rand 0.9.2", + "rand 0.9.3", "rstest", "serde", "snafu", @@ -5100,7 +5100,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "approx", "arrow-array", @@ -5116,12 +5116,12 @@ dependencies = [ "num-traits", "pprof", "proptest", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] name = "lance-namespace" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "async-trait", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -5154,7 +5154,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-ipc", @@ -5182,7 +5182,7 @@ dependencies = [ "lance-table", "log", "object_store", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "rstest", "serde", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -5243,7 +5243,7 @@ dependencies = [ "prost-build", "prost-types", "protobuf-src", - "rand 0.9.2", + "rand 0.9.3", "rangemap", "roaring", "rstest", @@ -5259,7 +5259,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "proc-macro2", "quote", @@ -5268,18 +5268,18 @@ dependencies = [ [[package]] name = "lance-testing" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", "arrow-schema", "lance-arrow", "num-traits", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] name = "lance-tools" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "clap", "lance-core", @@ -5500,7 +5500,7 @@ dependencies = [ "md5", "memmap2", "once_cell", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "serde", "tar", @@ -6149,7 +6149,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml 0.38.4", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "ring", "rustls-pemfile", @@ -6874,7 +6874,7 @@ dependencies = [ "bit-vec", "bitflags 2.11.0", "num-traits", - "rand 0.9.2", + "rand 0.9.3", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -7017,7 +7017,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.3", "ring", "rustc-hash", "rustls", @@ -7096,9 +7096,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -7178,7 +7178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -7217,7 +7217,7 @@ dependencies = [ "ahash", "brotli", "paste", - "rand 0.9.2", + "rand 0.9.3", "unicase", ] @@ -9058,7 +9058,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" dependencies = [ - "rand 0.9.2", + "rand 0.9.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2cc6e020bf6..e89bd071388 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ resolver = "3" [workspace.package] -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -53,24 +53,24 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=5.1.0-beta.2", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=5.1.0-beta.2", path = "./rust/lance-arrow" } -lance-core = { version = "=5.1.0-beta.2", path = "./rust/lance-core" } -lance-datafusion = { version = "=5.1.0-beta.2", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=5.1.0-beta.2", path = "./rust/lance-datagen" } -lance-encoding = { version = "=5.1.0-beta.2", path = "./rust/lance-encoding" } -lance-file = { version = "=5.1.0-beta.2", path = "./rust/lance-file" } -lance-geo = { version = "=5.1.0-beta.2", path = "./rust/lance-geo" } -lance-index = { version = "=5.1.0-beta.2", path = "./rust/lance-index" } -lance-io = { version = "=5.1.0-beta.2", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=5.1.0-beta.2", path = "./rust/lance-linalg" } -lance-namespace = { version = "=5.1.0-beta.2", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=5.1.0-beta.2", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=5.1.0-beta.2", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=5.1.0-beta.3", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=5.1.0-beta.3", path = "./rust/lance-arrow" } +lance-core = { version = "=5.1.0-beta.3", path = "./rust/lance-core" } +lance-datafusion = { version = "=5.1.0-beta.3", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=5.1.0-beta.3", path = "./rust/lance-datagen" } +lance-encoding = { version = "=5.1.0-beta.3", path = "./rust/lance-encoding" } +lance-file = { version = "=5.1.0-beta.3", path = "./rust/lance-file" } +lance-geo = { version = "=5.1.0-beta.3", path = "./rust/lance-geo" } +lance-index = { version = "=5.1.0-beta.3", path = "./rust/lance-index" } +lance-io = { version = "=5.1.0-beta.3", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=5.1.0-beta.3", path = "./rust/lance-linalg" } +lance-namespace = { version = "=5.1.0-beta.3", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=5.1.0-beta.3", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=5.1.0-beta.3", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-table = { version = "=5.1.0-beta.2", path = "./rust/lance-table" } -lance-test-macros = { version = "=5.1.0-beta.2", path = "./rust/lance-test-macros" } -lance-testing = { version = "=5.1.0-beta.2", path = "./rust/lance-testing" } +lance-table = { version = "=5.1.0-beta.3", path = "./rust/lance-table" } +lance-test-macros = { version = "=5.1.0-beta.3", path = "./rust/lance-test-macros" } +lance-testing = { version = "=5.1.0-beta.3", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -97,7 +97,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=5.1.0-beta.2", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=5.1.0-beta.3", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -138,7 +138,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=5.1.0-beta.2", path = "./rust/compression/fsst" } +fsst = { version = "=5.1.0-beta.3", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 0ab2c52fcd0..325bb7d6ca0 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -1438,9 +1438,9 @@ dependencies = [ [[package]] name = "datafusion" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c18ba387f9c05ac1f3be32a73f8f3cc6c1cfc43e5d4b7a8e5b0d3a5eb48dc7" +checksum = "7541353e77dc7262b71ca27be07d8393661737e3a73b5d1b1c6f7d814c64fa2a" dependencies = [ "arrow", "arrow-schema", @@ -1478,7 +1478,7 @@ dependencies = [ "object_store", "parking_lot", "parquet", - "rand 0.9.2", + "rand 0.9.3", "regex", "sqlparser", "tempfile", @@ -1489,9 +1489,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c75a4ce672b27fb8423810efb92a3600027717a1664d06a2c307eeeabcec694" +checksum = "9997731f90fa5398ef831ad0e69600f92c861b79c0d38bd1a29b6f0e3a0ce4c8" dependencies = [ "arrow", "async-trait", @@ -1514,9 +1514,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8b9a3795ffb46bf4957a34c67d89a67558b311ae455c8d4295ff2115eeea50" +checksum = "2b30a3dd50dec860c9559275c8d97d9de602e611237a6ecfbda0b3b63b872352" dependencies = [ "arrow", "async-trait", @@ -1537,9 +1537,9 @@ dependencies = [ [[package]] name = "datafusion-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "205dc1e20441973f470e6b7ef87626a3b9187970e5106058fef1b713047f770c" +checksum = "d551054acec0398ca604512310b77ce05c46f66e54b54d48200a686e385cca4e" dependencies = [ "ahash", "arrow", @@ -1560,9 +1560,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf5880c02ff6f5f11fb5bc19211789fb32fd3c53d79b7d6cb2b12e401312ba0" +checksum = "567d40e285f5b79f8737b576605721cd6c1133b5d2b00bdbd5d9838d90d0812f" dependencies = [ "futures", "log", @@ -1571,9 +1571,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc614d6e709450e29b7b032a42c1bdb705f166a6b2edef7bed7c7897eb905499" +checksum = "27d2668f51b3b30befae2207472569e37807fdedd1d14da58acc6f8ca6257eae" dependencies = [ "arrow", "async-trait", @@ -1593,16 +1593,16 @@ dependencies = [ "itertools 0.14.0", "log", "object_store", - "rand 0.9.2", + "rand 0.9.3", "tokio", "url", ] [[package]] name = "datafusion-datasource-arrow" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e497d5fc48dac7ce86f6b4fb09a3a494385774af301ff20ec91aebfae9b05b4" +checksum = "e02e1b3e3a8ec55f1f62de4252b0407c8567363d056078769a197e24fc834a0f" dependencies = [ "arrow", "arrow-ipc", @@ -1624,9 +1624,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dfc250cad940d0327ca2e9109dc98830892d17a3d6b2ca11d68570e872cf379" +checksum = "b559d7bf87d4f900f847baba8509634f838d9718695389e903604cdcccdb01f3" dependencies = [ "arrow", "async-trait", @@ -1647,9 +1647,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91e9677ed62833b0e8129dec0d1a8f3c9bb7590bd6dd714a43e4c3b663e4aa0" +checksum = "250e2d7591ba8b638f063854650faa40bca4e8bd4059b2ece8836f6388d02db4" dependencies = [ "arrow", "async-trait", @@ -1669,9 +1669,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-parquet" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23798383465e0c569bd442d1453b50691261f8ad6511d840c48457b3bf51ae21" +checksum = "0b043149f2c3557ca94abc58de40f68a8d412ff53365c06126ed234f8596399d" dependencies = [ "arrow", "async-trait", @@ -1699,15 +1699,15 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e13e5fe3447baa0584b61ee8644086e007e1ef6e58f4be48bc8a72417854729" +checksum = "b9496cb0db222dbb9a3735760ceca7fc56f35e1d5502c38d0caa77a81e9c1f6a" [[package]] name = "datafusion-execution" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48a6cc03e34899a54546b229235f7b192634c8e832f78a267f0989b18216c56d" +checksum = "dc45d23c516ed8d3637751e44e09e21b45b3f58b473c802dddd1f1ad4fe435ff" dependencies = [ "arrow", "async-trait", @@ -1719,16 +1719,16 @@ dependencies = [ "log", "object_store", "parking_lot", - "rand 0.9.2", + "rand 0.9.3", "tempfile", "url", ] [[package]] name = "datafusion-expr" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3315d87eca7a7df58e52a1fb43b4c4171b545fd30ffc3102945c162a9f6ddb" +checksum = "63dd30526d2db4fda6440806a41e4676334a94bc0596cc9cc2a0efed20ef2c44" dependencies = [ "arrow", "async-trait", @@ -1748,9 +1748,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98c6d83feae0753799f933a2c47dfd15980c6947960cb95ed60f5c1f885548b3" +checksum = "1b486b5f6255d40976b88bb83813b0d035a8333e0ec39864824e78068cf42fa6" dependencies = [ "arrow", "datafusion-common", @@ -1761,9 +1761,9 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b82962015cc3db4d7662459c9f7fcda0591b5edacb8af1cf3bc3031f274800" +checksum = "07356c94118d881130dd0ffbff127540407d969c8978736e324edcd6c41cd48f" dependencies = [ "arrow", "arrow-buffer", @@ -1783,7 +1783,7 @@ dependencies = [ "log", "md-5", "num-traits", - "rand 0.9.2", + "rand 0.9.3", "regex", "sha2", "unicode-segmentation", @@ -1792,9 +1792,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e42c227d9e55a6c8041785d4a8a117e4de531033d480aae10984247ac62e27e" +checksum = "b644f9cf696df9233ce6958b9807666d78563b56f923267474dd6c07795f1f8f" dependencies = [ "ahash", "arrow", @@ -1813,9 +1813,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cead3cfed825b0b688700f4338d281cd7857e4907775a5b9554c083edd5f3f95" +checksum = "c1de2deaaabe8923ce9ea9f29c47bbb4ee14f67ea2fe1ab5398d9bbebcf86e56" dependencies = [ "ahash", "arrow", @@ -1826,9 +1826,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ea99612970aebab8cf864d02eb3d296bbab7f4881e1023d282b57fe431b201" +checksum = "552f8d92e4331ee91d23c02d12bb6acf32cbfd5215117e01c0fb63cd4b15af1a" dependencies = [ "arrow", "arrow-ord", @@ -1849,9 +1849,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83dbf3ab8b9af6f209b068825a7adbd3b88bf276f2a1ec14ba09567b97f5674" +checksum = "970fd0cdd3df8802b9a9975ff600998289ba9d46682a4f7285cba4820c9ada78" dependencies = [ "arrow", "async-trait", @@ -1865,9 +1865,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732edabe07496e2fc5a1e57a284d7a36edcea445a2821119770a0dea624b472c" +checksum = "40b4c21a7c8a986a1866c0a87ab756d0bbf7b5f41f306009fa2d9af79c52ed31" dependencies = [ "arrow", "datafusion-common", @@ -1883,9 +1883,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c6e30e09700799bd52adce8c377ab03dda96e73a623e4803a31ad94fe7ce14" +checksum = "b1210ad73b8b3211aeaf4a42bef9bd7a2b7fce3ec119a478831f18c6ff7f7b93" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -1893,9 +1893,9 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402f2a8ed70fb99a18f71580a1fe338604222a3d32ddeac6e72c5b34feea2d4d" +checksum = "aaa566a963013a38681ad82a727a654bc7feb19632426aea8c3412d415d200c5" dependencies = [ "datafusion-doc", "quote", @@ -1904,9 +1904,9 @@ dependencies = [ [[package]] name = "datafusion-optimizer" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99f32edb8ba12f08138f86c09b80fae3d4a320551262fa06b91d8a8cb3065a5b" +checksum = "ff9aa82b240252a88dee118372f9b9757c545ab9e53c0736bebab2e7da0ef1f2" dependencies = [ "arrow", "chrono", @@ -1923,9 +1923,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "987c5e29e96186589301b42e25aa7d11bbe319a73eb02ef8d755edc55b5b89fc" +checksum = "7d48022b8af9988c1d852644f9e8b5584c490659769a550c5e8d39457a1da0a5" dependencies = [ "ahash", "arrow", @@ -1946,9 +1946,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-adapter" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de89d0afa08b6686697bd8a6bac4ba2cd44c7003356e1bce6114d5a93f94b5c" +checksum = "ae7a8abc0b4fe624000972a9b145b30b7f1b680bffaa950ea53f78d9b21c27c3" dependencies = [ "arrow", "datafusion-common", @@ -1961,9 +1961,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602d1970c0fe87f1c3a36665d131fbfe1c4379d35f8fc5ec43a362229ad2954d" +checksum = "147253ca3e6b9d59c162de64c02800973018660e13340dd1886dd038d17ac429" dependencies = [ "ahash", "arrow", @@ -1978,9 +1978,9 @@ dependencies = [ [[package]] name = "datafusion-physical-optimizer" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b24d704b6385ebe27c756a12e5ba15684576d3b47aeca79cc9fb09480236dc32" +checksum = "689156bb2282107b6239db8d7ef44b4dab10a9b33d3491a0c74acac5e4fedd72" dependencies = [ "arrow", "datafusion-common", @@ -1996,9 +1996,9 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c21d94141ea5043e98793f170798e9c1887095813b8291c5260599341e383a38" +checksum = "68253dc0ee5330aa558b2549c9b0da5af9fc17d753ae73022939014ad616fc28" dependencies = [ "ahash", "arrow", @@ -2027,9 +2027,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a68cce43d18c0dfac95cacd74e70565f7e2fb12b9ed41e2d312f0fa837626b1" +checksum = "0fcad240a54d0b1d3e8f668398900260a53122d522b2102ab57218590decacd6" dependencies = [ "arrow", "datafusion-common", @@ -2044,9 +2044,9 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b4e1c40a0b1896aed4a4504145c2eb7fa9b9da13c2d04b40a4767a09f076199" +checksum = "f58e83a68bb67007a8fcbf005c44cefe441270c7ee7f6dee10c0e0109b556f6d" dependencies = [ "async-trait", "datafusion-common", @@ -2058,9 +2058,9 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f1891e5b106d1d73c7fe403bd8a265d19c3977edc17f60808daf26c2fe65ffb" +checksum = "be53e9eb55db0fbb8980bb6d87f2435b0524acf4c718ed54a57cabbb299b2ab3" dependencies = [ "arrow", "bigdecimal", @@ -2075,9 +2075,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2379388ecab67079eeb1185c953fb9c5ed4b283fa3cb81417538378a30545957" +checksum = "7dadf18107366b3470e47bdde8eeb9294b36444e451830d8e5fc8dc7b4ad9c58" dependencies = [ "async-recursion", "async-trait", @@ -2398,10 +2398,10 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -3380,7 +3380,7 @@ dependencies = [ "nom 8.0.0", "num-traits", "ordered-float 5.3.0", - "rand 0.9.2", + "rand 0.9.3", "serde", "serde_json", "zmij", @@ -3403,7 +3403,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3453,7 +3453,7 @@ dependencies = [ "pin-project", "prost", "prost-types", - "rand 0.9.2", + "rand 0.9.3", "roaring", "semver", "serde", @@ -3470,7 +3470,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3486,12 +3486,12 @@ dependencies = [ "half", "jsonb", "num-traits", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrayref", "paste", @@ -3500,7 +3500,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3523,7 +3523,7 @@ dependencies = [ "object_store", "pin-project", "prost", - "rand 0.9.2", + "rand 0.9.3", "roaring", "serde_json", "snafu", @@ -3537,7 +3537,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -3571,7 +3571,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -3581,7 +3581,7 @@ dependencies = [ "futures", "half", "hex", - "rand 0.9.2", + "rand 0.9.3", "rand_distr 0.5.1", "rand_xoshiro", "random_word", @@ -3589,7 +3589,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -3615,7 +3615,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.3", "snafu", "strum", "tokio", @@ -3626,7 +3626,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -3658,7 +3658,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "datafusion", "geo-traits", @@ -3672,7 +3672,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3721,7 +3721,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.3", "rand_distr 0.5.1", "rangemap", "rayon", @@ -3740,7 +3740,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3773,7 +3773,7 @@ dependencies = [ "path_abs", "pin-project", "prost", - "rand 0.9.2", + "rand 0.9.3", "serde", "snafu", "tempfile", @@ -3784,7 +3784,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -3820,7 +3820,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3831,12 +3831,12 @@ dependencies = [ "lance-arrow", "lance-core", "num-traits", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] name = "lance-namespace" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "async-trait", @@ -3849,7 +3849,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-ipc", @@ -3868,7 +3868,7 @@ dependencies = [ "lance-table", "log", "object_store", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "serde", "serde_json", @@ -3894,7 +3894,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -3916,7 +3916,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.3", "rangemap", "roaring", "semver", @@ -4434,7 +4434,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml 0.38.4", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "ring", "rustls-pemfile", @@ -5004,7 +5004,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.3", "ring", "rustc-hash", "rustls", @@ -5070,9 +5070,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -5133,7 +5133,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -5154,7 +5154,7 @@ dependencies = [ "ahash", "brotli", "paste", - "rand 0.9.2", + "rand 0.9.3", "unicase", ] @@ -6604,7 +6604,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" dependencies = [ - "rand 0.9.2", + "rand 0.9.3", ] [[package]] diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 4b7bfb739b5..54213a2ca8c 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index ce3b011eea8..e18418a8d88 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 5.1.0-beta.2 + 5.1.0-beta.3 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index cf91d80d5d0..189bc5c25f5 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -1668,9 +1668,9 @@ dependencies = [ [[package]] name = "datafusion" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c18ba387f9c05ac1f3be32a73f8f3cc6c1cfc43e5d4b7a8e5b0d3a5eb48dc7" +checksum = "7541353e77dc7262b71ca27be07d8393661737e3a73b5d1b1c6f7d814c64fa2a" dependencies = [ "arrow", "arrow-schema", @@ -1711,7 +1711,7 @@ dependencies = [ "object_store", "parking_lot", "parquet", - "rand 0.9.2", + "rand 0.9.3", "regex", "sqlparser", "tempfile", @@ -1723,9 +1723,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c75a4ce672b27fb8423810efb92a3600027717a1664d06a2c307eeeabcec694" +checksum = "9997731f90fa5398ef831ad0e69600f92c861b79c0d38bd1a29b6f0e3a0ce4c8" dependencies = [ "arrow", "async-trait", @@ -1748,9 +1748,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8b9a3795ffb46bf4957a34c67d89a67558b311ae455c8d4295ff2115eeea50" +checksum = "2b30a3dd50dec860c9559275c8d97d9de602e611237a6ecfbda0b3b63b872352" dependencies = [ "arrow", "async-trait", @@ -1771,9 +1771,9 @@ dependencies = [ [[package]] name = "datafusion-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "205dc1e20441973f470e6b7ef87626a3b9187970e5106058fef1b713047f770c" +checksum = "d551054acec0398ca604512310b77ce05c46f66e54b54d48200a686e385cca4e" dependencies = [ "ahash", "arrow", @@ -1795,9 +1795,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf5880c02ff6f5f11fb5bc19211789fb32fd3c53d79b7d6cb2b12e401312ba0" +checksum = "567d40e285f5b79f8737b576605721cd6c1133b5d2b00bdbd5d9838d90d0812f" dependencies = [ "futures", "log", @@ -1806,9 +1806,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc614d6e709450e29b7b032a42c1bdb705f166a6b2edef7bed7c7897eb905499" +checksum = "27d2668f51b3b30befae2207472569e37807fdedd1d14da58acc6f8ca6257eae" dependencies = [ "arrow", "async-compression", @@ -1832,7 +1832,7 @@ dependencies = [ "liblzma", "log", "object_store", - "rand 0.9.2", + "rand 0.9.3", "tokio", "tokio-util", "url", @@ -1841,9 +1841,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-arrow" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e497d5fc48dac7ce86f6b4fb09a3a494385774af301ff20ec91aebfae9b05b4" +checksum = "e02e1b3e3a8ec55f1f62de4252b0407c8567363d056078769a197e24fc834a0f" dependencies = [ "arrow", "arrow-ipc", @@ -1865,9 +1865,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dfc250cad940d0327ca2e9109dc98830892d17a3d6b2ca11d68570e872cf379" +checksum = "b559d7bf87d4f900f847baba8509634f838d9718695389e903604cdcccdb01f3" dependencies = [ "arrow", "async-trait", @@ -1888,9 +1888,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91e9677ed62833b0e8129dec0d1a8f3c9bb7590bd6dd714a43e4c3b663e4aa0" +checksum = "250e2d7591ba8b638f063854650faa40bca4e8bd4059b2ece8836f6388d02db4" dependencies = [ "arrow", "async-trait", @@ -1910,9 +1910,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-parquet" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23798383465e0c569bd442d1453b50691261f8ad6511d840c48457b3bf51ae21" +checksum = "0b043149f2c3557ca94abc58de40f68a8d412ff53365c06126ed234f8596399d" dependencies = [ "arrow", "async-trait", @@ -1940,15 +1940,15 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e13e5fe3447baa0584b61ee8644086e007e1ef6e58f4be48bc8a72417854729" +checksum = "b9496cb0db222dbb9a3735760ceca7fc56f35e1d5502c38d0caa77a81e9c1f6a" [[package]] name = "datafusion-execution" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48a6cc03e34899a54546b229235f7b192634c8e832f78a267f0989b18216c56d" +checksum = "dc45d23c516ed8d3637751e44e09e21b45b3f58b473c802dddd1f1ad4fe435ff" dependencies = [ "arrow", "async-trait", @@ -1960,16 +1960,16 @@ dependencies = [ "log", "object_store", "parking_lot", - "rand 0.9.2", + "rand 0.9.3", "tempfile", "url", ] [[package]] name = "datafusion-expr" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3315d87eca7a7df58e52a1fb43b4c4171b545fd30ffc3102945c162a9f6ddb" +checksum = "63dd30526d2db4fda6440806a41e4676334a94bc0596cc9cc2a0efed20ef2c44" dependencies = [ "arrow", "async-trait", @@ -1990,9 +1990,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98c6d83feae0753799f933a2c47dfd15980c6947960cb95ed60f5c1f885548b3" +checksum = "1b486b5f6255d40976b88bb83813b0d035a8333e0ec39864824e78068cf42fa6" dependencies = [ "arrow", "datafusion-common", @@ -2003,9 +2003,9 @@ dependencies = [ [[package]] name = "datafusion-ffi" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae9f6e64d9d7fc21e47dbe6d0494dc8000dce57934d991ee1518da1648c712ca" +checksum = "26b04181cffefd632e57acfc233ed239626863682dd8bb30ab366293f441bba8" dependencies = [ "abi_stable", "arrow", @@ -2033,9 +2033,9 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b82962015cc3db4d7662459c9f7fcda0591b5edacb8af1cf3bc3031f274800" +checksum = "07356c94118d881130dd0ffbff127540407d969c8978736e324edcd6c41cd48f" dependencies = [ "arrow", "arrow-buffer", @@ -2055,7 +2055,7 @@ dependencies = [ "log", "md-5", "num-traits", - "rand 0.9.2", + "rand 0.9.3", "regex", "sha2", "unicode-segmentation", @@ -2064,9 +2064,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e42c227d9e55a6c8041785d4a8a117e4de531033d480aae10984247ac62e27e" +checksum = "b644f9cf696df9233ce6958b9807666d78563b56f923267474dd6c07795f1f8f" dependencies = [ "ahash", "arrow", @@ -2085,9 +2085,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cead3cfed825b0b688700f4338d281cd7857e4907775a5b9554c083edd5f3f95" +checksum = "c1de2deaaabe8923ce9ea9f29c47bbb4ee14f67ea2fe1ab5398d9bbebcf86e56" dependencies = [ "ahash", "arrow", @@ -2098,9 +2098,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ea99612970aebab8cf864d02eb3d296bbab7f4881e1023d282b57fe431b201" +checksum = "552f8d92e4331ee91d23c02d12bb6acf32cbfd5215117e01c0fb63cd4b15af1a" dependencies = [ "arrow", "arrow-ord", @@ -2121,9 +2121,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83dbf3ab8b9af6f209b068825a7adbd3b88bf276f2a1ec14ba09567b97f5674" +checksum = "970fd0cdd3df8802b9a9975ff600998289ba9d46682a4f7285cba4820c9ada78" dependencies = [ "arrow", "async-trait", @@ -2137,9 +2137,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732edabe07496e2fc5a1e57a284d7a36edcea445a2821119770a0dea624b472c" +checksum = "40b4c21a7c8a986a1866c0a87ab756d0bbf7b5f41f306009fa2d9af79c52ed31" dependencies = [ "arrow", "datafusion-common", @@ -2155,9 +2155,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c6e30e09700799bd52adce8c377ab03dda96e73a623e4803a31ad94fe7ce14" +checksum = "b1210ad73b8b3211aeaf4a42bef9bd7a2b7fce3ec119a478831f18c6ff7f7b93" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -2165,9 +2165,9 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402f2a8ed70fb99a18f71580a1fe338604222a3d32ddeac6e72c5b34feea2d4d" +checksum = "aaa566a963013a38681ad82a727a654bc7feb19632426aea8c3412d415d200c5" dependencies = [ "datafusion-doc", "quote", @@ -2176,9 +2176,9 @@ dependencies = [ [[package]] name = "datafusion-optimizer" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99f32edb8ba12f08138f86c09b80fae3d4a320551262fa06b91d8a8cb3065a5b" +checksum = "ff9aa82b240252a88dee118372f9b9757c545ab9e53c0736bebab2e7da0ef1f2" dependencies = [ "arrow", "chrono", @@ -2196,9 +2196,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "987c5e29e96186589301b42e25aa7d11bbe319a73eb02ef8d755edc55b5b89fc" +checksum = "7d48022b8af9988c1d852644f9e8b5584c490659769a550c5e8d39457a1da0a5" dependencies = [ "ahash", "arrow", @@ -2220,9 +2220,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-adapter" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de89d0afa08b6686697bd8a6bac4ba2cd44c7003356e1bce6114d5a93f94b5c" +checksum = "ae7a8abc0b4fe624000972a9b145b30b7f1b680bffaa950ea53f78d9b21c27c3" dependencies = [ "arrow", "datafusion-common", @@ -2235,9 +2235,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602d1970c0fe87f1c3a36665d131fbfe1c4379d35f8fc5ec43a362229ad2954d" +checksum = "147253ca3e6b9d59c162de64c02800973018660e13340dd1886dd038d17ac429" dependencies = [ "ahash", "arrow", @@ -2252,9 +2252,9 @@ dependencies = [ [[package]] name = "datafusion-physical-optimizer" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b24d704b6385ebe27c756a12e5ba15684576d3b47aeca79cc9fb09480236dc32" +checksum = "689156bb2282107b6239db8d7ef44b4dab10a9b33d3491a0c74acac5e4fedd72" dependencies = [ "arrow", "datafusion-common", @@ -2271,9 +2271,9 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c21d94141ea5043e98793f170798e9c1887095813b8291c5260599341e383a38" +checksum = "68253dc0ee5330aa558b2549c9b0da5af9fc17d753ae73022939014ad616fc28" dependencies = [ "ahash", "arrow", @@ -2302,9 +2302,9 @@ dependencies = [ [[package]] name = "datafusion-proto" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5e139c4259ccfd12e9f786172ebdf26245c041f7a40ddd0e7651d29da0fd249" +checksum = "3f5ab57d0b5a368258fff1d828f1619a10541fa5c4ec4930a383deb3a23204c8" dependencies = [ "arrow", "chrono", @@ -2329,9 +2329,9 @@ dependencies = [ [[package]] name = "datafusion-proto-common" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ea6437aecb636b0ea67c6a09feb68d20aaab163402acfa73173a61d78e15110" +checksum = "bd21d2c804802ca4b1719191dfe8e3d0860686649de6375ddc9237f85beb82b3" dependencies = [ "arrow", "datafusion-common", @@ -2340,9 +2340,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a68cce43d18c0dfac95cacd74e70565f7e2fb12b9ed41e2d312f0fa837626b1" +checksum = "0fcad240a54d0b1d3e8f668398900260a53122d522b2102ab57218590decacd6" dependencies = [ "arrow", "datafusion-common", @@ -2357,9 +2357,9 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b4e1c40a0b1896aed4a4504145c2eb7fa9b9da13c2d04b40a4767a09f076199" +checksum = "f58e83a68bb67007a8fcbf005c44cefe441270c7ee7f6dee10c0e0109b556f6d" dependencies = [ "async-trait", "datafusion-common", @@ -2371,9 +2371,9 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f1891e5b106d1d73c7fe403bd8a265d19c3977edc17f60808daf26c2fe65ffb" +checksum = "be53e9eb55db0fbb8980bb6d87f2435b0524acf4c718ed54a57cabbb299b2ab3" dependencies = [ "arrow", "bigdecimal", @@ -2389,9 +2389,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "52.4.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2379388ecab67079eeb1185c953fb9c5ed4b283fa3cb81417538378a30545957" +checksum = "7dadf18107366b3470e47bdde8eeb9294b36444e451830d8e5fc8dc7b4ad9c58" dependencies = [ "async-recursion", "async-trait", @@ -2827,10 +2827,10 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -3839,7 +3839,7 @@ dependencies = [ "nom 8.0.0", "num-traits", "ordered-float 5.3.0", - "rand 0.9.2", + "rand 0.9.3", "serde", "serde_json", "zmij", @@ -3871,7 +3871,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -3922,7 +3922,7 @@ dependencies = [ "pin-project", "prost", "prost-types", - "rand 0.9.2", + "rand 0.9.3", "roaring", "semver", "serde", @@ -3939,7 +3939,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3955,12 +3955,12 @@ dependencies = [ "half", "jsonb", "num-traits", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrayref", "paste", @@ -3969,7 +3969,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -3992,7 +3992,7 @@ dependencies = [ "object_store", "pin-project", "prost", - "rand 0.9.2", + "rand 0.9.3", "roaring", "serde_json", "snafu", @@ -4006,7 +4006,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4040,7 +4040,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4050,7 +4050,7 @@ dependencies = [ "futures", "half", "hex", - "rand 0.9.2", + "rand 0.9.3", "rand_distr 0.5.1", "rand_xoshiro", "random_word", @@ -4058,7 +4058,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4084,7 +4084,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.3", "snafu", "strum 0.26.3", "tokio", @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-arith", "arrow-array", @@ -4127,7 +4127,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "datafusion", "geo-traits", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -4193,7 +4193,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.3", "rand_distr 0.5.1", "rangemap", "rayon", @@ -4212,7 +4212,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-arith", @@ -4245,7 +4245,7 @@ dependencies = [ "path_abs", "pin-project", "prost", - "rand 0.9.2", + "rand 0.9.3", "serde", "snafu", "tempfile", @@ -4256,7 +4256,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow-array", "arrow-buffer", @@ -4267,12 +4267,12 @@ dependencies = [ "lance-arrow", "lance-core", "num-traits", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] name = "lance-namespace" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "async-trait", @@ -4285,7 +4285,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-ipc", @@ -4304,7 +4304,7 @@ dependencies = [ "lance-table", "log", "object_store", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "serde", "serde_json", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -4354,7 +4354,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.3", "rangemap", "roaring", "semver", @@ -4592,7 +4592,7 @@ dependencies = [ "md5", "memmap2", "once_cell", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "serde", "tar", @@ -5094,7 +5094,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml 0.38.4", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "ring", "rustls-pemfile", @@ -5694,7 +5694,7 @@ dependencies = [ [[package]] name = "pylance" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" dependencies = [ "arrow", "arrow-array", @@ -5865,7 +5865,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.3", "ring", "rustc-hash", "rustls", @@ -5931,9 +5931,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -5994,7 +5994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -6015,7 +6015,7 @@ dependencies = [ "ahash", "brotli", "paste", - "rand 0.9.2", + "rand 0.9.3", "unicase", ] @@ -7587,7 +7587,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" dependencies = [ - "rand 0.9.2", + "rand 0.9.3", ] [[package]] diff --git a/python/Cargo.toml b/python/Cargo.toml index ca42417ba55..95a8474d9b2 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "5.1.0-beta.2" +version = "5.1.0-beta.3" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From dabfb933c789b661805c36c9397d4045a1bc76ef Mon Sep 17 00:00:00 2001 From: dentiny Date: Sun, 12 Apr 2026 23:20:00 -0700 Subject: [PATCH 178/206] fix: use logical OR instead of bitwise OR in conflict resolver (#6492) --- rust/lance/src/io/commit/conflict_resolver.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/lance/src/io/commit/conflict_resolver.rs b/rust/lance/src/io/commit/conflict_resolver.rs index c270d2ab7e4..2c496a77a74 100644 --- a/rust/lance/src/io/commit/conflict_resolver.rs +++ b/rust/lance/src/io/commit/conflict_resolver.rs @@ -1095,7 +1095,7 @@ impl<'a> TransactionRebase<'a> { .transaction .operation .upsert_key_conflict(&other_transaction.operation) - | self + || self .transaction .operation .modifies_same_metadata(&other_transaction.operation) From 27351e5bc671b0f9148e1cef257c3cbe7dc691d9 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Mon, 13 Apr 2026 23:47:11 +0800 Subject: [PATCH 179/206] fix: keep delete-by-source fast path with scalar indexes (#6435) This teaches `merge_insert` to keep the delete-by-source fast path even when a scalar index exists on the join key. The actual indexed join path is still only used when unmatched target rows are kept, so the presence of index metadata should not force these operations back to the legacy full-join path. This also adds regression coverage for full-schema `FixedSizeList` merges with `when_not_matched_by_source(Delete)` both with and without a scalar index. That closes the gap behind #6195 and preserves the earlier fix for lancedb/lancedb#3094. --- rust/lance/src/dataset/write/merge_insert.rs | 203 +++++++++++++++++-- 1 file changed, 188 insertions(+), 15 deletions(-) diff --git a/rust/lance/src/dataset/write/merge_insert.rs b/rust/lance/src/dataset/write/merge_insert.rs index 89b578126fa..b252c3123a4 100644 --- a/rust/lance/src/dataset/write/merge_insert.rs +++ b/rust/lance/src/dataset/write/merge_insert.rs @@ -864,25 +864,28 @@ impl MergeInsertJob { &self, source: SendableRecordBatchStream, ) -> Result { - // We need to do a full index scan if we're deleting source data - let can_use_scalar_index = matches!( - self.params.delete_not_matched_by_source, // this value marks behavior for rows in target that are not matched by the source. Value assigned earlier. - WhenNotMatchedBySource::Keep - ) && self.params.use_index; - - if can_use_scalar_index { + if self.params.use_index + && matches!( + self.params.delete_not_matched_by_source, + WhenNotMatchedBySource::Keep + ) + { // keeping unmatched rows, no deletion if let Some(index) = self.join_key_as_scalar_index().await? { - self.create_indexed_scan_joined_stream(source, index).await - } else { - self.create_full_table_joined_stream(source).await + return self.create_indexed_scan_joined_stream(source, index).await; } - } else { + } + + if !matches!( + self.params.delete_not_matched_by_source, + WhenNotMatchedBySource::Keep + ) { info!( "The merge insert operation is configured to delete rows from the target table, this requires a potentially costly full table scan" ); - self.create_full_table_joined_stream(source).await } + + self.create_full_table_joined_stream(source).await } async fn update_fragments( @@ -1480,7 +1483,7 @@ impl MergeInsertJob { /// /// The fast path is only available for specific conditions: /// - when_matched is UpdateAll or UpdateIf or Fail - /// - Either use_index is false OR there's no scalar index on join key + /// - The execution will not use the legacy scalar-index join path /// - Source schema matches dataset schema exactly /// - when_not_matched_by_source is Keep, Delete, or DeleteIf async fn can_use_create_plan(&self, source_schema: &Schema) -> Result { @@ -1499,7 +1502,15 @@ impl MergeInsertJob { }, ); - let has_scalar_index = self.join_key_as_scalar_index().await?.is_some(); + let would_use_scalar_index = if self.params.use_index + && matches!( + self.params.delete_not_matched_by_source, + WhenNotMatchedBySource::Keep + ) { + self.join_key_as_scalar_index().await?.is_some() + } else { + false + }; // Check if this is a delete-only operation (no update/insert writes needed from source) // For delete-only, we don't need the full source schema, just key columns for matching @@ -1523,7 +1534,7 @@ impl MergeInsertJob { | WhenMatched::UpdateIf(_) | WhenMatched::Fail | WhenMatched::Delete - ) && (!self.params.use_index || !has_scalar_index) + ) && !would_use_scalar_index && schema_ok && matches!( self.params.delete_not_matched_by_source, @@ -5448,6 +5459,76 @@ MergeInsert: on=[id], when_matched=UpdateAll, when_not_matched=InsertAll, when_n ); } + #[tokio::test] + async fn test_explain_plan_full_schema_delete_by_source_with_fsl_and_scalar_index() { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vec", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + true, + ), + ])); + + let dataset_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2, 3])), + Arc::new( + FixedSizeListArray::try_new_from_values( + Float32Array::from(vec![ + 1.0, 1.1, 1.2, 1.3, 2.0, 2.1, 2.2, 2.3, 3.0, 3.1, 3.2, 3.3, + ]), + 4, + ) + .unwrap(), + ), + ], + ) + .unwrap(); + + let mut dataset = Dataset::write( + Box::new(RecordBatchIterator::new( + [Ok(dataset_batch)], + schema.clone(), + )), + "memory://test_explain_plan_full_schema_delete_by_source_with_fsl_and_scalar_index", + None, + ) + .await + .unwrap(); + + let scalar_params = ScalarIndexParams::default(); + dataset + .create_index(&["id"], IndexType::Scalar, None, &scalar_params, false) + .await + .unwrap(); + + let merge_insert_job = + MergeInsertBuilder::try_new(Arc::new(dataset), vec!["id".to_string()]) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::InsertAll) + .when_not_matched_by_source(WhenNotMatchedBySource::Delete) + .try_build() + .unwrap(); + + let plan = merge_insert_job.explain_plan(None, false).await.unwrap(); + assert!(plan.contains("HashJoinExec")); + assert!(plan.contains("join_type=Full")); + assert!(plan.contains("projection=[_rowid")); + assert!( + plan.contains("LanceRead: uri=") && plan.contains("projection=[id]"), + "target-side scan should prune the FSL payload from the join build side even when a scalar index exists: {plan}" + ); + assert!( + !plan.contains( + "LanceRead: uri=test_explain_plan_full_schema_delete_by_source_with_fsl_and_scalar_index/data, projection=[id, vec]" + ), + "target-side scan should not include the FSL payload in the join build side: {plan}" + ); + } + #[tokio::test] async fn test_merge_insert_full_schema_delete_by_source_with_fsl() { let schema = Arc::new(Schema::new(vec![ @@ -5534,6 +5615,98 @@ MergeInsert: on=[id], when_matched=UpdateAll, when_not_matched=InsertAll, when_n assert_eq!(actual, vec![20.0, 20.1, 20.2, 20.3, 40.0, 40.1, 40.2, 40.3]); } + #[tokio::test] + async fn test_merge_insert_full_schema_delete_by_source_with_fsl_and_scalar_index() { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new( + "vec", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + true, + ), + ])); + + let dataset_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![1, 2, 3])), + Arc::new( + FixedSizeListArray::try_new_from_values( + Float32Array::from(vec![ + 1.0, 1.1, 1.2, 1.3, 2.0, 2.1, 2.2, 2.3, 3.0, 3.1, 3.2, 3.3, + ]), + 4, + ) + .unwrap(), + ), + ], + ) + .unwrap(); + + let mut dataset = Dataset::write( + Box::new(RecordBatchIterator::new( + [Ok(dataset_batch)], + schema.clone(), + )), + "memory://test_merge_insert_full_schema_delete_by_source_with_fsl_and_scalar_index", + None, + ) + .await + .unwrap(); + + let scalar_params = ScalarIndexParams::default(); + dataset + .create_index(&["id"], IndexType::Scalar, None, &scalar_params, false) + .await + .unwrap(); + + let source_batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from(vec![2, 4])), + Arc::new( + FixedSizeListArray::try_new_from_values( + Float32Array::from(vec![20.0, 20.1, 20.2, 20.3, 40.0, 40.1, 40.2, 40.3]), + 4, + ) + .unwrap(), + ), + ], + ) + .unwrap(); + + let (merged_dataset, stats) = + MergeInsertBuilder::try_new(Arc::new(dataset), vec!["id".to_string()]) + .unwrap() + .when_matched(WhenMatched::UpdateAll) + .when_not_matched(WhenNotMatched::InsertAll) + .when_not_matched_by_source(WhenNotMatchedBySource::Delete) + .try_build() + .unwrap() + .execute_reader(Box::new(RecordBatchIterator::new( + [Ok(source_batch)], + schema.clone(), + ))) + .await + .unwrap(); + + assert_eq!(stats.num_deleted_rows, 2); + assert_eq!(stats.num_updated_rows, 1); + assert_eq!(stats.num_inserted_rows, 1); + + let merged = merged_dataset.scan().try_into_batch().await.unwrap(); + let ids = merged["id"].as_primitive::().values().to_vec(); + assert_eq!(ids, vec![2, 4]); + + let vecs = merged["vec"].as_fixed_size_list(); + let actual = vecs + .values() + .as_primitive::() + .values() + .to_vec(); + assert_eq!(actual, vec![20.0, 20.1, 20.2, 20.3, 40.0, 40.1, 40.2, 40.3]); + } + #[tokio::test] async fn test_analyze_plan() { // Set up test data using lance_datagen From 9931ace339b2d36e7acdc38c86e994f5f8f5190d Mon Sep 17 00:00:00 2001 From: Beinan Date: Mon, 13 Apr 2026 11:10:21 -0700 Subject: [PATCH 180/206] perf: intern DataFile fields/column_indices to reduce manifest memory (#6477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Change `DataFile.fields` and `DataFile.column_indices` from `Vec` to `Arc<[i32]>` so that fragments with identical field lists share a single heap allocation - Add `DataFileFieldInterner` that deduplicates these slices during manifest deserialization - In homogeneous tables (the common case), every fragment carries the same field list, so at 20M fragments this saves **~2.4 GB** of redundant heap allocations ## Motivation When dataset manifests grow large (>1 GB with millions of fragments), opening the dataset becomes very expensive in terms of memory. Each `DataFile` previously owned its own `Vec` for `fields` and `column_indices`, even though in most tables every fragment has the exact same field list. This PR deduplicates those allocations at deserialization time. ### Per-fragment memory breakdown (before) | Field | Size per fragment | |-------|------------------| | `fields: Vec` (10 fields) | ~64 bytes | | `column_indices: Vec` (10 cols) | ~64 bytes | | **Total redundant** | **~128 bytes x 20M = ~2.4 GB** | ### After this change With interning, all 20M fragments share a single `Arc<[i32]>` allocation (~80 bytes total instead of 2.4 GB). ## Changes - **`lance-table/src/format/fragment.rs`** — Core struct change (`Vec` → `Arc<[i32]>`), custom `Serialize`/`Deserialize` impls, and `DataFileFieldInterner` - **`lance-table/src/format/manifest.rs`** — Use interner during manifest deserialization - **`lance/src/dataset/fragment.rs`**, **`merge_insert.rs`**, **`io/commit.rs`** — Tombstoning and field-remapping rebuilt as new `Arc<[i32]>` instead of in-place mutation - **`python/src/fragment.rs`**, **`java/lance-jni/src/fragment.rs`** — FFI boundary conversions - Various test files — Updated struct literals and assertions ## Compatibility - No format change — protobuf schema is unchanged - Serde JSON output is identical (custom impl serializes `Arc<[i32]>` as `[i32]`) - All public API signatures that take `Vec` (e.g., `DataFile::new()`, `Fragment::add_file()`) still accept `Vec` and convert internally 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- java/lance-jni/src/fragment.rs | 8 +- python/src/debug.rs | 4 +- python/src/fragment.rs | 10 +- rust/lance-table/src/format/fragment.rs | 146 ++++++++++++++++-- rust/lance-table/src/format/manifest.rs | 6 +- rust/lance/src/dataset/fragment.rs | 21 ++- rust/lance/src/dataset/fragment/write.rs | 25 +-- .../lance/src/dataset/optimize/binary_copy.rs | 5 +- .../src/dataset/optimize/tests/binary_copy.rs | 6 +- .../src/dataset/tests/dataset_merge_update.rs | 46 ++++-- rust/lance/src/dataset/transaction.rs | 20 +-- rust/lance/src/dataset/write.rs | 2 +- rust/lance/src/dataset/write/commit.rs | 4 +- rust/lance/src/dataset/write/merge_insert.rs | 23 ++- rust/lance/src/io/commit.rs | 27 ++-- rust/lance/src/io/commit/conflict_resolver.rs | 6 +- rust/lance/src/utils/test.rs | 2 +- 17 files changed, 257 insertions(+), 104 deletions(-) diff --git a/java/lance-jni/src/fragment.rs b/java/lance-jni/src/fragment.rs index 3b2a7ba6b22..5db1892d55d 100644 --- a/java/lance-jni/src/fragment.rs +++ b/java/lance-jni/src/fragment.rs @@ -536,8 +536,8 @@ impl IntoJava for &FragmentUpdateResult { impl IntoJava for &DataFile { fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { let path = env.new_string(self.path.clone())?.into(); - let fields = JLance(self.fields.clone()).into_java(env)?; - let column_indices = JLance(self.column_indices.clone()).into_java(env)?; + let fields = JLance(self.fields.to_vec()).into_java(env)?; + let column_indices = JLance(self.column_indices.to_vec()).into_java(env)?; let file_size_bytes = match self.file_size_bytes.get() { Some(f) => JLance(u64::from(f) as i64).into_java(env)?, None => JObject::null(), @@ -775,8 +775,8 @@ impl FromJObjectWithEnv for JObject<'_> { let base_id = get_base_id(env, self)?; Ok(DataFile { path, - fields, - column_indices, + fields: fields.into(), + column_indices: column_indices.into(), file_major_version, file_minor_version, file_size_bytes, diff --git a/python/src/debug.rs b/python/src/debug.rs index 7bc073dff17..bc0e09aba2b 100644 --- a/python/src/debug.rs +++ b/python/src/debug.rs @@ -61,8 +61,8 @@ impl PrettyPrintableFragment { let schema = schema.project_by_ids(&file.fields, false); PrettyPrintableDataFile { path: file.path.clone(), - fields: file.fields.clone(), - column_indices: file.column_indices.clone(), + fields: file.fields.to_vec(), + column_indices: file.column_indices.to_vec(), schema, major_version: file.file_major_version, minor_version: file.file_minor_version, diff --git a/python/src/fragment.rs b/python/src/fragment.rs index de4165c6761..3dd512eb546 100644 --- a/python/src/fragment.rs +++ b/python/src/fragment.rs @@ -846,10 +846,12 @@ impl FromPyObject<'_> for PyLance { fn extract_bound(ob: &pyo3::Bound<'_, PyAny>) -> PyResult { let file_size_bytes: Option = ob.getattr("file_size_bytes")?.extract()?; let file_size_bytes = CachedFileSize::new(file_size_bytes.unwrap_or(0)); + let fields: Vec = ob.getattr("fields")?.extract()?; + let column_indices: Vec = ob.getattr("column_indices")?.extract()?; Ok(Self(DataFile { path: ob.getattr("path")?.extract()?, - fields: ob.getattr("fields")?.extract()?, - column_indices: ob.getattr("column_indices")?.extract()?, + fields: fields.into(), + column_indices: column_indices.into(), file_major_version: ob.getattr("file_major_version")?.extract()?, file_minor_version: ob.getattr("file_minor_version")?.extract()?, file_size_bytes, @@ -872,8 +874,8 @@ impl<'py> IntoPyObject<'py> for PyLance<&DataFile> { let file_size_bytes = self.0.file_size_bytes.get().map(u64::from); cls.call1(( &self.0.path, - self.0.fields.clone(), - self.0.column_indices.clone(), + self.0.fields.to_vec(), + self.0.column_indices.to_vec(), self.0.file_major_version, self.0.file_minor_version, file_size_bytes, diff --git a/rust/lance-table/src/format/fragment.rs b/rust/lance-table/src/format/fragment.rs index 01c5b535f8e..621198e329e 100644 --- a/rust/lance-table/src/format/fragment.rs +++ b/rust/lance-table/src/format/fragment.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::collections::HashMap; use std::num::NonZero; +use std::sync::Arc; use deepsize::DeepSizeOf; use lance_core::Error; @@ -9,7 +11,7 @@ use lance_file::format::{MAJOR_VERSION, MINOR_VERSION}; use lance_file::version::LanceFileVersion; use lance_io::utils::CachedFileSize; use object_store::path::Path; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::format::pb; @@ -22,12 +24,16 @@ use lance_core::error::Result; /// Lance Data File /// /// A data file is one piece of file storing data. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeepSizeOf)] +#[derive(Debug, Clone, PartialEq, Eq, DeepSizeOf)] pub struct DataFile { /// Relative path of the data file to dataset root. pub path: String, /// The ids of fields in this file. - pub fields: Vec, + /// + /// When identical across many fragments (common case), multiple `DataFile` + /// instances share a single heap allocation via `Arc`, significantly + /// reducing manifest memory for large tables. + pub fields: Arc<[i32]>, /// The offsets of the fields listed in `fields`, empty in v1 files /// /// Note that -1 is a possibility and it indices that the field has @@ -37,13 +43,10 @@ pub struct DataFile { /// `column_indices`; such columns are ignored by field-id–based projection. /// For example, some fields, such as blob fields, occupy multiple /// columns in the file but only have a single field id. - #[serde(default)] - pub column_indices: Vec, + pub column_indices: Arc<[i32]>, /// The major version of the file format used to write this file. - #[serde(default)] pub file_major_version: u32, /// The minor version of the file format used to write this file. - #[serde(default)] pub file_minor_version: u32, /// The size of the file in bytes, if known. @@ -53,6 +56,52 @@ pub struct DataFile { pub base_id: Option, } +// Custom Serialize: convert Arc<[i32]> to slice for transparent JSON output +impl Serialize for DataFile { + fn serialize(&self, serializer: S) -> std::result::Result { + use serde::ser::SerializeStruct; + let mut s = serializer.serialize_struct("DataFile", 7)?; + s.serialize_field("path", &self.path)?; + s.serialize_field("fields", self.fields.as_ref())?; + s.serialize_field("column_indices", self.column_indices.as_ref())?; + s.serialize_field("file_major_version", &self.file_major_version)?; + s.serialize_field("file_minor_version", &self.file_minor_version)?; + s.serialize_field("file_size_bytes", &self.file_size_bytes)?; + s.serialize_field("base_id", &self.base_id)?; + s.end() + } +} + +// Custom Deserialize: read Vec and convert to Arc<[i32]> +impl<'de> Deserialize<'de> for DataFile { + fn deserialize>(deserializer: D) -> std::result::Result { + #[derive(Deserialize)] + struct DataFileHelper { + path: String, + fields: Vec, + #[serde(default)] + column_indices: Vec, + #[serde(default)] + file_major_version: u32, + #[serde(default)] + file_minor_version: u32, + file_size_bytes: CachedFileSize, + base_id: Option, + } + + let helper = DataFileHelper::deserialize(deserializer)?; + Ok(Self { + path: helper.path, + fields: Arc::from(helper.fields), + column_indices: Arc::from(helper.column_indices), + file_major_version: helper.file_major_version, + file_minor_version: helper.file_minor_version, + file_size_bytes: helper.file_size_bytes, + base_id: helper.base_id, + }) + } +} + impl DataFile { pub fn new( path: impl Into, @@ -65,8 +114,8 @@ impl DataFile { ) -> Self { Self { path: path.into(), - fields, - column_indices, + fields: Arc::from(fields), + column_indices: Arc::from(column_indices), file_major_version, file_minor_version, file_size_bytes: file_size_bytes.into(), @@ -82,8 +131,8 @@ impl DataFile { ) -> Self { Self { path: path.into(), - fields: vec![], - column_indices: vec![], + fields: Arc::from([]), + column_indices: Arc::from([]), file_major_version, file_minor_version, file_size_bytes: Default::default(), @@ -158,8 +207,8 @@ impl From<&DataFile> for pb::DataFile { fn from(df: &DataFile) -> Self { Self { path: df.path.clone(), - fields: df.fields.clone(), - column_indices: df.column_indices.clone(), + fields: df.fields.to_vec(), + column_indices: df.column_indices.to_vec(), file_major_version: df.file_major_version, file_minor_version: df.file_minor_version, file_size_bytes: df.file_size_bytes.get().map_or(0, |v| v.get()), @@ -174,14 +223,77 @@ impl TryFrom for DataFile { fn try_from(proto: pb::DataFile) -> Result { Ok(Self { path: proto.path, - fields: proto.fields, - column_indices: proto.column_indices, + fields: Arc::from(proto.fields), + column_indices: Arc::from(proto.column_indices), + file_major_version: proto.file_major_version, + file_minor_version: proto.file_minor_version, + file_size_bytes: CachedFileSize::new(proto.file_size_bytes), + base_id: proto.base_id, + }) + } +} + +/// Interns `fields` and `column_indices` slices so that `DataFile` instances +/// with identical content share a single `Arc<[i32]>` allocation. +/// +/// At 20M fragments the deduplication typically saves multiple GB of heap +/// because every fragment in a homogeneous table carries the same field list. +#[derive(Default)] +pub struct DataFileFieldInterner { + fields: HashMap, Arc<[i32]>>, + column_indices: HashMap, Arc<[i32]>>, +} + +impl DataFileFieldInterner { + /// Intern a `Vec`: if the same content was seen before, return a + /// clone of the existing `Arc`; otherwise store and return a new one. + fn intern(cache: &mut HashMap, Arc<[i32]>>, v: Vec) -> Arc<[i32]> { + cache + .entry(v) + .or_insert_with_key(|k| Arc::from(k.as_slice())) + .clone() + } + + /// Convert a protobuf `DataFile`, interning `fields` and `column_indices`. + pub fn intern_data_file(&mut self, proto: pb::DataFile) -> Result { + Ok(DataFile { + path: proto.path, + fields: Self::intern(&mut self.fields, proto.fields), + column_indices: Self::intern(&mut self.column_indices, proto.column_indices), file_major_version: proto.file_major_version, file_minor_version: proto.file_minor_version, file_size_bytes: CachedFileSize::new(proto.file_size_bytes), base_id: proto.base_id, }) } + + /// Convert a protobuf `DataFragment`, interning fields within its data files. + pub fn intern_fragment(&mut self, p: pb::DataFragment) -> Result { + let physical_rows = if p.physical_rows > 0 { + Some(p.physical_rows as usize) + } else { + None + }; + Ok(Fragment { + id: p.id, + files: p + .files + .into_iter() + .map(|f| self.intern_data_file(f)) + .collect::>()?, + deletion_file: p.deletion_file.map(DeletionFile::try_from).transpose()?, + row_id_meta: p.row_id_sequence.map(RowIdMeta::try_from).transpose()?, + physical_rows, + last_updated_at_version_meta: p + .last_updated_at_version_sequence + .map(RowDatasetVersionMeta::try_from) + .transpose()?, + created_at_version_meta: p + .created_at_version_sequence + .map(RowDatasetVersionMeta::try_from) + .transpose()?, + }) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeepSizeOf)] @@ -624,9 +736,9 @@ mod tests { fn data_file_validate_allows_extra_columns() { let data_file = DataFile { path: "foo.lance".to_string(), - fields: vec![1, 2], + fields: Arc::from([1, 2]), // One extra column without a field id mapping - column_indices: vec![0, 1, 2], + column_indices: Arc::from([0, 1, 2]), file_major_version: MAJOR_VERSION as u32, file_minor_version: MINOR_VERSION as u32, file_size_bytes: Default::default(), diff --git a/rust/lance-table/src/format/manifest.rs b/rust/lance-table/src/format/manifest.rs index 71de80c547f..d2b5f2d31c6 100644 --- a/rust/lance-table/src/format/manifest.rs +++ b/rust/lance-table/src/format/manifest.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use super::Fragment; use crate::feature_flags::{FLAG_STABLE_ROW_IDS, has_deprecated_v2_feature_flag}; +use crate::format::fragment::DataFileFieldInterner; use crate::format::pb; use lance_core::cache::LanceCache; use lance_core::datatypes::Schema; @@ -426,7 +427,7 @@ impl Manifest { let fragment_max_id = self .fragments .iter() - .flat_map(|f| f.files.iter().flat_map(|file| file.fields.as_slice())) + .flat_map(|f| f.files.iter().flat_map(|file| file.fields.iter())) .max() .copied(); let fragment_max_id = fragment_max_id.unwrap_or(-1); @@ -863,10 +864,11 @@ impl TryFrom for Manifest { }), _ => None, }; + let mut interner = DataFileFieldInterner::default(); let fragments = Arc::new( p.fragments .into_iter() - .map(Fragment::try_from) + .map(|f| interner.intern_fragment(f)) .collect::>>()?, ); let fragment_offsets = compute_fragment_offsets(fragments.as_slice()); diff --git a/rust/lance/src/dataset/fragment.rs b/rust/lance/src/dataset/fragment.rs index 986fe8a8443..29b33a75018 100644 --- a/rust/lance/src/dataset/fragment.rs +++ b/rust/lance/src/dataset/fragment.rs @@ -1209,7 +1209,7 @@ impl FileFragment { let mut seen_fields = HashSet::new(); for data_file in &self.metadata.files { let last = -1; - for field_id in &data_file.fields { + for field_id in data_file.fields.iter() { if *field_id <= last { return Err(Error::corrupt_file( self.dataset @@ -1666,12 +1666,19 @@ impl FileFragment { // Mark fields in updated data files as obsolete ("tombstone"). let updated_fields = updated_fragment.files.last().unwrap().fields.clone(); for data_file in &mut updated_fragment.files.iter_mut().rev().skip(1) { - for field in &mut data_file.fields { - if updated_fields.contains(field) { - // Tombstone these fields - *field = -2; - } - } + let new_fields: Arc<[i32]> = data_file + .fields + .iter() + .map(|field| { + if updated_fields.contains(field) { + -2 // Tombstone + } else { + *field + } + }) + .collect::>() + .into(); + data_file.fields = new_fields; } // Remove data files that have become entirely tombstoned. updated_fragment diff --git a/rust/lance/src/dataset/fragment/write.rs b/rust/lance/src/dataset/fragment/write.rs index a1c02d20758..834d4dbd37b 100644 --- a/rust/lance/src/dataset/fragment/write.rs +++ b/rust/lance/src/dataset/fragment/write.rs @@ -15,6 +15,7 @@ use lance_io::object_store::ObjectStore; use lance_table::format::{DataFile, Fragment}; use lance_table::io::manifest::ManifestDescribing; use std::borrow::Cow; +use std::sync::Arc; use uuid::Uuid; use crate::Result; @@ -170,16 +171,18 @@ impl<'a> FragmentCreateBuilder<'a> { return Err(Error::invalid_input("Input data was empty.")); } - let field_ids = writer + let field_ids: Arc<[i32]> = writer .field_id_to_column_indices() .iter() .map(|(field_id, _)| *field_id as i32) - .collect::>(); - let column_indices = writer + .collect::>() + .into(); + let column_indices: Arc<[i32]> = writer .field_id_to_column_indices() .iter() .map(|(_, column_index)| *column_index as i32) - .collect::>(); + .collect::>() + .into(); fragment.files[0].fields = field_ids; fragment.files[0].column_indices = column_indices; @@ -414,7 +417,7 @@ mod tests { assert_eq!(fragment.id, 0); assert_eq!(fragment.deletion_file, None); assert_eq!(fragment.files.len(), 1); - assert_eq!(fragment.files[0].fields, vec![0, 1]); + assert_eq!(fragment.files[0].fields.as_ref(), &[0, 1]); } #[tokio::test] @@ -437,8 +440,8 @@ mod tests { assert_eq!(fragment.id, 42); assert_eq!(fragment.deletion_file, None); assert_eq!(fragment.files.len(), 1); - assert_eq!(fragment.files[0].fields, vec![3, 1]); - assert_eq!(fragment.files[0].column_indices, vec![0, 1]); + assert_eq!(fragment.files[0].fields.as_ref(), &[3, 1]); + assert_eq!(fragment.files[0].column_indices.as_ref(), &[0, 1]); } #[tokio::test] @@ -500,7 +503,7 @@ mod tests { assert_eq!(fragments.len(), 1); assert_eq!(fragments[0].deletion_file, None); assert_eq!(fragments[0].files.len(), 1); - assert_eq!(fragments[0].files[0].fields, vec![0, 1]); + assert_eq!(fragments[0].files[0].fields.as_ref(), &[0, 1]); } #[tokio::test] @@ -521,13 +524,13 @@ mod tests { assert_eq!(fragments.len(), 3); assert_eq!(fragments[0].deletion_file, None); assert_eq!(fragments[0].files.len(), 1); - assert_eq!(fragments[0].files[0].column_indices, vec![0, 1]); + assert_eq!(fragments[0].files[0].column_indices.as_ref(), &[0, 1]); assert_eq!(fragments[1].deletion_file, None); assert_eq!(fragments[1].files.len(), 1); - assert_eq!(fragments[1].files[0].column_indices, vec![0, 1]); + assert_eq!(fragments[1].files[0].column_indices.as_ref(), &[0, 1]); assert_eq!(fragments[2].deletion_file, None); assert_eq!(fragments[2].files.len(), 1); - assert_eq!(fragments[2].files[0].column_indices, vec![0, 1]); + assert_eq!(fragments[2].files[0].column_indices.as_ref(), &[0, 1]); } #[rstest] diff --git a/rust/lance/src/dataset/optimize/binary_copy.rs b/rust/lance/src/dataset/optimize/binary_copy.rs index 05299ab95f7..8b205471f49 100644 --- a/rust/lance/src/dataset/optimize/binary_copy.rs +++ b/rust/lance/src/dataset/optimize/binary_copy.rs @@ -136,9 +136,10 @@ async fn finalize_current_output_file( // Register the newly closed output file as a fragment data file let (maj, min) = version.to_numbers(); let mut fragment = Fragment::new(0); + let field_column_indices = compute_field_column_indices(schema, full_field_ids.len(), version); let mut data_file = DataFile::new_unstarted(current_filename.take().unwrap(), maj, min); - data_file.fields = full_field_ids.to_vec(); - data_file.column_indices = compute_field_column_indices(schema, full_field_ids.len(), version); + data_file.fields = full_field_ids.to_vec().into(); + data_file.column_indices = field_column_indices.into(); fragment.files.push(data_file); fragment.physical_rows = Some(total_rows_in_current as usize); Ok(fragment) diff --git a/rust/lance/src/dataset/optimize/tests/binary_copy.rs b/rust/lance/src/dataset/optimize/tests/binary_copy.rs index 1081384ee36..f7041bcf38a 100644 --- a/rust/lance/src/dataset/optimize/tests/binary_copy.rs +++ b/rust/lance/src/dataset/optimize/tests/binary_copy.rs @@ -566,11 +566,13 @@ async fn test_can_use_binary_copy_schema_mismatch() { .collect(); // Introduce a column index mismatch in the first data file if let Some(df) = frags.get_mut(0).and_then(|f| f.files.get_mut(0)) { - if let Some(first) = df.column_indices.get_mut(0) { + let mut indices = df.column_indices.to_vec(); + if let Some(first) = indices.get_mut(0) { *first = -*first - 1; } else { - df.column_indices.push(-1); + indices.push(-1); } + df.column_indices = indices.into(); } assert!(!can_use_binary_copy(&dataset, &options, &frags).await); diff --git a/rust/lance/src/dataset/tests/dataset_merge_update.rs b/rust/lance/src/dataset/tests/dataset_merge_update.rs index d5fbd30ae93..371e8769c8a 100644 --- a/rust/lance/src/dataset/tests/dataset_merge_update.rs +++ b/rust/lance/src/dataset/tests/dataset_merge_update.rs @@ -376,7 +376,7 @@ async fn test_insert_subschema() { let fragments = dataset.get_fragments(); assert_eq!(fragments.len(), 1); assert_eq!(fragments[0].metadata.files.len(), 1); - assert_eq!(&fragments[0].metadata.files[0].fields, &[0]); + assert_eq!(fragments[0].metadata.files[0].fields.as_ref(), &[0]); // When reading back, columns that are missing are null let data = dataset.scan().try_into_batch().await.unwrap(); @@ -424,7 +424,7 @@ async fn test_insert_subschema() { let fragments = dataset.get_fragments(); assert_eq!(fragments.len(), 1); assert_eq!(fragments[0].metadata.files.len(), 1); - assert_eq!(&fragments[0].metadata.files[0].fields, &[0, 1]); + assert_eq!(fragments[0].metadata.files[0].fields.as_ref(), &[0, 1]); // Can scan and get expected data. let data = dataset.scan().try_into_batch().await.unwrap(); @@ -480,8 +480,11 @@ async fn test_insert_nested_subschemas() { let fragments = dataset.get_fragments(); assert_eq!(fragments.len(), 1); assert_eq!(fragments[0].metadata.files.len(), 1); - assert_eq!(&fragments[0].metadata.files[0].fields, &[2, 1]); - assert_eq!(&fragments[0].metadata.files[0].column_indices, &[0, 1]); + assert_eq!(fragments[0].metadata.files[0].fields.as_ref(), &[2, 1]); + assert_eq!( + fragments[0].metadata.files[0].column_indices.as_ref(), + &[0, 1] + ); // Can insert c, b let just_c_b = Arc::new(ArrowSchema::new(vec![ArrowField::new( @@ -508,8 +511,11 @@ async fn test_insert_nested_subschemas() { let fragments = dataset.get_fragments(); assert_eq!(fragments.len(), 2); assert_eq!(fragments[1].metadata.files.len(), 1); - assert_eq!(&fragments[1].metadata.files[0].fields, &[3, 2]); - assert_eq!(&fragments[1].metadata.files[0].column_indices, &[0, 1]); + assert_eq!(fragments[1].metadata.files[0].fields.as_ref(), &[3, 2]); + assert_eq!( + fragments[1].metadata.files[0].column_indices.as_ref(), + &[0, 1] + ); // Can't insert a, c (b is non-nullable) let just_a_c = Arc::new(ArrowSchema::new(vec![ArrowField::new( @@ -612,7 +618,7 @@ async fn test_insert_balanced_subschemas() { let fragments = dataset.get_fragments(); assert_eq!(fragments.len(), 1); assert_eq!(fragments[0].metadata.files.len(), 1); - assert_eq!(&fragments[0].metadata.files[0].fields, &[0]); + assert_eq!(fragments[0].metadata.files[0].fields.as_ref(), &[0]); // Insert right side let just_b = Arc::new(ArrowSchema::new(vec![field_b.clone()])); @@ -628,7 +634,7 @@ async fn test_insert_balanced_subschemas() { let fragments = dataset.get_fragments(); assert_eq!(fragments.len(), 2); assert_eq!(fragments[1].metadata.files.len(), 1); - assert_eq!(&fragments[1].metadata.files[0].fields, &[1]); + assert_eq!(fragments[1].metadata.files[0].fields.as_ref(), &[1]); let data = dataset .take( @@ -860,9 +866,9 @@ async fn test_datafile_partial_replacement() { let new_data_file = DataFile { path: "test.lance".to_string(), // the second column in the dataset - fields: vec![1], + fields: Arc::from([1]), // is located in the first column of this datafile - column_indices: vec![0], + column_indices: Arc::from([0]), file_major_version: major, file_minor_version: minor, file_size_bytes: CachedFileSize::unknown(), @@ -886,8 +892,14 @@ async fn test_datafile_partial_replacement() { assert_eq!(dataset.version().version, 4); assert_eq!(dataset.get_fragments().len(), 1); assert_eq!(dataset.get_fragments()[0].metadata.files.len(), 2); - assert_eq!(dataset.get_fragments()[0].metadata.files[0].fields, vec![0]); - assert_eq!(dataset.get_fragments()[0].metadata.files[1].fields, vec![1]); + assert_eq!( + dataset.get_fragments()[0].metadata.files[0].fields.as_ref(), + &[0] + ); + assert_eq!( + dataset.get_fragments()[0].metadata.files[1].fields.as_ref(), + &[1] + ); let batch = dataset.scan().try_into_batch().await.unwrap(); assert_eq!(batch.num_rows(), 3); @@ -915,9 +927,9 @@ async fn test_datafile_partial_replacement() { let new_data_file = DataFile { path: "test.lance".to_string(), // the first column in the dataset - fields: vec![0], + fields: Arc::from([0]), // is located in the first column of this datafile - column_indices: vec![0], + column_indices: Arc::from([0]), file_major_version: major, file_minor_version: minor, file_size_bytes: CachedFileSize::unknown(), @@ -1014,9 +1026,9 @@ async fn test_datafile_replacement_error() { let new_data_file = DataFile { path: "test.lance".to_string(), // the second column in the dataset - fields: vec![1], + fields: Arc::from([1]), // is located in the first column of this datafile - column_indices: vec![0], + column_indices: Arc::from([0]), file_major_version: 2, file_minor_version: 0, file_size_bytes: CachedFileSize::unknown(), @@ -1024,7 +1036,7 @@ async fn test_datafile_replacement_error() { }; let new_data_file = DataFile { - fields: vec![0, 1], + fields: Arc::from([0, 1]), ..new_data_file }; diff --git a/rust/lance/src/dataset/transaction.rs b/rust/lance/src/dataset/transaction.rs index b5f2e05ff0d..62f30696234 100644 --- a/rust/lance/src/dataset/transaction.rs +++ b/rust/lance/src/dataset/transaction.rs @@ -2067,8 +2067,8 @@ impl Transaction { if columns_covered.is_disjoint(&new_file.fields.iter().collect()) { new_frag.add_file( new_file.path.clone(), - new_file.fields.clone(), - new_file.column_indices.clone(), + new_file.fields.to_vec(), + new_file.column_indices.to_vec(), &LanceFileVersion::try_from_major_minor( new_file.file_major_version, new_file.file_minor_version, @@ -3730,8 +3730,8 @@ mod tests { // Add a normal data file with valid field IDs fragment.files.push(DataFile { path: "normal.lance".to_string(), - fields: vec![1, 2, 3], - column_indices: vec![], + fields: Arc::from([1, 2, 3]), + column_indices: Arc::from([]), file_major_version: 2, file_minor_version: 0, file_size_bytes: CachedFileSize::new(1000), @@ -3741,8 +3741,8 @@ mod tests { // Add a data file with all fields tombstoned fragment.files.push(DataFile { path: "all_tombstoned.lance".to_string(), - fields: vec![-2, -2, -2], - column_indices: vec![], + fields: Arc::from([-2, -2, -2]), + column_indices: Arc::from([]), file_major_version: 2, file_minor_version: 0, file_size_bytes: CachedFileSize::new(500), @@ -3752,8 +3752,8 @@ mod tests { // Add a data file with mixed tombstoned and valid fields fragment.files.push(DataFile { path: "mixed.lance".to_string(), - fields: vec![4, -2, 5], - column_indices: vec![], + fields: Arc::from([4, -2, 5]), + column_indices: Arc::from([]), file_major_version: 2, file_minor_version: 0, file_size_bytes: CachedFileSize::new(750), @@ -3763,8 +3763,8 @@ mod tests { // Add another fully tombstoned file fragment.files.push(DataFile { path: "another_tombstoned.lance".to_string(), - fields: vec![-2], - column_indices: vec![], + fields: Arc::from([-2_i32]), + column_indices: Arc::from([]), file_major_version: 2, file_minor_version: 0, file_size_bytes: CachedFileSize::new(250), diff --git a/rust/lance/src/dataset/write.rs b/rust/lance/src/dataset/write.rs index bcbe9aa120c..206bbd3320c 100644 --- a/rust/lance/src/dataset/write.rs +++ b/rust/lance/src/dataset/write.rs @@ -1761,7 +1761,7 @@ mod tests { assert_eq!(fragments.len(), 1); let fragment = &fragments[0]; assert_eq!(fragment.files.len(), 1); - assert_eq!(fragment.files[0].fields, vec![0, 1, 3]); + assert_eq!(fragment.files[0].fields.as_ref(), &[0, 1, 3]); let path = base_path .child(DATA_DIR) diff --git a/rust/lance/src/dataset/write/commit.rs b/rust/lance/src/dataset/write/commit.rs index ddf1b769425..f51833e5861 100644 --- a/rust/lance/src/dataset/write/commit.rs +++ b/rust/lance/src/dataset/write/commit.rs @@ -486,8 +486,8 @@ mod tests { id: 0, files: vec![DataFile { path: "file.lance".to_string(), - fields: vec![0], - column_indices: vec![0], + fields: Arc::from([0]), + column_indices: Arc::from([0]), file_major_version: major_version, file_minor_version: minor_version, file_size_bytes: CachedFileSize::new(100), diff --git a/rust/lance/src/dataset/write/merge_insert.rs b/rust/lance/src/dataset/write/merge_insert.rs index b252c3123a4..8ea25c7285f 100644 --- a/rust/lance/src/dataset/write/merge_insert.rs +++ b/rust/lance/src/dataset/write/merge_insert.rs @@ -1264,12 +1264,19 @@ impl MergeInsertJob { let updated_fields = fragment.files.last().unwrap().fields.clone(); all_fields_updated.extend(updated_fields.iter().map(|&f| f as u32)); for data_file in &mut fragment.files.iter_mut().rev().skip(1) { - for field in &mut data_file.fields { - if updated_fields.contains(field) { - // Tombstone these fields - *field = -2; - } - } + let new_fields: Arc<[i32]> = data_file + .fields + .iter() + .map(|field| { + if updated_fields.contains(field) { + -2 // Tombstone + } else { + *field + } + }) + .collect::>() + .into(); + data_file.fields = new_fields; } } @@ -3618,8 +3625,8 @@ mod tests { let data_files = &frag.files; // Updated columns should be only columns in new data files // -2 field ids are tombstoned. - assert_eq!(&data_files[0].fields, &[0, -2, -2]); - assert_eq!(&data_files[1].fields, &[2, 1]); + assert_eq!(data_files[0].fields.as_ref(), &[0, -2, -2]); + assert_eq!(data_files[1].fields.as_ref(), &[2, 1]); }; has_added_files(&fragments_after[1]); has_added_files(&fragments_after[2]); diff --git a/rust/lance/src/io/commit.rs b/rust/lance/src/io/commit.rs index fa59454d973..9e8db7caf0f 100644 --- a/rust/lance/src/io/commit.rs +++ b/rust/lance/src/io/commit.rs @@ -492,17 +492,22 @@ fn fix_schema(manifest: &mut Manifest) -> Result<()> { // We iterate over files in reverse order so that we only map the last field id seen_fields.clear(); for fragment in fragments.iter_mut() { - for field_id in fragment - .files - .iter_mut() - .rev() - .flat_map(|file| file.fields.iter_mut()) - { - if let Some(new_field_id) = old_field_id_mapping.get(field_id) - && seen_fields.insert(*field_id) - { - *field_id = *new_field_id; - } + for file in fragment.files.iter_mut().rev() { + let new_fields: Arc<[i32]> = file + .fields + .iter() + .map(|field_id| { + if let Some(new_field_id) = old_field_id_mapping.get(field_id) + && seen_fields.insert(*field_id) + { + *new_field_id + } else { + *field_id + } + }) + .collect::>() + .into(); + file.fields = new_fields; } seen_fields.clear(); } diff --git a/rust/lance/src/io/commit/conflict_resolver.rs b/rust/lance/src/io/commit/conflict_resolver.rs index 2c496a77a74..ec03ba596db 100644 --- a/rust/lance/src/io/commit/conflict_resolver.rs +++ b/rust/lance/src/io/commit/conflict_resolver.rs @@ -607,7 +607,7 @@ impl<'a> TransactionRebase<'a> { .flat_map(|idx| idx.fields.iter()) .collect::>(); for replacement in replacements { - for field in &replacement.1.fields { + for field in replacement.1.fields.iter() { if newly_indexed_fields.contains(&field) { return Err( self.retryable_conflict_err(other_transaction, other_version) @@ -898,7 +898,7 @@ impl<'a> TransactionRebase<'a> { .flat_map(|idx| idx.fields.iter()) .collect::>(); for replacement in replacements { - for field in &replacement.1.fields { + for field in replacement.1.fields.iter() { if newly_indexed_fields.contains(&field) { return Err( self.retryable_conflict_err(other_transaction, other_version) @@ -933,7 +933,7 @@ impl<'a> TransactionRebase<'a> { continue; } - for field in &replacement.1.fields { + for field in replacement.1.fields.iter() { if other_replacement.1.fields.contains(field) { return Err(self .retryable_conflict_err(other_transaction, other_version)); diff --git a/rust/lance/src/utils/test.rs b/rust/lance/src/utils/test.rs index ec38e05cd35..ae6d2a67d4b 100644 --- a/rust/lance/src/utils/test.rs +++ b/rust/lance/src/utils/test.rs @@ -262,7 +262,7 @@ fn field_structure(fragment: &Fragment) -> Vec> { fragment .files .iter() - .map(|file| file.fields.clone()) + .map(|file| file.fields.to_vec()) .collect::>() } From 23526f64913e4298414a3566ce478e0bf54f73d9 Mon Sep 17 00:00:00 2001 From: Beinan Date: Mon, 13 Apr 2026 20:45:20 -0700 Subject: [PATCH 181/206] perf: intern RowDatasetVersionMeta inline bytes to reduce manifest memory (#6499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Change `RowDatasetVersionMeta::Inline` from `Vec` to `Arc<[u8]>` so that fragments with identical version metadata share a single heap allocation - Extend `DataFileFieldInterner` to deduplicate these inline byte payloads during manifest deserialization - Introduce `InternCache`: a hybrid cache that uses Vec linear scan for ≤16 entries and upgrades to HashMap for larger caches - Add custom `Serialize`/`Deserialize` impls for `RowDatasetVersionMeta` to handle `Arc<[u8]>` transparently ## Motivation Follow-up to #6477 (interning `DataFile.fields`/`column_indices`). After a compaction, all fragments are stamped with the same version metadata (both `last_updated_at_version_meta` and `created_at_version_meta`), but each fragment previously owned its own `Vec` copy. ### Per-fragment memory breakdown (before) | Field | Size per fragment | |-------|------------------| | `last_updated_at_version_meta: Inline(Vec)` | ~24 bytes + payload | | `created_at_version_meta: Inline(Vec)` | ~24 bytes + payload | | **Total redundant at 20M fragments** | **~480 MB+** | ### After this change With interning, all 20M fragments share a single `Arc<[u8]>` allocation per unique payload. ## Benchmark results Microbenchmark at 100K fragments (10 fields per fragment): | Scenario | No interning | With interning | Delta | |----------|-------------|----------------|-------| | **Uniform (1 unique version)** | 24.5 ms | 17.9 ms | **27% faster** | | **Diverse (10 unique)** | 25.7 ms | 19.7 ms | **23% faster** | | **Diverse (100 unique)** | 26.0 ms | 23.4 ms | **10% faster** | | **Diverse (500 unique)** | 26.0 ms | 22.8 ms | **12% faster** | | Memory (100K fragments) | No interning | With interning | Savings | |------------------------|-------------|----------------|---------| | **10 fields** | 39.47 MB | 29.74 MB | **24.6%** | | **50 fields** | 69.99 MB | 29.74 MB | **57.5%** | Both memory and speed improve across all scenarios. The hybrid `InternCache` uses fast Vec scan for the common case (1-3 unique values) and upgrades to HashMap when diversity exceeds 16 entries. Run with: `cargo bench -p lance-table --bench manifest_intern` ## Changes - **`rust/lance-table/src/rowids/version.rs`** — `Inline(Vec)` → `Inline(Arc<[u8]>)`, custom serde impls, updated protobuf conversions - **`rust/lance-table/src/format/fragment.rs`** — `InternCache` (Vec/HashMap hybrid), extended `DataFileFieldInterner` with version meta interning - **`rust/lance-table/benches/manifest_intern.rs`** — Microbenchmark covering uniform and diverse scenarios ## Compatibility - No format change — protobuf schema is unchanged - Serde JSON output is identical (custom impl serializes `Arc<[u8]>` as `[u8]`) - `from_sequence()` still works as before (converts internally) ## Test plan - [x] `cargo check --workspace --tests` passes - [x] `cargo clippy -p lance-table -p lance -- -D warnings` passes - [x] All 88 `lance-table` tests pass - [x] `cargo fmt --all -- --check` passes - [x] Microbenchmark validates performance across uniform and diverse scenarios - [ ] CI 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-table/Cargo.toml | 4 + rust/lance-table/benches/manifest_intern.rs | 261 ++++++++++++++++++++ rust/lance-table/src/format/fragment.rs | 134 ++++++++-- rust/lance-table/src/rowids/version.rs | 58 ++++- 4 files changed, 427 insertions(+), 30 deletions(-) create mode 100644 rust/lance-table/benches/manifest_intern.rs diff --git a/rust/lance-table/Cargo.toml b/rust/lance-table/Cargo.toml index 8f44e75f364..c9cfc828f70 100644 --- a/rust/lance-table/Cargo.toml +++ b/rust/lance-table/Cargo.toml @@ -72,5 +72,9 @@ features = ["protoc"] name = "row_id_index" harness = false +[[bench]] +name = "manifest_intern" +harness = false + [lints] workspace = true diff --git a/rust/lance-table/benches/manifest_intern.rs b/rust/lance-table/benches/manifest_intern.rs new file mode 100644 index 00000000000..09ba9264b21 --- /dev/null +++ b/rust/lance-table/benches/manifest_intern.rs @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +// Benchmarks use eprintln! to report memory stats alongside criterion output. +#![allow(clippy::print_stderr)] + +//! Benchmark for manifest fragment interning. +//! +//! Measures memory savings and deserialization throughput when interning +//! `DataFile.fields`, `DataFile.column_indices`, and +//! `RowDatasetVersionMeta::Inline` bytes across many fragments. + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use deepsize::DeepSizeOf; +use prost::Message; + +use lance_table::format::pb; +use lance_table::format::{DataFileFieldInterner, Fragment}; + +fn num_fragments() -> u64 { + std::env::var("BENCH_NUM_FRAGMENTS") + .map(|s| s.parse().unwrap()) + .unwrap_or(100_000) +} + +/// Build a vector of protobuf DataFragment messages that simulate a +/// homogeneous, post-compaction table: every fragment has the same field +/// list, column indices, and version metadata bytes. +fn make_uniform_pb_fragments(n: u64, num_fields: usize) -> Vec { + let fields: Vec = (0..num_fields as i32).collect(); + let column_indices: Vec = (0..num_fields as i32).collect(); + + // Simulate version metadata: a small protobuf-encoded payload + // (identical across all fragments post-compaction) + let version_bytes: Vec = { + let seq = pb::RowDatasetVersionSequence { + runs: vec![pb::RowDatasetVersionRun { + span: Some(pb::U64Segment { + segment: Some(pb::u64_segment::Segment::Range(pb::u64_segment::Range { + start: 0, + end: 1000, + })), + }), + version: 42, + }], + }; + seq.encode_to_vec() + }; + + (0..n) + .map(|i| pb::DataFragment { + id: i, + files: vec![pb::DataFile { + path: format!("data/{i}.lance"), + fields: fields.clone(), + column_indices: column_indices.clone(), + file_major_version: 2, + file_minor_version: 0, + file_size_bytes: 0, + base_id: None, + }], + deletion_file: None, + row_id_sequence: None, + physical_rows: 1000, + last_updated_at_version_sequence: Some( + pb::data_fragment::LastUpdatedAtVersionSequence::InlineLastUpdatedAtVersions( + version_bytes.clone(), + ), + ), + created_at_version_sequence: Some( + pb::data_fragment::CreatedAtVersionSequence::InlineCreatedAtVersions( + version_bytes.clone(), + ), + ), + }) + .collect() +} + +/// Deserialize protobuf fragments WITHOUT interning (baseline). +fn deserialize_without_interning(protos: &[pb::DataFragment]) -> Vec { + protos + .iter() + .map(|p| Fragment::try_from(p.clone()).unwrap()) + .collect() +} + +/// Deserialize protobuf fragments WITH interning. +fn deserialize_with_interning(protos: &[pb::DataFragment]) -> Vec { + let mut interner = DataFileFieldInterner::default(); + protos + .iter() + .map(|p| interner.intern_fragment(p.clone()).unwrap()) + .collect() +} + +/// Build fragments where each group shares the same version metadata, +/// simulating many small appends without compaction. +fn make_diverse_pb_fragments( + n: u64, + num_fields: usize, + unique_versions: u64, +) -> Vec { + let fields: Vec = (0..num_fields as i32).collect(); + let column_indices: Vec = (0..num_fields as i32).collect(); + let group_size = n / unique_versions; + + let version_payloads: Vec> = (0..unique_versions) + .map(|v| { + let seq = pb::RowDatasetVersionSequence { + runs: vec![pb::RowDatasetVersionRun { + span: Some(pb::U64Segment { + segment: Some(pb::u64_segment::Segment::Range(pb::u64_segment::Range { + start: 0, + end: 1000, + })), + }), + version: v, + }], + }; + seq.encode_to_vec() + }) + .collect(); + + (0..n) + .map(|i| { + let version_idx = (i / group_size).min(unique_versions - 1) as usize; + pb::DataFragment { + id: i, + files: vec![pb::DataFile { + path: format!("data/{i}.lance"), + fields: fields.clone(), + column_indices: column_indices.clone(), + file_major_version: 2, + file_minor_version: 0, + file_size_bytes: 0, + base_id: None, + }], + deletion_file: None, + row_id_sequence: None, + physical_rows: 1000, + last_updated_at_version_sequence: Some( + pb::data_fragment::LastUpdatedAtVersionSequence::InlineLastUpdatedAtVersions( + version_payloads[version_idx].clone(), + ), + ), + created_at_version_sequence: Some( + pb::data_fragment::CreatedAtVersionSequence::InlineCreatedAtVersions( + version_payloads[version_idx].clone(), + ), + ), + } + }) + .collect() +} + +fn bench_deserialization(c: &mut Criterion) { + let mut group = c.benchmark_group("manifest_intern"); + let n = num_fragments(); + + for num_fields in [10, 50] { + let protos = make_uniform_pb_fragments(n, num_fields); + + group.bench_with_input( + BenchmarkId::new("deserialize_no_intern", num_fields), + &num_fields, + |b, _| { + b.iter(|| deserialize_without_interning(&protos)); + }, + ); + + group.bench_with_input( + BenchmarkId::new("deserialize_with_intern", num_fields), + &num_fields, + |b, _| { + b.iter(|| deserialize_with_interning(&protos)); + }, + ); + } + + // Benchmark with many unique version payloads + for unique_versions in [10, 100, 500] { + let protos = make_diverse_pb_fragments(n, 10, unique_versions); + + group.bench_with_input( + BenchmarkId::new("deserialize_no_intern_diverse", unique_versions), + &unique_versions, + |b, _| { + b.iter(|| deserialize_without_interning(&protos)); + }, + ); + + group.bench_with_input( + BenchmarkId::new("deserialize_with_intern_diverse", unique_versions), + &unique_versions, + |b, _| { + b.iter(|| deserialize_with_interning(&protos)); + }, + ); + } + + group.finish(); +} + +fn bench_memory(c: &mut Criterion) { + let mut group = c.benchmark_group("manifest_memory"); + let n = num_fragments(); + + for num_fields in [10, 50] { + let protos = make_uniform_pb_fragments(n, num_fields); + + let no_intern = deserialize_without_interning(&protos); + let with_intern = deserialize_with_interning(&protos); + + let size_no_intern = no_intern.deep_size_of(); + let size_with_intern = with_intern.deep_size_of(); + + eprintln!( + "\n[{} fragments, {} fields] Memory without interning: {:.2} MB", + n, + num_fields, + size_no_intern as f64 / 1_048_576.0 + ); + eprintln!( + "[{} fragments, {} fields] Memory with interning: {:.2} MB", + n, + num_fields, + size_with_intern as f64 / 1_048_576.0 + ); + eprintln!( + "[{} fragments, {} fields] Savings: {:.2} MB ({:.1}%)", + n, + num_fields, + (size_no_intern - size_with_intern) as f64 / 1_048_576.0, + (1.0 - size_with_intern as f64 / size_no_intern as f64) * 100.0 + ); + + // Benchmark deep_size_of measurement itself (sanity check) + group.bench_with_input( + BenchmarkId::new("deep_size_of_interned", num_fields), + &num_fields, + |b, _| { + b.iter(|| with_intern.deep_size_of()); + }, + ); + + drop(no_intern); + drop(with_intern); + } + + group.finish(); +} + +#[cfg(target_os = "linux")] +criterion_group!( + name = benches; + config = Criterion::default().with_profiler(pprof::criterion::PProfProfiler::new(100, pprof::criterion::Output::Flamegraph(None))); + targets = bench_deserialization, bench_memory +); +#[cfg(not(target_os = "linux"))] +criterion_group!(benches, bench_deserialization, bench_memory); +criterion_main!(benches); diff --git a/rust/lance-table/src/format/fragment.rs b/rust/lance-table/src/format/fragment.rs index 621198e329e..e1643b0712e 100644 --- a/rust/lance-table/src/format/fragment.rs +++ b/rust/lance-table/src/format/fragment.rs @@ -233,33 +233,119 @@ impl TryFrom for DataFile { } } -/// Interns `fields` and `column_indices` slices so that `DataFile` instances -/// with identical content share a single `Arc<[i32]>` allocation. +/// Interns repeated data so that fragments with identical content share a +/// single heap allocation via `Arc`. /// /// At 20M fragments the deduplication typically saves multiple GB of heap -/// because every fragment in a homogeneous table carries the same field list. +/// because every fragment in a homogeneous table carries the same field list, +/// and post-compaction fragments share identical version metadata bytes. +/// +/// Uses a `Vec`-based linear scan when the cache is small (<=16 entries) +/// and upgrades to `HashMap` for larger caches. In the common homogeneous +/// case (1-3 unique values), linear scan avoids per-fragment hashing overhead. #[derive(Default)] pub struct DataFileFieldInterner { - fields: HashMap, Arc<[i32]>>, - column_indices: HashMap, Arc<[i32]>>, + fields: InternCache, + column_indices: InternCache, + inline_bytes: InternCache, +} + +/// A cache that uses linear scan for small sizes and HashMap for large. +/// The threshold is chosen so that scan + compare is cheaper than hash for +/// typical payload sizes (20-200 bytes). +enum InternCache { + Small(Vec>), + Large(HashMap, ()>), +} + +const INTERN_CACHE_UPGRADE_THRESHOLD: usize = 16; + +impl Default for InternCache { + fn default() -> Self { + Self::Small(Vec::new()) + } +} + +impl InternCache { + fn intern(&mut self, v: Vec) -> Arc<[T]> { + match self { + Self::Small(entries) => { + for existing in entries.iter() { + if existing.as_ref() == v.as_slice() { + return existing.clone(); + } + } + let arc: Arc<[T]> = Arc::from(v); + entries.push(arc.clone()); + if entries.len() > INTERN_CACHE_UPGRADE_THRESHOLD { + let mut map = HashMap::with_capacity(entries.len()); + for e in entries.drain(..) { + map.insert(e, ()); + } + *self = Self::Large(map); + } + arc + } + Self::Large(map) => { + if let Some((existing, _)) = map.get_key_value(v.as_slice()) { + existing.clone() + } else { + let arc: Arc<[T]> = Arc::from(v); + map.insert(arc.clone(), ()); + arc + } + } + } + } } impl DataFileFieldInterner { - /// Intern a `Vec`: if the same content was seen before, return a - /// clone of the existing `Arc`; otherwise store and return a new one. - fn intern(cache: &mut HashMap, Arc<[i32]>>, v: Vec) -> Arc<[i32]> { - cache - .entry(v) - .or_insert_with_key(|k| Arc::from(k.as_slice())) - .clone() + /// Intern a `RowDatasetVersionMeta`, deduplicating inline byte payloads. + /// Accepts the protobuf oneof value directly to avoid an intermediate + /// `Arc<[u8]>` allocation that would need to be `.to_vec()`'d for the key lookup. + fn intern_last_updated_version_meta( + cache: &mut InternCache, + pb: pb::data_fragment::LastUpdatedAtVersionSequence, + ) -> Result { + match pb { + pb::data_fragment::LastUpdatedAtVersionSequence::InlineLastUpdatedAtVersions(data) => { + Ok(RowDatasetVersionMeta::Inline(cache.intern(data))) + } + pb::data_fragment::LastUpdatedAtVersionSequence::ExternalLastUpdatedAtVersions( + file, + ) => Ok(RowDatasetVersionMeta::External(ExternalFile { + path: file.path, + offset: file.offset, + size: file.size, + })), + } + } + + /// Intern a `RowDatasetVersionMeta`, deduplicating inline byte payloads. + fn intern_created_version_meta( + cache: &mut InternCache, + pb: pb::data_fragment::CreatedAtVersionSequence, + ) -> Result { + match pb { + pb::data_fragment::CreatedAtVersionSequence::InlineCreatedAtVersions(data) => { + Ok(RowDatasetVersionMeta::Inline(cache.intern(data))) + } + pb::data_fragment::CreatedAtVersionSequence::ExternalCreatedAtVersions(file) => { + Ok(RowDatasetVersionMeta::External(ExternalFile { + path: file.path, + offset: file.offset, + size: file.size, + })) + } + } } /// Convert a protobuf `DataFile`, interning `fields` and `column_indices`. pub fn intern_data_file(&mut self, proto: pb::DataFile) -> Result { Ok(DataFile { path: proto.path, - fields: Self::intern(&mut self.fields, proto.fields), - column_indices: Self::intern(&mut self.column_indices, proto.column_indices), + fields: self.fields.intern(proto.fields), + column_indices: self.column_indices.intern(proto.column_indices), file_major_version: proto.file_major_version, file_minor_version: proto.file_minor_version, file_size_bytes: CachedFileSize::new(proto.file_size_bytes), @@ -267,13 +353,21 @@ impl DataFileFieldInterner { }) } - /// Convert a protobuf `DataFragment`, interning fields within its data files. + /// Convert a protobuf `DataFragment`, interning fields and version metadata. pub fn intern_fragment(&mut self, p: pb::DataFragment) -> Result { let physical_rows = if p.physical_rows > 0 { Some(p.physical_rows as usize) } else { None }; + let last_updated_at_version_meta = p + .last_updated_at_version_sequence + .map(|pb| Self::intern_last_updated_version_meta(&mut self.inline_bytes, pb)) + .transpose()?; + let created_at_version_meta = p + .created_at_version_sequence + .map(|pb| Self::intern_created_version_meta(&mut self.inline_bytes, pb)) + .transpose()?; Ok(Fragment { id: p.id, files: p @@ -284,14 +378,8 @@ impl DataFileFieldInterner { deletion_file: p.deletion_file.map(DeletionFile::try_from).transpose()?, row_id_meta: p.row_id_sequence.map(RowIdMeta::try_from).transpose()?, physical_rows, - last_updated_at_version_meta: p - .last_updated_at_version_sequence - .map(RowDatasetVersionMeta::try_from) - .transpose()?, - created_at_version_meta: p - .created_at_version_sequence - .map(RowDatasetVersionMeta::try_from) - .transpose()?, + last_updated_at_version_meta, + created_at_version_meta, }) } } diff --git a/rust/lance-table/src/rowids/version.rs b/rust/lance-table/src/rowids/version.rs index f1c528c91ef..80f3d06db60 100644 --- a/rust/lance-table/src/rowids/version.rs +++ b/rust/lance-table/src/rowids/version.rs @@ -7,10 +7,14 @@ //! update version for each row in a Lance dataset, enabling efficient //! cross-version diff operations. +use std::sync::Arc; + use deepsize::DeepSizeOf; use lance_core::Error; use lance_core::Result; use prost::Message; +use serde::de::Deserializer; +use serde::ser::Serializer; use serde::{Deserialize, Serialize}; use crate::format::{ExternalFile, Fragment, pb}; @@ -210,19 +214,59 @@ impl<'a> Iterator for VersionsIter<'a> { /// Metadata about the location of dataset version sequence data /// Following the same pattern as RowIdMeta -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeepSizeOf)] +/// +/// When stored inline, identical byte sequences are shared across fragments +/// via `Arc<[u8]>` to reduce manifest memory for large tables. +#[derive(Debug, Clone, PartialEq, Eq, DeepSizeOf)] pub enum RowDatasetVersionMeta { /// Small sequences stored inline in the fragment metadata - Inline(Vec), + Inline(Arc<[u8]>), /// Large sequences stored in external files External(ExternalFile), } +// Custom Serialize: convert Arc<[u8]> to slice for transparent JSON output +impl Serialize for RowDatasetVersionMeta { + fn serialize(&self, serializer: S) -> std::result::Result { + #[derive(Serialize)] + #[serde(untagged)] + enum Helper<'a> { + Inline { inline: &'a [u8] }, + External { external: &'a ExternalFile }, + } + + match self { + Self::Inline(data) => Helper::Inline { + inline: data.as_ref(), + } + .serialize(serializer), + Self::External(file) => Helper::External { external: file }.serialize(serializer), + } + } +} + +// Custom Deserialize: read Vec and convert to Arc<[u8]> +impl<'de> Deserialize<'de> for RowDatasetVersionMeta { + fn deserialize>(deserializer: D) -> std::result::Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Helper { + Inline { inline: Vec }, + External { external: ExternalFile }, + } + + match Helper::deserialize(deserializer)? { + Helper::Inline { inline } => Ok(Self::Inline(Arc::from(inline))), + Helper::External { external } => Ok(Self::External(external)), + } + } +} + impl RowDatasetVersionMeta { /// Create inline metadata from a version sequence pub fn from_sequence(sequence: &RowDatasetVersionSequence) -> lance_core::Result { let bytes = write_dataset_versions(sequence); - Ok(Self::Inline(bytes)) + Ok(Self::Inline(Arc::from(bytes))) } /// Create external metadata reference @@ -248,7 +292,7 @@ pub fn last_updated_at_version_meta_to_pb( meta.as_ref().map(|m| match m { RowDatasetVersionMeta::Inline(data) => { pb::data_fragment::LastUpdatedAtVersionSequence::InlineLastUpdatedAtVersions( - data.clone(), + data.to_vec(), ) } RowDatasetVersionMeta::External(file) => { @@ -269,7 +313,7 @@ pub fn created_at_version_meta_to_pb( ) -> Option { meta.as_ref().map(|m| match m { RowDatasetVersionMeta::Inline(data) => { - pb::data_fragment::CreatedAtVersionSequence::InlineCreatedAtVersions(data.clone()) + pb::data_fragment::CreatedAtVersionSequence::InlineCreatedAtVersions(data.to_vec()) } RowDatasetVersionMeta::External(file) => { pb::data_fragment::CreatedAtVersionSequence::ExternalCreatedAtVersions( @@ -561,7 +605,7 @@ impl TryFrom for RowDatasetVers fn try_from(value: pb::data_fragment::LastUpdatedAtVersionSequence) -> Result { match value { pb::data_fragment::LastUpdatedAtVersionSequence::InlineLastUpdatedAtVersions(data) => { - Ok(Self::Inline(data)) + Ok(Self::Inline(Arc::from(data))) } pb::data_fragment::LastUpdatedAtVersionSequence::ExternalLastUpdatedAtVersions( file, @@ -580,7 +624,7 @@ impl TryFrom for RowDatasetVersionM fn try_from(value: pb::data_fragment::CreatedAtVersionSequence) -> Result { match value { pb::data_fragment::CreatedAtVersionSequence::InlineCreatedAtVersions(data) => { - Ok(Self::Inline(data)) + Ok(Self::Inline(Arc::from(data))) } pb::data_fragment::CreatedAtVersionSequence::ExternalCreatedAtVersions(file) => { Ok(Self::External(ExternalFile { From d37348d7b9249ffab3c2e0cad55ff4bce16d9a71 Mon Sep 17 00:00:00 2001 From: YueZhang <69956021+zhangyue19921010@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:59:56 +0800 Subject: [PATCH 182/206] feat(dictionary-namespace): support table related operation (#6308) - `list_all_tables` - `restore_table` - `update_table_schema_metadata` - `get_table_stats` - `explain_table_query_plan` - `analyze_table_query_plan` --------- Co-authored-by: zhangyue19921010 --- rust/lance-namespace-impls/src/dir.rs | 623 +++++++++++++++++++++++++- 1 file changed, 611 insertions(+), 12 deletions(-) diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index 734cbc64c2b..afd9267dffc 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -15,6 +15,8 @@ use async_trait::async_trait; use bytes::Bytes; use futures::TryStreamExt; use lance::dataset::builder::DatasetBuilder; +use lance::dataset::scanner::Scanner; +use lance::dataset::statistics::DatasetStatisticsExt; use lance::dataset::transaction::{Operation, Transaction}; use lance::dataset::{Dataset, WriteMode, WriteParams}; use lance::index::{DatasetIndexExt, IndexParams, vector::VectorIndexParams}; @@ -38,23 +40,27 @@ use std::sync::{Arc, Mutex}; use crate::context::DynamicContextProvider; use lance_namespace::models::{ - BatchDeleteTableVersionsRequest, BatchDeleteTableVersionsResponse, CountTableRowsRequest, - CreateNamespaceRequest, CreateNamespaceResponse, CreateTableIndexRequest, - CreateTableIndexResponse, CreateTableRequest, CreateTableResponse, - CreateTableScalarIndexResponse, CreateTableVersionRequest, CreateTableVersionResponse, - DeclareTableRequest, DeclareTableResponse, DescribeNamespaceRequest, DescribeNamespaceResponse, - DescribeTableIndexStatsRequest, DescribeTableIndexStatsResponse, DescribeTableRequest, - DescribeTableResponse, DescribeTableVersionRequest, DescribeTableVersionResponse, - DescribeTransactionRequest, DescribeTransactionResponse, DropNamespaceRequest, - DropNamespaceResponse, DropTableIndexRequest, DropTableIndexResponse, DropTableRequest, - DropTableResponse, Identity, IndexContent, InsertIntoTableRequest, InsertIntoTableResponse, + AnalyzeTableQueryPlanRequest, BatchDeleteTableVersionsRequest, + BatchDeleteTableVersionsResponse, CountTableRowsRequest, CreateNamespaceRequest, + CreateNamespaceResponse, CreateTableIndexRequest, CreateTableIndexResponse, CreateTableRequest, + CreateTableResponse, CreateTableScalarIndexResponse, CreateTableVersionRequest, + CreateTableVersionResponse, DeclareTableRequest, DeclareTableResponse, + DescribeNamespaceRequest, DescribeNamespaceResponse, DescribeTableIndexStatsRequest, + DescribeTableIndexStatsResponse, DescribeTableRequest, DescribeTableResponse, + DescribeTableVersionRequest, DescribeTableVersionResponse, DescribeTransactionRequest, + DescribeTransactionResponse, DropNamespaceRequest, DropNamespaceResponse, + DropTableIndexRequest, DropTableIndexResponse, DropTableRequest, DropTableResponse, + ExplainTableQueryPlanRequest, FragmentStats, FragmentSummary, GetTableStatsRequest, + GetTableStatsResponse, Identity, IndexContent, InsertIntoTableRequest, InsertIntoTableResponse, ListNamespacesRequest, ListNamespacesResponse, ListTableIndicesRequest, ListTableIndicesResponse, ListTableVersionsRequest, ListTableVersionsResponse, ListTablesRequest, ListTablesResponse, NamespaceExistsRequest, QueryTableRequest, - TableExistsRequest, TableVersion, + QueryTableRequestColumns, QueryTableRequestVector, RestoreTableRequest, RestoreTableResponse, + TableExistsRequest, TableVersion, UpdateTableSchemaMetadataRequest, + UpdateTableSchemaMetadataResponse, }; -use lance_core::Result; +use lance_core::{Error, Result}; use lance_namespace::LanceNamespace; use lance_namespace::error::NamespaceError; use lance_namespace::schema::arrow_schema_to_json; @@ -1793,6 +1799,142 @@ impl DirectoryNamespace { Ok(deleted_count) } + /// Apply all query parameters from a `QueryTableRequest`-like source onto a `Scanner`. + /// + /// This covers vector search, filters, column projection, limits, and ANN tuning knobs so + /// that `explain_table_query_plan` / `analyze_table_query_plan` produce an accurate plan. + #[allow(clippy::too_many_arguments)] + fn apply_query_params_to_scanner( + scanner: &mut Scanner, + filter: Option<&str>, + columns: Option<&QueryTableRequestColumns>, + vector_column: Option<&str>, + vector: &QueryTableRequestVector, + k: i32, + offset: Option, + prefilter: Option, + bypass_vector_index: Option, + nprobes: Option, + ef: Option, + refine_factor: Option, + distance_type: Option<&str>, + fast_search_flag: Option, + with_row_id: Option, + lower_bound: Option, + upper_bound: Option, + operation: &str, + ) -> Result<()> { + // prefilter must be set before nearest() so the fragment-scan guard sees it. + if let Some(pf) = prefilter { + scanner.prefilter(pf); + } + + if let Some(filter) = filter { + scanner.filter(filter).map_err(|e| { + Error::invalid_input_source( + format!("Invalid filter expression for {}: {}", operation, e).into(), + ) + })?; + } + + if let Some(cols) = columns { + if let Some(ref names) = cols.column_names { + scanner.project(names.as_slice()).map_err(|e| { + Error::invalid_input_source( + format!("Invalid column projection for {}: {}", operation, e).into(), + ) + })?; + } else if let Some(ref aliases) = cols.column_aliases { + // aliases maps output_alias -> source_column + let pairs: Vec<(&str, &str)> = aliases + .iter() + .map(|(alias, src)| (alias.as_str(), src.as_str())) + .collect(); + scanner.project_with_transform(&pairs).map_err(|e| { + Error::invalid_input_source( + format!("Invalid column aliases for {}: {}", operation, e).into(), + ) + })?; + } + } + + // Resolve query vector: prefer single_vector, fall back to first row of multi_vector. + let query_vec: Option> = vector + .single_vector + .as_ref() + .filter(|v| !v.is_empty()) + .cloned() + .or_else(|| { + vector + .multi_vector + .as_ref() + .and_then(|mv| mv.first()) + .filter(|v| !v.is_empty()) + .cloned() + }); + + if let Some(q_vec) = query_vec { + let col = vector_column.unwrap_or("vector"); + let q = Arc::new(Float32Array::from(q_vec)); + scanner + .nearest(col, q.as_ref(), k.max(1) as usize) + .map_err(|e| { + Error::invalid_input_source( + format!("Invalid vector query for {}: {}", operation, e).into(), + ) + })?; + + // ANN parameters — must be applied after nearest(). + if let Some(n) = nprobes { + scanner.nprobes(n.max(1) as usize); + } + if let Some(e) = ef { + scanner.ef(e.max(1) as usize); + } + if let Some(rf) = refine_factor { + scanner.refine(rf.max(0) as u32); + } + // bypass_vector_index and fast_search are mutually exclusive; apply in order. + if let Some(true) = bypass_vector_index { + scanner.use_index(false); + } + if let Some(true) = fast_search_flag { + scanner.fast_search(); + } + if lower_bound.is_some() || upper_bound.is_some() { + scanner.distance_range(lower_bound, upper_bound); + } + if let Some(dt) = distance_type { + let metric = Self::parse_metric_type(Some(dt))?; + scanner.distance_metric(metric); + } + // Apply offset on top of the k nearest results. + if let Some(off) = offset.filter(|&o| o > 0) { + scanner.limit(None, Some(off as i64)).map_err(|e| { + Error::invalid_input_source( + format!("Invalid offset for {}: {}", operation, e).into(), + ) + })?; + } + } else { + // Scalar (non-vector) query: treat k as a row LIMIT. + let limit = if k > 0 { Some(k as i64) } else { None }; + scanner + .limit(limit, offset.map(|o| o as i64)) + .map_err(|e| { + Error::invalid_input_source( + format!("Invalid limit/offset for {}: {}", operation, e).into(), + ) + })?; + } + + if let Some(true) = with_row_id { + scanner.with_row_id(); + } + + Ok(()) + } + /// Retrieve a snapshot of operation metrics. /// /// Returns a HashMap where keys are operation names (e.g., "list_tables", "describe_table") @@ -2967,6 +3109,281 @@ impl LanceNamespace for DirectoryNamespace { Ok(DropTableIndexResponse { transaction_id }) } + async fn list_all_tables(&self, request: ListTablesRequest) -> Result { + // In dir-only mode there are no child namespaces, so all tables live in the + // root directory. This is equivalent to listing the root namespace. + let mut tables = self.list_directory_tables().await?; + Self::apply_pagination(&mut tables, request.page_token, request.limit); + Ok(ListTablesResponse::new(tables)) + } + + async fn restore_table(&self, request: RestoreTableRequest) -> Result { + let version = request.version; + if version < 0 { + return Err(Error::invalid_input_source( + format!( + "Table version for restore_table must be non-negative, got {}", + version + ) + .into(), + )); + } + + let table_uri = self.resolve_table_location(&request.id).await?; + let mut dataset = self.load_dataset(&table_uri, None, "restore_table").await?; + + dataset = dataset + .checkout_version(version as u64) + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to checkout version {} for restore at '{}': {}", + version, table_uri, e + ) + .into(), + ) + })?; + + dataset.restore().await.map_err(|e| { + Error::namespace_source( + format!( + "Failed to restore table at '{}' to version {}: {}", + table_uri, version, e + ) + .into(), + ) + })?; + + let transaction_id = dataset + .read_transaction() + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to read transaction after restoring '{}': {}", + table_uri, e + ) + .into(), + ) + })? + .map(|t| t.uuid); + + Ok(RestoreTableResponse { transaction_id }) + } + + async fn update_table_schema_metadata( + &self, + request: UpdateTableSchemaMetadataRequest, + ) -> Result { + let table_uri = self.resolve_table_location(&request.id).await?; + let mut dataset = self + .load_dataset(&table_uri, None, "update_table_schema_metadata") + .await?; + + let new_metadata = request.metadata.unwrap_or_default(); + let updated_metadata = dataset + .update_schema_metadata(new_metadata.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to update schema metadata for table at '{}': {}", + table_uri, e + ) + .into(), + ) + })?; + + let transaction_id = dataset + .read_transaction() + .await + .map_err(|e| { + Error::namespace_source( + format!( + "Failed to read transaction after updating metadata for '{}': {}", + table_uri, e + ) + .into(), + ) + })? + .map(|t| t.uuid); + + Ok(UpdateTableSchemaMetadataResponse { + metadata: Some(updated_metadata), + transaction_id, + }) + } + + async fn get_table_stats( + &self, + request: GetTableStatsRequest, + ) -> Result { + let table_uri = self.resolve_table_location(&request.id).await?; + let dataset = Arc::new( + self.load_dataset(&table_uri, None, "get_table_stats") + .await?, + ); + + // Compute total bytes on disk using field-level statistics + let data_stats = dataset.calculate_data_stats().await.map_err(|e| { + Error::namespace_source( + format!( + "Failed to calculate data statistics for table at '{}': {}", + table_uri, e + ) + .into(), + ) + })?; + let total_bytes: i64 = data_stats + .fields + .iter() + .map(|f| f.bytes_on_disk as i64) + .sum(); + + // Collect per-fragment row counts + let fragment_row_futures: Vec<_> = dataset + .get_fragments() + .into_iter() + .map(|f| async move { f.physical_rows().await }) + .collect(); + let fragment_row_results = futures::future::join_all(fragment_row_futures).await; + let mut fragment_row_counts: Vec = fragment_row_results + .into_iter() + .filter_map(|r| r.ok()) + .map(|r| r as i64) + .collect(); + + let num_fragments = fragment_row_counts.len() as i64; + let num_rows: i64 = fragment_row_counts.iter().sum(); + + // Fragments with fewer rows than the compaction target are considered "small", + // consistent with CompactionOptions::target_rows_per_fragment default. + const SMALL_FRAGMENT_THRESHOLD: i64 = 1024 * 1024; + let num_small_fragments = fragment_row_counts + .iter() + .filter(|&&r| r < SMALL_FRAGMENT_THRESHOLD) + .count() as i64; + + // Compute length summary statistics + fragment_row_counts.sort_unstable(); + let lengths = if fragment_row_counts.is_empty() { + FragmentSummary::new(0, 0, 0, 0, 0, 0, 0) + } else { + let len = fragment_row_counts.len(); + let min = fragment_row_counts[0]; + let max = fragment_row_counts[len - 1]; + let mean = num_rows / num_fragments; + let pct = |p: f64| fragment_row_counts[((len - 1) as f64 * p) as usize]; + FragmentSummary::new(min, max, mean, pct(0.25), pct(0.50), pct(0.75), pct(0.99)) + }; + + // Count non-system indices + let indices = dataset.load_indices().await.map_err(|e| { + Error::namespace_source( + format!("Failed to load indices for table at '{}': {}", table_uri, e).into(), + ) + })?; + let num_indices = indices.iter().filter(|m| !is_system_index(m)).count() as i64; + + let fragment_stats = FragmentStats::new(num_fragments, num_small_fragments, lengths); + Ok(GetTableStatsResponse::new( + total_bytes, + num_rows, + num_indices, + fragment_stats, + )) + } + + async fn explain_table_query_plan( + &self, + request: ExplainTableQueryPlanRequest, + ) -> Result { + let table_uri = self.resolve_table_location(&request.id).await?; + let dataset = self + .load_dataset( + &table_uri, + request.query.version, + "explain_table_query_plan", + ) + .await?; + let verbose = request.verbose.unwrap_or(false); + + let mut scanner = dataset.scan(); + Self::apply_query_params_to_scanner( + &mut scanner, + request.query.filter.as_deref(), + request.query.columns.as_deref(), + request.query.vector_column.as_deref(), + &request.query.vector, + request.query.k, + request.query.offset, + request.query.prefilter, + request.query.bypass_vector_index, + request.query.nprobes, + request.query.ef, + request.query.refine_factor, + request.query.distance_type.as_deref(), + request.query.fast_search, + request.query.with_row_id, + request.query.lower_bound, + request.query.upper_bound, + "explain_table_query_plan", + )?; + + scanner.explain_plan(verbose).await.map_err(|e| { + Error::namespace_source( + format!( + "Failed to explain query plan for table at '{}': {}", + table_uri, e + ) + .into(), + ) + }) + } + + async fn analyze_table_query_plan( + &self, + request: AnalyzeTableQueryPlanRequest, + ) -> Result { + let table_uri = self.resolve_table_location(&request.id).await?; + let dataset = self + .load_dataset(&table_uri, request.version, "analyze_table_query_plan") + .await?; + + let mut scanner = dataset.scan(); + Self::apply_query_params_to_scanner( + &mut scanner, + request.filter.as_deref(), + request.columns.as_deref(), + request.vector_column.as_deref(), + &request.vector, + request.k, + request.offset, + request.prefilter, + request.bypass_vector_index, + request.nprobes, + request.ef, + request.refine_factor, + request.distance_type.as_deref(), + request.fast_search, + request.with_row_id, + request.lower_bound, + request.upper_bound, + "analyze_table_query_plan", + )?; + + scanner.analyze_plan().await.map_err(|e| { + Error::namespace_source( + format!( + "Failed to analyze query plan for table at '{}': {}", + table_uri, e + ) + .into(), + ) + }) + } + async fn count_table_rows(&self, request: CountTableRowsRequest) -> Result { self.record_op("count_table_rows"); let table_uri = self.resolve_table_location(&request.id).await?; @@ -3312,11 +3729,24 @@ mod tests { use lance_core::utils::tempfile::{TempStdDir, TempStrDir}; use lance_namespace::models::{ CreateTableRequest, JsonArrowDataType, JsonArrowField, JsonArrowSchema, ListTablesRequest, + QueryTableRequestColumns, }; use lance_namespace::schema::convert_json_arrow_schema; use std::io::Cursor; use std::sync::Arc; + fn assert_plan_contains_all(plan: &str, expected_fragments: &[&str], context: &str) { + for expected_fragment in expected_fragments { + assert!( + plan.contains(expected_fragment), + "{}. Missing fragment: '{}'. Plan:\n{}", + context, + expected_fragment, + plan + ); + } + } + /// Helper to create a test DirectoryNamespace with a temporary directory async fn create_test_namespace() -> (DirectoryNamespace, TempStdDir) { let temp_dir = TempStdDir::default(); @@ -7734,4 +8164,173 @@ mod tests { assert_eq!(*ver, 2, "Recorded version should be 2"); } } + + #[tokio::test] + async fn test_list_all_tables() { + use lance_namespace::models::ListTablesRequest; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "alpha").await; + create_scalar_table(&namespace, "beta").await; + + let request = ListTablesRequest { + id: Some(vec![]), + page_token: None, + limit: None, + ..Default::default() + }; + let response = namespace.list_all_tables(request).await.unwrap(); + let mut tables = response.tables; + tables.sort(); + assert_eq!(tables, vec!["alpha", "beta"]); + } + + #[tokio::test] + async fn test_restore_table() { + use lance_namespace::models::RestoreTableRequest; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "users").await; + + // Create a second version by creating a scalar index (this adds a new version) + create_scalar_index(&namespace, "users", "users_id_idx").await; + + let dataset = open_dataset(&namespace, "users").await; + let current_version = dataset.version().version; + assert!(current_version >= 2, "Should have at least 2 versions"); + + // Restore to version 1 + let mut restore_req = RestoreTableRequest::new(1); + restore_req.id = Some(vec!["users".to_string()]); + let response = namespace.restore_table(restore_req).await.unwrap(); + + // transaction_id should be present (the restore operation) + assert!( + response.transaction_id.is_some(), + "restore_table should return a transaction_id" + ); + + // Verify the dataset now has a new version (restore creates a new version) + let dataset_after = open_dataset(&namespace, "users").await; + assert!( + dataset_after.version().version > current_version, + "Restore should create a new version" + ); + } + + #[tokio::test] + async fn test_update_table_schema_metadata() { + use lance_namespace::models::UpdateTableSchemaMetadataRequest; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "products").await; + + let mut metadata = HashMap::new(); + metadata.insert("owner".to_string(), "team_a".to_string()); + metadata.insert("version".to_string(), "1.0".to_string()); + + let mut req = UpdateTableSchemaMetadataRequest::new(); + req.id = Some(vec!["products".to_string()]); + req.metadata = Some(metadata.clone()); + + let response = namespace.update_table_schema_metadata(req).await.unwrap(); + + assert!(response.metadata.is_some()); + let returned = response.metadata.unwrap(); + assert_eq!(returned.get("owner"), Some(&"team_a".to_string())); + assert_eq!(returned.get("version"), Some(&"1.0".to_string())); + assert!( + response.transaction_id.is_some(), + "update_table_schema_metadata should return a transaction_id" + ); + } + + #[tokio::test] + async fn test_get_table_stats() { + use lance_namespace::models::GetTableStatsRequest; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "items").await; + create_scalar_index(&namespace, "items", "items_id_idx").await; + + let mut req = GetTableStatsRequest::new(); + req.id = Some(vec!["items".to_string()]); + + let response = namespace.get_table_stats(req).await.unwrap(); + assert_eq!(response.num_rows, 3); + assert_eq!(response.num_indices, 1); + } + + #[tokio::test] + async fn test_explain_table_query_plan() { + use lance_namespace::models::QueryTableRequestVector; + use lance_namespace::models::{ExplainTableQueryPlanRequest, QueryTableRequest}; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "catalog").await; + + let mut query = QueryTableRequest::new(1, QueryTableRequestVector::new()); + query.filter = Some("id > 1".to_string()); + query.columns = Some(Box::new(QueryTableRequestColumns { + column_names: Some(vec!["id".to_string(), "name".to_string()]), + column_aliases: None, + })); + query.with_row_id = Some(true); + + let mut req = ExplainTableQueryPlanRequest::new(query); + req.id = Some(vec!["catalog".to_string()]); + + let plan_str = namespace.explain_table_query_plan(req).await.unwrap(); + assert_plan_contains_all( + &plan_str, + &[ + "ProjectionExec: expr=[id@0 as id, name@2 as name", + "Take: columns=\"id, _rowid, (name)\"", + "LanceRead: uri=", + "projection=[id]", + "row_id=true, row_addr=false", + "full_filter=id > Int32(1)", + "refine_filter=id > Int32(1)", + ], + "Filtered explain plan should preserve late materialization and filter pushdown", + ); + } + + #[tokio::test] + async fn test_analyze_table_query_plan() { + use lance_namespace::models::AnalyzeTableQueryPlanRequest; + use lance_namespace::models::QueryTableRequestVector; + + let (namespace, _temp_dir) = create_test_namespace().await; + create_scalar_table(&namespace, "catalog").await; + + let mut req = AnalyzeTableQueryPlanRequest::new(1, QueryTableRequestVector::new()); + req.id = Some(vec!["catalog".to_string()]); + req.filter = Some("id > 0".to_string()); + req.columns = Some(Box::new(QueryTableRequestColumns { + column_names: Some(vec!["id".to_string(), "name".to_string()]), + column_aliases: None, + })); + req.with_row_id = Some(true); + + let analysis_str = namespace.analyze_table_query_plan(req).await.unwrap(); + assert_plan_contains_all( + &analysis_str, + &[ + "AnalyzeExec verbose=true", + "ProjectionExec: elapsed=", + "expr=[id@0 as id, name@2 as name", + "Take: elapsed=", + "columns=\"id, _rowid, (name)\"", + "CoalesceBatchesExec: elapsed=", + "LanceRead: elapsed=", + "projection=[id]", + "row_id=true, row_addr=false", + "full_filter=id > Int32(0)", + "refine_filter=id > Int32(0)", + "metrics=[output_rows=", + ], + "Filtered analyze plan should preserve late materialization and filter pushdown", + ); + } } From 97a89a677ef6d36a57989b9012d8595b890f47dd Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Tue, 14 Apr 2026 08:40:36 -0500 Subject: [PATCH 183/206] chore: add tracing instrumentation to mem_wal module (#6430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `#[instrument]` attributes from the `tracing` crate to key functions across the `mem_wal` module - Covers write path (`RegionWriter::open`, `put`, `close`), flush path (`MemTableFlusher::flush`, `flush_with_indexes`), WAL operations, manifest store, memtable inserts, scanner/planner, point lookups, and vector search - Uses appropriate trace levels (`info` for high-level operations, `debug` for internals) with relevant fields (region_id, epoch, row counts, batch counts) ## Test plan - [x] `cargo check` passes — no functional changes, only attribute additions - [x] Existing `mem_wal` tests continue to pass - [ ] Tracing output verified with `RUST_LOG=debug` showing instrumented spans 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance/src/dataset/mem_wal/index.rs | 4 ++++ rust/lance/src/dataset/mem_wal/manifest.rs | 6 ++++++ rust/lance/src/dataset/mem_wal/memtable.rs | 3 +++ rust/lance/src/dataset/mem_wal/memtable/flush.rs | 4 ++++ rust/lance/src/dataset/mem_wal/scanner/planner.rs | 2 ++ .../lance/src/dataset/mem_wal/scanner/point_lookup.rs | 2 ++ .../src/dataset/mem_wal/scanner/vector_search.rs | 2 ++ rust/lance/src/dataset/mem_wal/wal.rs | 3 +++ rust/lance/src/dataset/mem_wal/write.rs | 11 +++++++++++ 9 files changed, 37 insertions(+) diff --git a/rust/lance/src/dataset/mem_wal/index.rs b/rust/lance/src/dataset/mem_wal/index.rs index e7eb7394c45..a267a9d2e14 100644 --- a/rust/lance/src/dataset/mem_wal/index.rs +++ b/rust/lance/src/dataset/mem_wal/index.rs @@ -31,6 +31,7 @@ use lance_index::vector::pq::ProductQuantizer; use lance_linalg::distance::DistanceType; use lance_table::format::IndexMetadata; use prost::Message as _; +use tracing::instrument; /// Row position in MemTable. /// @@ -331,6 +332,7 @@ impl IndexStore { } /// Insert a batch into all indexes with batch position tracking. + #[instrument(name = "idx_insert_batch", level = "debug", skip_all, fields(num_rows = batch.num_rows(), row_offset, batch_position))] pub fn insert_with_batch_position( &self, batch: &RecordBatch, @@ -378,6 +380,7 @@ impl IndexStore { /// For IVF-PQ indexes, this enables vectorized partition assignment and /// PQ encoding across all batches, improving performance through better /// SIMD utilization. + #[instrument(name = "idx_insert_batches", level = "debug", skip_all, fields(batch_count = batches.len()))] pub fn insert_batches(&self, batches: &[StoredBatch]) -> Result<()> { if batches.is_empty() { return Ok(()); @@ -419,6 +422,7 @@ impl IndexStore { /// /// Returns a map of index names to their update durations for performance tracking. #[allow(clippy::print_stderr)] + #[instrument(name = "idx_insert_batches_parallel", level = "debug", skip_all, fields(batch_count = batches.len()))] pub fn insert_batches_parallel( &self, batches: &[StoredBatch], diff --git a/rust/lance/src/dataset/mem_wal/manifest.rs b/rust/lance/src/dataset/mem_wal/manifest.rs index 1aceac60346..744dcb2def2 100644 --- a/rust/lance/src/dataset/mem_wal/manifest.rs +++ b/rust/lance/src/dataset/mem_wal/manifest.rs @@ -42,6 +42,7 @@ use object_store::PutOptions; use object_store::path::Path; use prost::Message; use serde::{Deserialize, Serialize}; +use tracing::instrument; use uuid::Uuid; use super::util::{manifest_filename, parse_bit_reversed_filename, shard_manifest_path}; @@ -91,6 +92,7 @@ impl ShardManifestStore { /// Read the latest manifest version. /// /// Returns `None` if no manifest exists (new shard). + #[instrument(name = "manifest_read_latest", level = "debug", skip_all, fields(shard_id = %self.shard_id))] pub async fn read_latest(&self) -> Result> { let version = self.find_latest_version().await?; if version == 0 { @@ -134,6 +136,7 @@ impl ShardManifestStore { /// # Errors /// /// Returns `Error::AlreadyExists` if another writer already wrote this version. + #[instrument(name = "manifest_write", level = "debug", skip_all, fields(shard_id = %self.shard_id, version = manifest.version, epoch = manifest.writer_epoch))] pub async fn write(&self, manifest: &ShardManifest) -> Result { let version = manifest.version; let filename = manifest_filename(version); @@ -369,6 +372,7 @@ impl ShardManifestStore { /// # Errors /// /// Returns an error if another writer already claimed the shard. + #[instrument(name = "manifest_claim_epoch", level = "info", skip_all, fields(shard_id = %self.shard_id, shard_spec_id))] pub async fn claim_epoch(&self, shard_spec_id: u32) -> Result<(u64, ShardManifest)> { let current = self.read_latest().await?; @@ -415,6 +419,7 @@ impl ShardManifestStore { /// /// Loads the current manifest and compares epochs. If the stored epoch /// is higher than the local epoch, the writer has been fenced. + #[instrument(name = "manifest_check_fenced", level = "debug", skip_all, fields(shard_id = %self.shard_id, local_epoch))] pub async fn check_fenced(&self, local_epoch: u64) -> Result<()> { let current = self.read_latest().await?; Self::check_fenced_against(¤t, local_epoch, self.shard_id) @@ -452,6 +457,7 @@ impl ShardManifestStore { /// # Returns /// /// The successfully written manifest. + #[instrument(name = "manifest_commit_update", level = "debug", skip_all, fields(shard_id = %self.shard_id, local_epoch))] pub async fn commit_update(&self, local_epoch: u64, prepare_fn: F) -> Result where F: Fn(&ShardManifest) -> ShardManifest, diff --git a/rust/lance/src/dataset/mem_wal/memtable.rs b/rust/lance/src/dataset/mem_wal/memtable.rs index a93b5627f2d..7f40ff35df4 100644 --- a/rust/lance/src/dataset/mem_wal/memtable.rs +++ b/rust/lance/src/dataset/mem_wal/memtable.rs @@ -17,6 +17,7 @@ use lance_core::datatypes::Schema; use lance_core::{Error, Result}; use lance_index::scalar::bloomfilter::sbbf::Sbbf; use tokio::sync::RwLock; +use tracing::instrument; use uuid::Uuid; use super::index::IndexStore; @@ -341,6 +342,7 @@ impl MemTable { /// # Single Writer Requirement /// /// This method MUST only be called from the single writer task. + #[instrument(name = "mt_insert", level = "debug", skip_all, fields(num_rows = batch.num_rows(), generation = self.generation))] pub async fn insert(&mut self, batch: RecordBatch) -> Result { // Validate schema compatibility if batch.schema() != self.schema { @@ -423,6 +425,7 @@ impl MemTable { /// # Single Writer Requirement /// /// This method MUST only be called from the single writer task. + #[instrument(name = "mt_insert_batches", level = "debug", skip_all, fields(batch_count = batches.len(), generation = self.generation))] pub async fn insert_batches_only( &mut self, batches: Vec, diff --git a/rust/lance/src/dataset/mem_wal/memtable/flush.rs b/rust/lance/src/dataset/mem_wal/memtable/flush.rs index 4a46ed062c0..15c9da44f7a 100644 --- a/rust/lance/src/dataset/mem_wal/memtable/flush.rs +++ b/rust/lance/src/dataset/mem_wal/memtable/flush.rs @@ -15,6 +15,7 @@ use lance_io::object_store::ObjectStore; use lance_table::format::IndexMetadata; use log::info; use object_store::path::Path; +use tracing::instrument; use uuid::Uuid; use super::super::index::MemIndexConfig; @@ -77,6 +78,7 @@ impl MemTableFlusher { } /// Flush the MemTable to storage (data files, indexes, bloom filter). + #[instrument(name = "mt_flush_storage", level = "info", skip_all, fields(shard_id = %self.shard_id, epoch, generation = memtable.generation(), row_count = memtable.row_count()))] pub async fn flush(&self, memtable: &MemTable, epoch: u64) -> Result { self.manifest_store.check_fenced(epoch).await?; @@ -134,6 +136,7 @@ impl MemTableFlusher { /// /// Returns the total number of rows written, which is needed for /// reversing row positions in indexes. + #[instrument(name = "mt_write_data_file", level = "debug", skip_all, fields(path = %path))] async fn write_data_file(&self, path: &Path, memtable: &MemTable) -> Result { use arrow_array::RecordBatchIterator; @@ -180,6 +183,7 @@ impl MemTableFlusher { } /// Flush the MemTable to storage with indexes. + #[instrument(name = "mt_flush_with_indexes", level = "info", skip_all, fields(shard_id = %self.shard_id, epoch, generation = memtable.generation(), row_count = memtable.row_count(), index_count = index_configs.len()))] pub async fn flush_with_indexes( &self, memtable: &MemTable, diff --git a/rust/lance/src/dataset/mem_wal/scanner/planner.rs b/rust/lance/src/dataset/mem_wal/scanner/planner.rs index d75a304480e..c70c127f747 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/planner.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/planner.rs @@ -14,6 +14,7 @@ use datafusion::physical_plan::union::UnionExec; use datafusion::physical_plan::{ExecutionPlan, limit::GlobalLimitExec}; use datafusion::prelude::Expr; use lance_core::Result; +use tracing::instrument; use super::collector::LsmDataSourceCollector; use super::data_source::LsmDataSource; @@ -75,6 +76,7 @@ impl LsmScanPlanner { /// - SortPreservingMergeExec is O(N log K) where K is the number of sources /// - Memory usage is bounded by the sum of K sort buffers rather than all data /// - No extra column for _memtable_gen in the common case + #[instrument(name = "lsm_plan_scan", level = "debug", skip_all, fields(has_filter = filter.is_some(), limit, offset))] pub async fn plan_scan( &self, projection: Option<&[String]>, diff --git a/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs b/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs index fecdb251166..a11ba083139 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/point_lookup.rs @@ -14,6 +14,7 @@ use datafusion::physical_plan::limit::GlobalLimitExec; use datafusion::prelude::Expr; use lance_core::Result; use lance_index::scalar::bloomfilter::sbbf::Sbbf; +use tracing::instrument; use super::collector::LsmDataSourceCollector; use super::data_source::LsmDataSource; @@ -117,6 +118,7 @@ impl LsmPointLookupPlanner { /// /// An execution plan that returns at most one row - the newest version /// of the row with the given primary key. + #[instrument(name = "lsm_point_lookup", level = "debug", skip_all, fields(pk_column_count = self.pk_columns.len()))] pub async fn plan_lookup( &self, pk_values: &[ScalarValue], diff --git a/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs b/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs index 0433929501f..3f9880250c7 100644 --- a/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs +++ b/rust/lance/src/dataset/mem_wal/scanner/vector_search.rs @@ -18,6 +18,7 @@ use datafusion::physical_plan::sorts::sort::SortExec; use datafusion::physical_plan::union::UnionExec; use lance_core::Result; use lance_index::scalar::bloomfilter::sbbf::Sbbf; +use tracing::instrument; use super::collector::LsmDataSourceCollector; use super::data_source::LsmDataSource; @@ -144,6 +145,7 @@ impl LsmVectorSearchPlanner { /// /// An execution plan that returns the top-K nearest neighbors across all /// LSM levels, with stale results filtered out. + #[instrument(name = "lsm_vector_search", level = "info", skip_all, fields(k, nprobes, vector_column = %self.vector_column, distance_type = ?self.distance_type))] pub async fn plan_search( &self, query_vector: &FixedSizeListArray, diff --git a/rust/lance/src/dataset/mem_wal/wal.rs b/rust/lance/src/dataset/mem_wal/wal.rs index 4e92355402c..8dd1d9d6e32 100644 --- a/rust/lance/src/dataset/mem_wal/wal.rs +++ b/rust/lance/src/dataset/mem_wal/wal.rs @@ -21,6 +21,7 @@ use lance_io::object_store::ObjectStore; use object_store::path::Path; use tokio::sync::{mpsc, watch}; +use tracing::instrument; use uuid::Uuid; use super::util::{WatchableOnceCell, shard_wal_path, wal_entry_filename}; @@ -297,6 +298,7 @@ impl WalFlusher { /// /// A `WalFlushResult` with timing metrics and the WAL entry. /// Returns empty result if nothing to flush (already flushed past end_batch_position). + #[instrument(name = "wal_flush", level = "info", skip_all, fields(shard_id = %self.shard_id, end_batch_position, has_indexes = indexes.is_some()))] pub async fn flush_to_with_index_update( &self, batch_store: &BatchStore, @@ -498,6 +500,7 @@ impl WalEntryData { /// # Returns /// /// The parsed WAL entry data, or an error if reading/parsing fails. + #[instrument(name = "wal_entry_read", level = "debug", skip_all, fields(path = %path))] pub async fn read(object_store: &ObjectStore, path: &Path) -> Result { // Read the file let data = object_store diff --git a/rust/lance/src/dataset/mem_wal/write.rs b/rust/lance/src/dataset/mem_wal/write.rs index 4c956fd3ad2..406e521e10d 100644 --- a/rust/lance/src/dataset/mem_wal/write.rs +++ b/rust/lance/src/dataset/mem_wal/write.rs @@ -31,6 +31,7 @@ use tokio::sync::{RwLock, mpsc}; use tokio::task::JoinHandle; use tokio::time::{Interval, interval_at}; use tokio_util::sync::CancellationToken; +use tracing::instrument; use uuid::Uuid; pub use super::index::{ @@ -942,6 +943,7 @@ impl ShardWriter { /// /// The `base_path` should come from `ObjectStore::from_uri()` to ensure /// WAL files are written inside the dataset directory. + #[instrument(name = "sw_open", level = "info", skip_all, fields(shard_id = %config.shard_id, index_count = index_configs.len()))] pub async fn open( object_store: Arc, base_path: Path, @@ -1102,6 +1104,7 @@ impl ShardWriter { /// Fencing is detected lazily during WAL flush via atomic writes. /// If another writer has taken over, the WAL flush will fail with /// `AlreadyExists`, indicating this writer has been fenced. + #[instrument(name = "sw_put", level = "info", skip_all, fields(batch_count = batches.len(), shard_id = %self.config.shard_id))] pub async fn put(&self, batches: Vec) -> Result { if batches.is_empty() { return Err(Error::invalid_input("Cannot write empty batch list")); @@ -1257,6 +1260,7 @@ impl ShardWriter { /// Close the writer gracefully. /// /// Flushes pending data and shuts down background tasks. + #[instrument(name = "sw_close", level = "info", skip_all, fields(shard_id = %self.config.shard_id, epoch = self.epoch))] pub async fn close(self) -> Result<()> { info!("Closing ShardWriter for shard {}", self.config.shard_id); @@ -1373,6 +1377,12 @@ impl WalFlushHandler { /// * `batch_store` - The batch store to flush from /// * `indexes` - Optional indexes to update in parallel with WAL I/O /// * `end_batch_position` - End batch ID (exclusive). Flush batches in (max_flushed, end_batch_position). + #[instrument( + name = "wal_do_flush", + level = "debug", + skip_all, + fields(end_batch_position) + )] async fn do_flush( &self, batch_store: Arc, @@ -1486,6 +1496,7 @@ impl MemTableFlushHandler { /// This method waits for the WAL flush to complete (sent at freeze time), /// then flushes to Lance storage. The WAL flush is already queued by /// freeze_memtable to ensure strict ordering of WAL entries. + #[instrument(name = "mt_flush", level = "info", skip_all, fields(generation = memtable.generation(), row_count = memtable.row_count()))] async fn flush_memtable( &mut self, memtable: Arc, From 9df651bac7130258b64f7d2967e7473c45e5bd03 Mon Sep 17 00:00:00 2001 From: hushengquan <45221305+hushengquan@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:58:11 +0800 Subject: [PATCH 184/206] perf: submit I/O requests eagerly in FullZipScheduler (#6513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Refactor `FullZipScheduler::create_page_load_task` to accept a pre-submitted I/O future instead of deferring I/O submission until the async task executes. This allows the I/O requests to be submitted immediately during scheduling, enabling the object store layer to batch and parallelize them. close #6504 ## I/O Model Change ### Before: Lazy I/O submission (serialized) Previously, `create_page_load_task` received a `FullZipReadSource::Remote(io)` along with byte ranges and priority. The actual `io.submit_request()` call happened **inside** the async block, meaning the I/O request was not submitted until the future was first polled. When decoding multiple pages (e.g. across many fragments), this created a sequential I/O pattern: ``` Page 1: [schedule] -> [poll] -> [submit I/O] -> [wait response] -> [decode] Page 2: [schedule] -> [poll] -> [submit I/O] -> [wait response] -> [decode] Page 3: [schedule] -> [poll] -> ... ``` Each page's I/O request could only be submitted after the previous task started executing. The I/O scheduler had no visibility into upcoming requests, preventing it from batching or parallelizing them effectively. ### After: Eager I/O submission (pipelined) Now, `io.submit_request()` is called **before** constructing the `PageLoadTask`, and the resulting future is passed into `create_page_load_task`. All I/O requests for all pages are submitted upfront during the scheduling phase: ``` [schedule all pages] --> submit I/O page 1 -+ --> submit I/O page 2 -+ --> submit I/O page 3 -+ (all in-flight concurrently) --> submit I/O page N -+ | [poll] -> [await page 1 response] -> [decode] [poll] -> [await page 2 response] -> [decode] [poll] -> [await page 3 response] -> [decode] ``` The object store layer can now see all pending requests at once and optimize I/O through batching, connection multiplexing, and parallel fetches. The async tasks only await the already-in-flight I/O futures. ## Changes - `rust/lance-encoding/src/encodings/logical/primitive.rs`: - Changed `create_page_load_task` signature to accept `BoxFuture<'static, Result>>` instead of `FullZipReadSource` + byte ranges + priority - Moved `io.submit_request()` calls to happen eagerly at both call sites (`schedule_ranges_with_rep_index` and the non-rep-index path), before constructing the page load task ## Performance Tested with a multi-fragment dataset containing fixed-width columns (768-dim float32 vectors, 40 fragments, 50 rows/fragment): | Benchmark | Before (p50) | After (p50) | Speedup | |---|---|---|---| | Fixed-width column scan | 3453 ms | 523 ms | **6.6x** | The improvement comes entirely from I/O pipelining — the decoding logic itself is unchanged. The effect is most pronounced with many fragments or pages, where the serialized I/O submission was the dominant bottleneck. --- .../src/encodings/logical/primitive.rs | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/rust/lance-encoding/src/encodings/logical/primitive.rs b/rust/lance-encoding/src/encodings/logical/primitive.rs index 6bcdbc5e56b..9f6be591d13 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive.rs @@ -2165,15 +2165,17 @@ impl FullZipScheduler { } fn create_page_load_task( - read_source: FullZipReadSource, - byte_ranges: Vec>, - priority: u64, + io_future: BoxFuture<'static, Result>>, num_rows: u64, details: Arc, bits_per_offset: u8, ) -> PageLoadTask { let load_task = async move { - let data = read_source.fetch(&byte_ranges, priority).await?; + let buffers = io_future.await?; + let data = buffers + .into_iter() + .map(|bytes| LanceBuffer::from_bytes(bytes, 1)) + .collect::>(); Self::create_decoder(details, data, num_rows, bits_per_offset) } .boxed(); @@ -2333,14 +2335,9 @@ impl FullZipScheduler { rep_index.bytes_per_value, data_buf_position, ); - let page_load_task = Self::create_page_load_task( - FullZipReadSource::Remote(io.clone()), - byte_ranges, - priority, - num_rows, - details, - bits_per_offset, - ); + let io_future = io.submit_request(byte_ranges, priority); + let page_load_task = + Self::create_page_load_task(io_future, num_rows, details, bits_per_offset); return Ok(vec![page_load_task]); } @@ -2403,10 +2400,9 @@ impl FullZipScheduler { }) .collect::>(); + let io_future = io.submit_request(byte_ranges, self.priority); let page_load_task = Self::create_page_load_task( - FullZipReadSource::Remote(io.clone()), - byte_ranges, - self.priority, + io_future, num_rows, self.details.clone(), self.bits_per_offset, From 967257327bc591240fa838fd2b289cc5eb4ec4c0 Mon Sep 17 00:00:00 2001 From: Dan Rammer Date: Tue, 14 Apr 2026 11:58:36 -0500 Subject: [PATCH 185/206] feat: add configurable blob v2 pack file size (#6508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `blob_max_pack_file_bytes` to `WriteParams`, allowing users to override the default 1 GiB maximum pack (`.blob`) sidecar file size - Thread the configuration through the full write path: `WriteParams` -> `WriterGenerator` -> `WriterOptions` -> `BlobPreprocessor` -> `PackWriter` - Expose the option in Python (`write_dataset`) and Java (`WriteParams.Builder`) bindings ## Test plan - [x] All 37 existing blob tests pass (`cargo test -p lance blob`) - [x] Clippy clean on `lance` and `lance-jni` crates - [x] Verify Python binding works end-to-end with `blob_max_pack_file_bytes` kwarg - [x] Verify Java binding compiles with `./mvnw compile` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- java/lance-jni/src/blocking_dataset.rs | 47 +++++++++++++------ java/lance-jni/src/fragment.rs | 10 ++++ java/lance-jni/src/utils.rs | 4 ++ java/src/main/java/org/lance/Dataset.java | 12 ++++- java/src/main/java/org/lance/Fragment.java | 12 +++-- .../java/org/lance/WriteDatasetBuilder.java | 30 ++++++++++++ java/src/main/java/org/lance/WriteParams.java | 33 ++++++++++++- python/python/lance/dataset.py | 5 ++ python/src/dataset.rs | 3 ++ rust/lance/src/dataset/blob.rs | 7 ++- rust/lance/src/dataset/write.rs | 25 ++++++++++ 11 files changed, 165 insertions(+), 23 deletions(-) diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index 6366341ec1a..ab2333266da 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -413,6 +413,8 @@ pub extern "system" fn Java_org_lance_Dataset_createWithFfiSchema<'local>( storage_options_obj: JObject, // Map initial_bases: JObject, target_bases: JObject, + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional ) -> JObject<'local> { ok_or_throw!( env, @@ -430,6 +432,8 @@ pub extern "system" fn Java_org_lance_Dataset_createWithFfiSchema<'local>( storage_options_obj, initial_bases, target_bases, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, ) ) } @@ -449,6 +453,8 @@ fn inner_create_with_ffi_schema<'local>( storage_options_obj: JObject, // Map initial_bases: JObject, target_bases: JObject, + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional ) -> Result> { let c_schema_ptr = arrow_schema_addr as *mut FFI_ArrowSchema; let c_schema = unsafe { FFI_ArrowSchema::from_raw(c_schema_ptr) }; @@ -468,6 +474,8 @@ fn inner_create_with_ffi_schema<'local>( storage_options_obj, initial_bases, target_bases, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, reader, None, // No namespace for schema-only creation false, // No managed versioning for schema-only creation @@ -523,6 +531,8 @@ pub extern "system" fn Java_org_lance_Dataset_createWithFfiStream<'local>( storage_options_obj: JObject, // Map initial_bases: JObject, // Optional> target_bases: JObject, // Optional> + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional namespace_obj: JObject, // LanceNamespace (can be null) table_id_obj: JObject, // List (can be null) namespace_client_managed_versioning: jboolean, // Whether namespace manages versioning @@ -543,6 +553,8 @@ pub extern "system" fn Java_org_lance_Dataset_createWithFfiStream<'local>( storage_options_obj, initial_bases, target_bases, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, namespace_obj, table_id_obj, namespace_client_managed_versioning != 0, @@ -555,19 +567,21 @@ fn inner_create_with_ffi_stream<'local>( env: &mut JNIEnv<'local>, arrow_array_stream_addr: jlong, path: JString, - max_rows_per_file: JObject, // Optional - max_rows_per_group: JObject, // Optional - max_bytes_per_file: JObject, // Optional - mode: JObject, // Optional - enable_stable_row_ids: JObject, // Optional - data_storage_version: JObject, // Optional - enable_v2_manifest_paths: JObject, // Optional - storage_options_obj: JObject, // Map - initial_bases: JObject, // Optional> - target_bases: JObject, // Optional> - namespace_obj: JObject, // LanceNamespace (can be null) - table_id_obj: JObject, // List (can be null) - namespace_client_managed_versioning: bool, // Whether namespace manages versioning + max_rows_per_file: JObject, // Optional + max_rows_per_group: JObject, // Optional + max_bytes_per_file: JObject, // Optional + mode: JObject, // Optional + enable_stable_row_ids: JObject, // Optional + data_storage_version: JObject, // Optional + enable_v2_manifest_paths: JObject, // Optional + storage_options_obj: JObject, // Map + initial_bases: JObject, // Optional> + target_bases: JObject, // Optional> + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional + namespace_obj: JObject, // LanceNamespace (can be null) + table_id_obj: JObject, // List (can be null) + namespace_client_managed_versioning: bool, // Whether namespace manages versioning ) -> Result> { let stream_ptr = arrow_array_stream_addr as *mut FFI_ArrowArrayStream; let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; @@ -588,6 +602,8 @@ fn inner_create_with_ffi_stream<'local>( storage_options_obj, initial_bases, target_bases, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, reader, namespace_info, namespace_client_managed_versioning, @@ -613,6 +629,8 @@ fn create_dataset<'local>( storage_options_obj: JObject, initial_bases: JObject, target_bases: JObject, + allow_external_blob_outside_bases: JObject, + blob_pack_file_size_threshold: JObject, reader: impl RecordBatchReader + Send + 'static, namespace_info: Option<(Arc, Vec)>, namespace_client_managed_versioning: bool, @@ -631,7 +649,8 @@ fn create_dataset<'local>( &storage_options_obj, &initial_bases, &target_bases, - &JObject::null(), // allow_external_blob_outside_bases not used for Dataset.write() + &allow_external_blob_outside_bases, + &blob_pack_file_size_threshold, )?; // Set up namespace commit handler and storage options provider if namespace is provided diff --git a/java/lance-jni/src/fragment.rs b/java/lance-jni/src/fragment.rs index 5db1892d55d..6ef10ee2f7d 100644 --- a/java/lance-jni/src/fragment.rs +++ b/java/lance-jni/src/fragment.rs @@ -99,6 +99,7 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiArray<'local>( namespace_obj: JObject, // LanceNamespace (can be null) table_id_obj: JObject, // List (can be null) allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional ) -> JObject<'local> { ok_or_throw_with_return!( env, @@ -117,6 +118,7 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiArray<'local>( namespace_obj, table_id_obj, allow_external_blob_outside_bases, + blob_pack_file_size_threshold, ), JObject::default() ) @@ -138,6 +140,7 @@ fn inner_create_with_ffi_array<'local>( namespace_obj: JObject, // LanceNamespace (can be null) table_id_obj: JObject, // List (can be null) allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional ) -> Result> { let c_array_ptr = arrow_array_addr as *mut FFI_ArrowArray; let c_schema_ptr = arrow_schema_addr as *mut FFI_ArrowSchema; @@ -165,6 +168,7 @@ fn inner_create_with_ffi_array<'local>( namespace_obj, table_id_obj, allow_external_blob_outside_bases, + blob_pack_file_size_threshold, reader, ) } @@ -185,6 +189,7 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiStream<'a>( namespace_obj: JObject, // LanceNamespace (can be null) table_id_obj: JObject, // List (can be null) allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional ) -> JObject<'a> { ok_or_throw_with_return!( env, @@ -202,6 +207,7 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiStream<'a>( namespace_obj, table_id_obj, allow_external_blob_outside_bases, + blob_pack_file_size_threshold, ), JObject::null() ) @@ -222,6 +228,7 @@ fn inner_create_with_ffi_stream<'local>( namespace_obj: JObject, // LanceNamespace (can be null) table_id_obj: JObject, // List (can be null) allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional ) -> Result> { let stream_ptr = arrow_array_stream_addr as *mut FFI_ArrowArrayStream; let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; @@ -239,6 +246,7 @@ fn inner_create_with_ffi_stream<'local>( namespace_obj, table_id_obj, allow_external_blob_outside_bases, + blob_pack_file_size_threshold, reader, ) } @@ -257,6 +265,7 @@ fn create_fragment<'a>( namespace_obj: JObject, // LanceNamespace (can be null) table_id_obj: JObject, // List (can be null) allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional source: impl StreamingWriteSource, ) -> Result> { let path_str = dataset_uri.extract(env)?; @@ -274,6 +283,7 @@ fn create_fragment<'a>( &JObject::null(), // not used when creating fragments &JObject::null(), // not used when creating fragments &allow_external_blob_outside_bases, + &blob_pack_file_size_threshold, )?; // Set up storage options provider if namespace is provided diff --git a/java/lance-jni/src/utils.rs b/java/lance-jni/src/utils.rs index d2787dc6400..35c1e0ffae9 100644 --- a/java/lance-jni/src/utils.rs +++ b/java/lance-jni/src/utils.rs @@ -52,6 +52,7 @@ pub fn extract_write_params( initial_bases: &JObject, // Optional target_bases: &JObject, // Optional allow_external_blob_outside_bases: &JObject, // Optional + blob_pack_file_size_threshold: &JObject, // Optional ) -> Result { let mut write_params = WriteParams::default(); @@ -101,6 +102,9 @@ pub fn extract_write_params( if let Some(allow) = env.get_boolean_opt(allow_external_blob_outside_bases)? { write_params.allow_external_blob_outside_bases = allow; } + if let Some(max_bytes) = env.get_long_opt(blob_pack_file_size_threshold)? { + write_params.blob_pack_file_size_threshold = Some(max_bytes as usize); + } // Create storage options accessor from static storage_options let accessor = if storage_options.is_empty() { diff --git a/java/src/main/java/org/lance/Dataset.java b/java/src/main/java/org/lance/Dataset.java index f5cc5e73fba..42444a9aa83 100644 --- a/java/src/main/java/org/lance/Dataset.java +++ b/java/src/main/java/org/lance/Dataset.java @@ -151,7 +151,9 @@ public static Dataset create( params.getEnableV2ManifestPaths(), params.getStorageOptions(), params.getInitialBases(), - params.getTargetBases()); + params.getTargetBases(), + params.getAllowExternalBlobOutsideBases(), + params.getBlobPackFileSizeThreshold()); dataset.allocator = allocator; return dataset; } @@ -197,7 +199,9 @@ private static native Dataset createWithFfiSchema( Optional enableV2ManifestPaths, Map storageOptions, Optional> initialBases, - Optional> targetBases); + Optional> targetBases, + Optional allowExternalBlobOutsideBases, + Optional blobPackFileSizeThreshold); /** * Creates a dataset from an FFI arrow stream. @@ -232,6 +236,8 @@ private static native Dataset createWithFfiStream( Map storageOptions, Optional> initialBases, Optional> targetBases, + Optional allowExternalBlobOutsideBases, + Optional blobPackFileSizeThreshold, LanceNamespace namespaceClient, List tableId, boolean namespaceClientManagedVersioning); @@ -281,6 +287,8 @@ static Dataset create( params.getStorageOptions(), params.getInitialBases(), params.getTargetBases(), + params.getAllowExternalBlobOutsideBases(), + params.getBlobPackFileSizeThreshold(), namespaceClient, tableId, namespaceClientManagedVersioning); diff --git a/java/src/main/java/org/lance/Fragment.java b/java/src/main/java/org/lance/Fragment.java index 89a61560561..43091269382 100644 --- a/java/src/main/java/org/lance/Fragment.java +++ b/java/src/main/java/org/lance/Fragment.java @@ -280,7 +280,8 @@ static List create( params.getStorageOptions(), namespaceClient, tableId, - params.getAllowExternalBlobOutsideBases()); + params.getAllowExternalBlobOutsideBases(), + params.getBlobPackFileSizeThreshold()); } } @@ -306,7 +307,8 @@ static List create( params.getStorageOptions(), namespaceClient, tableId, - params.getAllowExternalBlobOutsideBases()); + params.getAllowExternalBlobOutsideBases(), + params.getBlobPackFileSizeThreshold()); } /** Create a fragment from the given arrow array and schema. */ @@ -323,7 +325,8 @@ private static native List createWithFfiArray( Map storageOptions, LanceNamespace namespaceClient, List tableId, - Optional allowExternalBlobOutsideBases); + Optional allowExternalBlobOutsideBases, + Optional blobPackFileSizeThreshold); /** Create a fragment from the given arrow stream. */ private static native List createWithFfiStream( @@ -338,5 +341,6 @@ private static native List createWithFfiStream( Map storageOptions, LanceNamespace namespaceClient, List tableId, - Optional allowExternalBlobOutsideBases); + Optional allowExternalBlobOutsideBases, + Optional blobPackFileSizeThreshold); } diff --git a/java/src/main/java/org/lance/WriteDatasetBuilder.java b/java/src/main/java/org/lance/WriteDatasetBuilder.java index c0e5f8839c2..3c06b43fd54 100644 --- a/java/src/main/java/org/lance/WriteDatasetBuilder.java +++ b/java/src/main/java/org/lance/WriteDatasetBuilder.java @@ -78,6 +78,8 @@ public class WriteDatasetBuilder { private Optional dataStorageVersion = Optional.empty(); private Optional> initialBases = Optional.empty(); private Optional> targetBases = Optional.empty(); + private Optional allowExternalBlobOutsideBases = Optional.empty(); + private Optional blobPackFileSizeThreshold = Optional.empty(); private Session session; /** Creates a new builder instance. Package-private, use Dataset.write() instead. */ @@ -282,6 +284,30 @@ public WriteDatasetBuilder targetBases(List targetBases) { return this; } + /** + * Sets whether to allow external blob URIs outside registered base paths. + * + * @param allowExternalBlobOutsideBases Whether to allow external blob URIs outside bases + * @return this builder instance + */ + public WriteDatasetBuilder allowExternalBlobOutsideBases(boolean allowExternalBlobOutsideBases) { + this.allowExternalBlobOutsideBases = Optional.of(allowExternalBlobOutsideBases); + return this; + } + + /** + * Sets the maximum size in bytes for blob v2 pack (.blob) sidecar files. + * + *

    When a pack file reaches this size, a new one is started. If not set, defaults to 1 GiB. + * + * @param blobPackFileSizeThreshold maximum pack file size in bytes + * @return this builder instance + */ + public WriteDatasetBuilder blobPackFileSizeThreshold(long blobPackFileSizeThreshold) { + this.blobPackFileSizeThreshold = Optional.of(blobPackFileSizeThreshold); + return this; + } + /** * Sets the session to share caches with other datasets. * @@ -414,6 +440,8 @@ private Dataset executeWithNamespaceClient() { initialBases.ifPresent(paramsBuilder::withInitialBases); targetBases.ifPresent(paramsBuilder::withTargetBases); + allowExternalBlobOutsideBases.ifPresent(paramsBuilder::withAllowExternalBlobOutsideBases); + blobPackFileSizeThreshold.ifPresent(paramsBuilder::withBlobPackFileSizeThreshold); WriteParams params = paramsBuilder.build(); @@ -446,6 +474,8 @@ private Dataset executeWithUri() { dataStorageVersion.ifPresent(paramsBuilder::withDataStorageVersion); initialBases.ifPresent(paramsBuilder::withInitialBases); targetBases.ifPresent(paramsBuilder::withTargetBases); + allowExternalBlobOutsideBases.ifPresent(paramsBuilder::withAllowExternalBlobOutsideBases); + blobPackFileSizeThreshold.ifPresent(paramsBuilder::withBlobPackFileSizeThreshold); WriteParams params = paramsBuilder.build(); diff --git a/java/src/main/java/org/lance/WriteParams.java b/java/src/main/java/org/lance/WriteParams.java index 6f5d4545ca9..970007f442f 100644 --- a/java/src/main/java/org/lance/WriteParams.java +++ b/java/src/main/java/org/lance/WriteParams.java @@ -41,6 +41,7 @@ public enum WriteMode { private final Optional> initialBases; private final Optional> targetBases; private final Optional allowExternalBlobOutsideBases; + private final Optional blobPackFileSizeThreshold; private WriteParams( Optional maxRowsPerFile, @@ -53,7 +54,8 @@ private WriteParams( Map storageOptions, Optional> initialBases, Optional> targetBases, - Optional allowExternalBlobOutsideBases) { + Optional allowExternalBlobOutsideBases, + Optional blobPackFileSizeThreshold) { this.maxRowsPerFile = maxRowsPerFile; this.maxRowsPerGroup = maxRowsPerGroup; this.maxBytesPerFile = maxBytesPerFile; @@ -65,6 +67,7 @@ private WriteParams( this.initialBases = initialBases; this.targetBases = targetBases; this.allowExternalBlobOutsideBases = allowExternalBlobOutsideBases; + this.blobPackFileSizeThreshold = blobPackFileSizeThreshold; } public Optional getMaxRowsPerFile() { @@ -124,6 +127,17 @@ public Optional getAllowExternalBlobOutsideBases() { return allowExternalBlobOutsideBases; } + /** + * Get the maximum size in bytes for blob v2 pack (.blob) sidecar files. + * + *

    When a pack file reaches this size, a new one is started. If not set, defaults to 1 GiB. + * + * @return Optional containing the max pack file size in bytes, or empty if not set + */ + public Optional getBlobPackFileSizeThreshold() { + return blobPackFileSizeThreshold; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -148,6 +162,7 @@ public static class Builder { private Optional> initialBases = Optional.empty(); private Optional> targetBases = Optional.empty(); private Optional allowExternalBlobOutsideBases = Optional.empty(); + private Optional blobPackFileSizeThreshold = Optional.empty(); public Builder withMaxRowsPerFile(int maxRowsPerFile) { this.maxRowsPerFile = Optional.of(maxRowsPerFile); @@ -214,6 +229,19 @@ public Builder withAllowExternalBlobOutsideBases(boolean allow) { return this; } + /** + * Set the maximum size in bytes for blob v2 pack (.blob) sidecar files. + * + *

    When a pack file reaches this size, a new one is started. If not set, defaults to 1 GiB. + * + * @param maxBytes maximum pack file size in bytes + * @return this builder + */ + public Builder withBlobPackFileSizeThreshold(long maxBytes) { + this.blobPackFileSizeThreshold = Optional.of(maxBytes); + return this; + } + public WriteParams build() { return new WriteParams( maxRowsPerFile, @@ -226,7 +254,8 @@ public WriteParams build() { storageOptions, initialBases, targetBases, - allowExternalBlobOutsideBases); + allowExternalBlobOutsideBases, + blobPackFileSizeThreshold); } } } diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index 3cb783c6536..3c8668abb84 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -6283,6 +6283,7 @@ def write_dataset( target_bases: Optional[List[str]] = None, external_blob_mode: Literal["reference", "ingest"] = "reference", allow_external_blob_outside_bases: bool = False, + blob_pack_file_size_threshold: Optional[int] = None, namespace_client: Optional[LanceNamespace] = None, table_id: Optional[List[str]] = None, ) -> LanceDataset: @@ -6388,6 +6389,9 @@ def write_dataset( If False, external blob URIs must map to the dataset root or a registered base path. If True, external blob URIs outside registered bases are allowed. This option only applies when ``external_blob_mode="reference"``. + blob_pack_file_size_threshold: optional, int, default None + Maximum size in bytes for blob v2 pack (.blob) sidecar files. When a pack + file reaches this size, a new one is started. If not set, defaults to 1 GiB. namespace_client : optional, LanceNamespace A namespace client from which to fetch table location and storage options. Must be provided together with `table_id`. Cannot be used with `uri`. @@ -6516,6 +6520,7 @@ def write_dataset( "target_bases": target_bases, "external_blob_mode": external_blob_mode, "allow_external_blob_outside_bases": allow_external_blob_outside_bases, + "blob_pack_file_size_threshold": blob_pack_file_size_threshold, } # Add namespace_client and table_id for storage options provider and managed diff --git a/python/src/dataset.rs b/python/src/dataset.rs index e1144788a7f..7e139446819 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -3517,6 +3517,9 @@ pub fn get_write_params(options: &Bound<'_, PyDict>) -> PyResult(options, "blob_pack_file_size_threshold")? { + p = p.with_blob_pack_file_size_threshold(max_bytes); + } // Handle properties if let Some(props) = diff --git a/rust/lance/src/dataset/blob.rs b/rust/lance/src/dataset/blob.rs index 33c365ee534..7a736ec4928 100644 --- a/rust/lance/src/dataset/blob.rs +++ b/rust/lance/src/dataset/blob.rs @@ -308,12 +308,16 @@ impl BlobPreprocessor { external_blob_mode: ExternalBlobMode, source_store_registry: Arc, source_store_params: ObjectStoreParams, + pack_file_size_threshold: Option, ) -> Self { - let pack_writer = PackWriter::new( + let mut pack_writer = PackWriter::new( object_store.clone(), data_dir.clone(), data_file_key.clone(), ); + if let Some(max_bytes) = pack_file_size_threshold { + pack_writer.max_pack_size = max_bytes; + } let arrow_schema = arrow_schema::Schema::from(schema); let fields = arrow_schema.fields(); let blob_v2_cols = fields.iter().map(|field| field.is_blob_v2()).collect(); @@ -2399,6 +2403,7 @@ mod tests { ExternalBlobMode::Reference, Arc::new(ObjectStoreRegistry::default()), ObjectStoreParams::default(), + None, ); let mut blob_builder = BlobArrayBuilder::new(1); diff --git a/rust/lance/src/dataset/write.rs b/rust/lance/src/dataset/write.rs index 206bbd3320c..49e645bbfb5 100644 --- a/rust/lance/src/dataset/write.rs +++ b/rust/lance/src/dataset/write.rs @@ -298,6 +298,11 @@ pub struct WriteParams { /// The strategy used when writing external blob URIs. pub external_blob_mode: ExternalBlobMode, + + /// Maximum size in bytes for blob v2 pack (.blob) sidecar files. + /// When a pack file reaches this size, a new one is started. + /// If not set, defaults to 1 GiB. + pub blob_pack_file_size_threshold: Option, } impl Default for WriteParams { @@ -325,6 +330,7 @@ impl Default for WriteParams { target_base_names_or_paths: None, allow_external_blob_outside_bases: false, external_blob_mode: ExternalBlobMode::Reference, + blob_pack_file_size_threshold: None, } } } @@ -419,6 +425,14 @@ impl WriteParams { ..self } } + + /// Set the maximum size in bytes for blob v2 pack (.blob) sidecar files. + pub fn with_blob_pack_file_size_threshold(self, max_bytes: usize) -> Self { + Self { + blob_pack_file_size_threshold: Some(max_bytes), + ..self + } + } } /// Writes the given data to the dataset and returns fragments. @@ -491,6 +505,7 @@ pub async fn do_write_fragments( params.external_blob_mode, source_store_registry, source_store_params, + params.blob_pack_file_size_threshold, ); let mut writer: Option> = None; let mut num_rows_in_current_file = 0; @@ -999,6 +1014,7 @@ struct WriterOptions { external_blob_mode: ExternalBlobMode, source_store_registry: Arc, source_store_params: ObjectStoreParams, + blob_pack_file_size_threshold: Option, } async fn open_writer_with_options( @@ -1016,6 +1032,7 @@ async fn open_writer_with_options( external_blob_mode, source_store_registry, source_store_params, + blob_pack_file_size_threshold, } = options; let data_file_key = generate_random_filename(); @@ -1063,6 +1080,7 @@ async fn open_writer_with_options( external_blob_mode, source_store_registry, source_store_params, + blob_pack_file_size_threshold, )) } else { None @@ -1105,6 +1123,7 @@ struct WriterGenerator { external_blob_mode: ExternalBlobMode, source_store_registry: Arc, source_store_params: ObjectStoreParams, + blob_pack_file_size_threshold: Option, /// Counter for round-robin selection next_base_index: AtomicUsize, } @@ -1122,6 +1141,7 @@ impl WriterGenerator { external_blob_mode: ExternalBlobMode, source_store_registry: Arc, source_store_params: ObjectStoreParams, + blob_pack_file_size_threshold: Option, ) -> Self { Self { object_store, @@ -1134,6 +1154,7 @@ impl WriterGenerator { external_blob_mode, source_store_registry, source_store_params, + blob_pack_file_size_threshold, next_base_index: AtomicUsize::new(0), } } @@ -1167,6 +1188,7 @@ impl WriterGenerator { external_blob_mode: self.external_blob_mode, source_store_registry: self.source_store_registry.clone(), source_store_params: self.source_store_params.clone(), + blob_pack_file_size_threshold: self.blob_pack_file_size_threshold, }, ) .await? @@ -1184,6 +1206,7 @@ impl WriterGenerator { external_blob_mode: self.external_blob_mode, source_store_registry: self.source_store_registry.clone(), source_store_params: self.source_store_params.clone(), + blob_pack_file_size_threshold: self.blob_pack_file_size_threshold, }, ) .await? @@ -1820,6 +1843,7 @@ mod tests { ExternalBlobMode::Reference, Arc::new(ObjectStoreRegistry::default()), ObjectStoreParams::default(), + None, ); // Create a writer @@ -1937,6 +1961,7 @@ mod tests { ExternalBlobMode::Reference, Arc::new(ObjectStoreRegistry::default()), ObjectStoreParams::default(), + None, ); // Create test batch From 086833a221a382ae227eb47a58ac2281d094bfbe Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Tue, 14 Apr 2026 22:07:19 -0700 Subject: [PATCH 186/206] fix: bump jieba-rs to 0.9.0 to fix build-no-lock CI (#6518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Bump `jieba-rs` from 0.8.1 to 0.9.0 to fix the `build-no-lock` CI job - The `core2` crate v0.4.0 was yanked from crates.io, breaking fresh dependency resolution (`jieba-rs` → `include-flate` → `libflate` → `core2`) - `jieba-rs` 0.9.0 drops the `include-flate`/`libflate`/`core2` chain entirely, removing 9 transitive dependencies with no API changes ## Test plan - [x] `cargo check -p lance-index --features tokenizer-jieba` passes - [x] Verified build succeeds without `Cargo.lock` (simulating the CI job) - [ ] CI `build-no-lock` job passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- Cargo.lock | 115 ++-------------------------------------------- Cargo.toml | 2 +- python/Cargo.lock | 115 ++-------------------------------------------- 3 files changed, 9 insertions(+), 223 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dfe4ac91599..1a8ffd37012 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,12 +23,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "adler32" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" - [[package]] name = "aes" version = "0.8.4" @@ -1761,15 +1755,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpp_demangle" version = "0.5.1" @@ -2074,12 +2059,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dary_heap" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" - [[package]] name = "dashmap" version = "6.1.0" @@ -4252,39 +4231,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "include-flate" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a05fb00d9abc625268e0573a519506b264a7d6965de09bac13201bfb44e723d" -dependencies = [ - "include-flate-codegen", - "include-flate-compress", -] - -[[package]] -name = "include-flate-codegen" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92c3c319a7527668538a8530c541e74e881e94c4f41e1425622d0a41c16468af" -dependencies = [ - "include-flate-compress", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "include-flate-compress" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0bd9ea81b94169d61c5a397e9faef02153d3711fc62d3270bcde3ac85380d9" -dependencies = [ - "libflate", - "zstd", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -4456,21 +4402,20 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jieba-macros" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348294e44ee7e3c42685da656490f8febc7359632544019621588902216da95c" +checksum = "a29cfc5dcd898604c6f80363411fa6b6b08e27d1d253d6225b9cb6702ea02fc0" dependencies = [ "phf_codegen", ] [[package]] name = "jieba-rs" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "766bd7012aa5ba49411ebdf4e93bddd59b182d2918e085d58dec5bb9b54b7105" +checksum = "3245d6e9d1d5facbd6a23848d6b67e3439738ccbb4fa5a3d65da315ba1a910a2" dependencies = [ "cedarwood", - "include-flate", "jieba-macros", "phf 0.13.1", "regex", @@ -5395,30 +5340,6 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" -[[package]] -name = "libflate" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74" -dependencies = [ - "adler32", - "core2", - "crc32fast", - "dary_heap", - "libflate_lz77", -] - -[[package]] -name = "libflate_lz77" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" -dependencies = [ - "core2", - "hashbrown 0.16.1", - "rle-decode-fast", -] - [[package]] name = "libm" version = "0.2.16" @@ -6833,28 +6754,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -7459,12 +7358,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rle-decode-fast" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" - [[package]] name = "roaring" version = "0.11.3" diff --git a/Cargo.toml b/Cargo.toml index e89bd071388..224ec4ee2fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,7 +150,7 @@ humantime = "2.2.0" hyperloglogplus = { version = "0.4.1", features = ["const-loop"] } io-uring = "0.7" itertools = "0.13" -jieba-rs = { version = "0.8.1", default-features = false } +jieba-rs = { version = "0.9.0", default-features = false } jsonb = { version = "0.5.3", default-features = false, features = ["databend"] } libm = "0.2.15" log = "0.4" diff --git a/python/Cargo.lock b/python/Cargo.lock index 189bc5c25f5..a7f860cbd96 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -56,12 +56,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "adler32" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" - [[package]] name = "aes" version = "0.8.4" @@ -1427,15 +1421,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "core_extensions" version = "1.5.4" @@ -1646,12 +1631,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dary_heap" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" - [[package]] name = "dashmap" version = "6.1.0" @@ -3603,39 +3582,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "include-flate" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a05fb00d9abc625268e0573a519506b264a7d6965de09bac13201bfb44e723d" -dependencies = [ - "include-flate-codegen", - "include-flate-compress", -] - -[[package]] -name = "include-flate-codegen" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92c3c319a7527668538a8530c541e74e881e94c4f41e1425622d0a41c16468af" -dependencies = [ - "include-flate-compress", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "include-flate-compress" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0bd9ea81b94169d61c5a397e9faef02153d3711fc62d3270bcde3ac85380d9" -dependencies = [ - "libflate", - "zstd", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -3741,21 +3687,20 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jieba-macros" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348294e44ee7e3c42685da656490f8febc7359632544019621588902216da95c" +checksum = "a29cfc5dcd898604c6f80363411fa6b6b08e27d1d253d6225b9cb6702ea02fc0" dependencies = [ "phf_codegen", ] [[package]] name = "jieba-rs" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "766bd7012aa5ba49411ebdf4e93bddd59b182d2918e085d58dec5bb9b54b7105" +checksum = "3245d6e9d1d5facbd6a23848d6b67e3439738ccbb4fa5a3d65da315ba1a910a2" dependencies = [ "cedarwood", - "include-flate", "jieba-macros", "phf 0.13.1", "regex", @@ -4457,30 +4402,6 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" -[[package]] -name = "libflate" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74" -dependencies = [ - "adler32", - "core2", - "crc32fast", - "dary_heap", - "libflate_lz77", -] - -[[package]] -name = "libflate_lz77" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" -dependencies = [ - "core2", - "hashbrown 0.16.1", - "rle-decode-fast", -] - [[package]] name = "libloading" version = "0.7.4" @@ -5600,28 +5521,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -6246,12 +6145,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rle-decode-fast" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" - [[package]] name = "roaring" version = "0.11.3" From 690a7c291c359844a3eddf20aeca5074b45eb9a4 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 15 Apr 2026 13:46:44 +0800 Subject: [PATCH 187/206] docs: tighten python environment workflow guidance (#6520) This tightens the repository's environment guidance so language-specific tasks must follow the documented workflow before reporting missing tools or dependencies. For Python work, the docs now make `uv sync --extra tests --extra dev` and `uv run ...` mandatory, and explicitly call out the common failure mode where slow `uv sync` is interrupted or global Python is used instead. --- AGENTS.md | 6 ++++++ python/AGENTS.md | 27 ++++++++++++++++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bb7ff391cea..df823a8ac5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,12 @@ Key technical traits: async-first (tokio), Arrow-native, versioned writes with m See [python/AGENTS.md](python/AGENTS.md) and [java/AGENTS.md](java/AGENTS.md). +## Language-Specific Environment Contract + +- For language-specific tasks, always follow the environment and command rules in the corresponding subdirectory guide before running build, test, lint, format, or tooling commands. +- Do not substitute a different environment manager or toolchain just because a command appears missing, unavailable, or slow. +- If a language-specific command fails outside the documented workflow, treat that as an environment usage mistake first. Fix the environment usage, rerun with the prescribed commands, and only then conclude that a dependency or tool is unavailable. + ### Integration Testing ```bash diff --git a/python/AGENTS.md b/python/AGENTS.md index 5cb45aa69f9..9e74d7c0656 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -4,15 +4,19 @@ Also see [root AGENTS.md](../AGENTS.md) for cross-language standards. ## Commands -* Environment: prefer `uv` for local Python environment setup; start with `uv sync --extra tests --extra dev`, and add other extras such as `benchmarks`, `torch`, or `geo` only when needed. -* Command execution: after initializing the environment with `uv`, either activate the environment or use `uv run ...`; keep `Makefile` targets and CI commands environment-manager agnostic unless CI has been migrated explicitly. -* Build time expectations: `uv sync` and `uv run maturin develop` build the local `pylance` Rust extension as part of the environment workflow. This can be slow, especially on the first run or after Rust dependency changes; treat that as expected and do not switch to a different environment manager just because the build takes time. -* Build: `maturin develop` (required after Rust changes) -* Test: `make test` -* Run single test: `pytest python/tests/.py::` -* Doctest: `make doctest` -* Lint: `make lint` -* Format: `make format` +* Environment: use `uv` for all local Python environment setup in this repository. +* First step in every new worktree or fresh checkout: run `uv sync --extra tests --extra dev` from `python/` before any Python command. Add other extras such as `benchmarks`, `torch`, or `geo` only when needed. +* `uv sync` builds the local `pylance` Rust extension as part of environment setup. This can take a long time. Start it early, let it finish, and do not interrupt it or switch to a different setup path just because the build is slow. +* Command execution: always use `uv run ...` for Python-related repository commands. Do not rely on a globally activated environment. +* Never invoke bare `python`, `pytest`, `pip`, `maturin`, `make test`, `make doctest`, `make lint`, or `make format` for repository work. +* If a Python command fails outside `uv run`, that does not count as a dependency or test failure. Fix the environment usage first and rerun correctly. +* Build time expectations: `uv sync` and `uv run maturin develop` build the local `pylance` Rust extension as part of the environment workflow. This can be slow, especially on the first run or after Rust dependency changes; treat that as expected and do not switch to a different environment manager or shortcut around the build just because it takes time. +* Build: `uv run maturin develop` (required after Rust changes) +* Test: `uv run make test` +* Run single test: `uv run pytest python/tests/.py::` +* Doctest: `uv run make doctest` +* Lint: `uv run make lint` +* Format: `uv run make format` ## API Design @@ -26,3 +30,8 @@ Also see [root AGENTS.md](../AGENTS.md) for cross-language standards. - Use `@pytest.mark.parametrize` for tests that differ only in inputs — extract shared setup into helpers. - Add tests to existing `test_{module}.py` files rather than creating new test files for the same module. - Replace `print()` in tests with `assert` statements. + +## Common Failure Mode + +- A missing module or missing command error from bare `python`, `pytest`, `pip`, `maturin`, or `make` is usually an environment usage mistake, not a repository issue. +- Before reporting a Python dependency as unavailable, verify that `uv sync --extra tests --extra dev` has been run in the current worktree and that the failing command was executed with `uv run ...`. From e5ceacb8d77f3a69461723a77dd93aa4459c2fb8 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 15 Apr 2026 13:52:55 +0800 Subject: [PATCH 188/206] refactor: use exact base-scoped store bindings (#6422) This changes per-base runtime configuration to use exact `ObjectStoreParams` bindings keyed by `BasePath.path` instead of per-base storage option overrides. Dataset-level and write-level store params now act only as fallbacks, while reads, target-base writes, and external blob resolution all consult the same base-scoped binding model. This keeps provider-specific runtime state out of the manifest and follows the direction in discussion #6307 to keep `BasePath` focused on identity. --- rust/lance/src/dataset.rs | 28 ++++- rust/lance/src/dataset/blob.rs | 94 ++++++++++++++-- rust/lance/src/dataset/builder.rs | 84 +++++++++++---- rust/lance/src/dataset/tests/dataset_io.rs | 65 ++++++++++- rust/lance/src/dataset/write.rs | 119 +++++++++++++++++++-- rust/lance/src/dataset/write/commit.rs | 1 + 6 files changed, 350 insertions(+), 41 deletions(-) diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index b45aee6b814..9763f8bb68a 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -181,6 +181,8 @@ pub struct Dataset { /// Object store parameters used when opening this dataset. /// These are used when creating object stores for additional base paths. pub(crate) store_params: Option>, + /// Optional runtime-only object store parameters keyed by base path URI. + pub(crate) base_store_params: Option>>, } impl std::fmt::Debug for Dataset { @@ -190,6 +192,7 @@ impl std::fmt::Debug for Dataset { .field("base", &self.base) .field("version", &self.manifest.version) .field("cache_num_items", &self.session.approx_num_items()) + .field("base_store_params", &self.base_store_params.is_some()) .finish() } } @@ -584,6 +587,7 @@ impl Dataset { self.commit_handler.clone(), self.file_reader_options.clone(), self.store_params.as_deref().cloned(), + self.base_store_params.clone(), ) } @@ -702,6 +706,7 @@ impl Dataset { commit_handler: Arc, file_reader_options: Option, store_params: Option, + base_store_params: Option>>, ) -> Result { let refs = Refs::new( object_store.clone(), @@ -729,6 +734,7 @@ impl Dataset { index_cache, file_reader_options, store_params: store_params.map(Box::new), + base_store_params, }) } @@ -1638,6 +1644,23 @@ impl Dataset { cloned } + fn store_params_for_base( + &self, + base_path: Option<&lance_table::format::BasePath>, + ) -> ObjectStoreParams { + // Base-specific bindings are exact ObjectStoreParams keyed by + // `BasePath.path`. If a base has no explicit binding then reads fall back + // to the dataset-level default store params. + base_path + .and_then(|base_path| { + self.base_store_params + .as_ref() + .and_then(|params| params.get(&base_path.path)) + }) + .cloned() + .unwrap_or_else(|| self.store_params.as_deref().cloned().unwrap_or_default()) + } + /// Returns the initial storage options used when opening this dataset, if any. /// /// This returns the static initial options without triggering any refresh. @@ -1848,11 +1871,12 @@ impl Dataset { let base_path = self.manifest.base_paths.get(&base_id).ok_or_else(|| { Error::invalid_input(format!("Dataset base path with ID {} not found", base_id)) })?; + let store_params = self.store_params_for_base(Some(base_path)); let (store, _) = ObjectStore::from_uri_and_params( self.session.store_registry(), &base_path.path, - &self.store_params.as_deref().cloned().unwrap_or_default(), + &store_params, ) .await?; @@ -2673,6 +2697,7 @@ pub(crate) fn load_new_transactions(dataset: &Dataset) -> NewTransactionResult<' dataset.commit_handler.clone(), dataset.file_reader_options.clone(), dataset.store_params.as_deref().cloned(), + dataset.base_store_params.clone(), )?; let loaded = Arc::new(dataset_version.read_transaction().await?.ok_or_else(|| { @@ -2704,6 +2729,7 @@ pub(crate) fn load_new_transactions(dataset: &Dataset) -> NewTransactionResult<' dataset.commit_handler.clone(), dataset.file_reader_options.clone(), dataset.store_params.as_deref().cloned(), + dataset.base_store_params.clone(), ) } else { // If we didn't get the latest manifest, we can still return the dataset diff --git a/rust/lance/src/dataset/blob.rs b/rust/lance/src/dataset/blob.rs index 7a736ec4928..29ed15533aa 100644 --- a/rust/lance/src/dataset/blob.rs +++ b/rust/lance/src/dataset/blob.rs @@ -45,25 +45,23 @@ pub(super) struct ExternalBaseCandidate { pub base_id: u32, pub store_prefix: String, pub base_path: Path, + pub store_params: ObjectStoreParams, } #[derive(Debug)] pub(super) struct ExternalBaseResolver { candidates: Vec, store_registry: Arc, - store_params: ObjectStoreParams, } impl ExternalBaseResolver { pub(super) fn new( candidates: Vec, store_registry: Arc, - store_params: ObjectStoreParams, ) -> Self { Self { candidates, store_registry, - store_params, } } @@ -71,13 +69,13 @@ impl ExternalBaseResolver { &self, uri: &str, ) -> Result> { - let uri_store_prefix = self - .store_registry - .calculate_object_store_prefix(uri, self.store_params.storage_options())?; let uri_path = ObjectStore::extract_path_from_uri(self.store_registry.clone(), uri)?; let mut best_match: Option<(usize, ResolvedExternalBase)> = None; for candidate in &self.candidates { + let uri_store_prefix = self + .store_registry + .calculate_object_store_prefix(uri, candidate.store_params.storage_options())?; if candidate.store_prefix != uri_store_prefix { continue; } @@ -1325,6 +1323,7 @@ fn data_file_key_from_path(path: &str) -> &str { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::sync::Arc; use arrow::{ @@ -1342,7 +1341,9 @@ mod tests { ARROW_EXT_NAME_KEY, BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY, BLOB_V2_EXT_NAME, DataTypeExt, }; use lance_core::datatypes::BlobKind; - use lance_io::object_store::{ObjectStore, ObjectStoreParams, ObjectStoreRegistry}; + use lance_io::object_store::{ + ObjectStore, ObjectStoreParams, ObjectStoreRegistry, StorageOptionsAccessor, + }; use lance_io::stream::RecordBatchStream; use lance_table::format::BasePath; use object_store::{ @@ -1358,7 +1359,7 @@ mod tests { use lance_datagen::{BatchCount, RowCount, array}; use lance_file::version::LanceFileVersion; - use super::{BlobFile, data_file_key_from_path}; + use super::{BlobFile, ExternalBaseCandidate, ExternalBaseResolver, data_file_key_from_path}; use crate::{ Dataset, blob::{BlobArrayBuilder, blob_field}, @@ -1378,9 +1379,86 @@ mod tests { expected: Vec, } + #[cfg(feature = "azure")] + fn azure_store_params(account_name: &str) -> ObjectStoreParams { + ObjectStoreParams { + storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options( + HashMap::from([ + ("account_name".to_string(), account_name.to_string()), + ("account_key".to_string(), "dGVzdA==".to_string()), + ]), + ))), + ..Default::default() + } + } + #[derive(Debug)] struct RejectEmptyRangeObjectStore; + #[cfg(feature = "azure")] + #[tokio::test] + async fn test_external_base_resolver_uses_candidate_store_params() { + let store_registry = Arc::new(ObjectStoreRegistry::default()); + let base_a = BasePath::new( + 1, + "az://container/path-a".to_string(), + Some("base-a".to_string()), + false, + ); + let base_b = BasePath::new( + 2, + "az://container/path-b".to_string(), + Some("base-b".to_string()), + false, + ); + + let base_a_params = azure_store_params("account-a"); + let base_b_params = azure_store_params("account-b"); + + let (store_a, extracted_a) = + ObjectStore::from_uri_and_params(store_registry.clone(), &base_a.path, &base_a_params) + .await + .unwrap(); + let (store_b, extracted_b) = + ObjectStore::from_uri_and_params(store_registry.clone(), &base_b.path, &base_b_params) + .await + .unwrap(); + + let resolver = ExternalBaseResolver::new( + vec![ + ExternalBaseCandidate { + base_id: base_a.id, + store_prefix: store_a.store_prefix.clone(), + base_path: extracted_a, + store_params: base_a_params, + }, + ExternalBaseCandidate { + base_id: base_b.id, + store_prefix: store_b.store_prefix.clone(), + base_path: extracted_b, + store_params: base_b_params, + }, + ], + store_registry, + ); + + let resolved_a = resolver + .resolve_external_uri("az://container/path-a/file.bin") + .await + .unwrap() + .unwrap(); + let resolved_b = resolver + .resolve_external_uri("az://container/path-b/file.bin") + .await + .unwrap() + .unwrap(); + + assert_eq!(resolved_a.base_id, 1); + assert_eq!(resolved_a.relative_path, "file.bin"); + assert_eq!(resolved_b.base_id, 2); + assert_eq!(resolved_b.relative_path, "file.bin"); + } + impl std::fmt::Display for RejectEmptyRangeObjectStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "RejectEmptyRangeObjectStore") diff --git a/rust/lance/src/dataset/builder.rs b/rust/lance/src/dataset/builder.rs index 2d995d3b78f..393ff45c4ea 100644 --- a/rust/lance/src/dataset/builder.rs +++ b/rust/lance/src/dataset/builder.rs @@ -51,6 +51,8 @@ pub struct DatasetBuilder { file_reader_options: Option, /// Storage options that override user-provided options (e.g., from namespace client) storage_options_override: Option>, + /// Runtime-only exact object store bindings keyed by base path URI. + base_store_params: HashMap, } impl std::fmt::Debug for DatasetBuilder { @@ -68,6 +70,7 @@ impl std::fmt::Debug for DatasetBuilder { "storage_options_override", &self.storage_options_override.is_some(), ) + .field("base_store_params", &!self.base_store_params.is_empty()) .finish() } } @@ -86,6 +89,7 @@ impl DatasetBuilder { manifest: None, file_reader_options: None, storage_options_override: None, + base_store_params: HashMap::new(), } } @@ -448,6 +452,22 @@ impl DatasetBuilder { self } + /// Set runtime-only object store params for a specific registered base path. + /// + /// These params are not persisted in the manifest. They are used as-is + /// whenever the dataset resolves an object store for the given + /// `BasePath.path`. Dataset-level store params remain the fallback for bases + /// without an explicit binding. + pub fn with_base_store_params( + mut self, + base_path: impl AsRef, + store_params: ObjectStoreParams, + ) -> Self { + self.base_store_params + .insert(base_path.as_ref().to_string(), store_params); + self + } + /// Set options based on [ReadParams]. pub fn with_read_params(mut self, read_params: ReadParams) -> Self { self = self @@ -496,6 +516,12 @@ impl DatasetBuilder { self } + /// Set exact object store params used as the dataset-level default binding. + pub fn with_store_params(mut self, store_params: ObjectStoreParams) -> Self { + self.options = store_params; + self + } + /// Build a lance object store for the given config pub async fn build_object_store( self, @@ -566,30 +592,41 @@ impl DatasetBuilder { } } + // Runtime per-base overrides are supplied as storage options, but the dataset + // ultimately resolves object stores from ObjectStoreParams. Normalize once in + // the builder so reads only need to look up the prepared params by base path. + fn merge_store_params_with_storage_options( + params: &ObjectStoreParams, + override_options: &HashMap, + ) -> ObjectStoreParams { + if override_options.is_empty() { + return params.clone(); + } + + let mut merged_params = params.clone(); + let mut merged_options = merged_params.storage_options().cloned().unwrap_or_default(); + merged_options.extend(override_options.clone()); + + let storage_options_accessor = match merged_params + .storage_options_accessor + .as_ref() + .and_then(|accessor| accessor.provider().cloned()) + { + Some(provider) => Arc::new(StorageOptionsAccessor::with_initial_and_provider( + merged_options, + provider, + )), + None => Arc::new(StorageOptionsAccessor::with_static_options(merged_options)), + }; + merged_params.storage_options_accessor = Some(storage_options_accessor); + merged_params + } + async fn load_impl(mut self) -> Result { // Apply storage_options_override to merge namespace client options with any existing accessor if let Some(override_opts) = self.storage_options_override.take() { - // Get existing options and merge - let mut merged_opts = self.options.storage_options().cloned().unwrap_or_default(); - // Override with namespace client storage options - they take precedence - merged_opts.extend(override_opts); - - // Update accessor with merged options - if let Some(accessor) = &self.options.storage_options_accessor { - if let Some(provider) = accessor.provider().cloned() { - self.options.storage_options_accessor = Some(Arc::new( - StorageOptionsAccessor::with_initial_and_provider(merged_opts, provider), - )); - } else { - self.options.storage_options_accessor = Some(Arc::new( - StorageOptionsAccessor::with_static_options(merged_opts), - )); - } - } else { - self.options.storage_options_accessor = Some(Arc::new( - StorageOptionsAccessor::with_static_options(merged_opts), - )); - } + self.options = + Self::merge_store_params_with_storage_options(&self.options, &override_opts); } let index_cache_backend = self.index_cache_backend.take(); @@ -617,6 +654,8 @@ impl DatasetBuilder { let file_reader_options = self.file_reader_options.clone(); let store_params = self.options.clone(); + let base_store_params = (!self.base_store_params.is_empty()) + .then(|| Arc::new(std::mem::take(&mut self.base_store_params))); let (object_store, base_path, commit_handler) = self.build_object_store().await?; // Two cases that need to check out after loading the manifest: @@ -669,6 +708,7 @@ impl DatasetBuilder { base_path, commit_handler, Some(store_params), + base_store_params, ) .await?; @@ -706,6 +746,7 @@ impl DatasetBuilder { base_path: Path, commit_handler: Arc, store_params: Option, + base_store_params: Option>>, ) -> Result { let (manifest, location) = if let Some(mut manifest) = manifest { let location = commit_handler @@ -768,6 +809,7 @@ impl DatasetBuilder { commit_handler, file_reader_options, store_params, + base_store_params, ) } } diff --git a/rust/lance/src/dataset/tests/dataset_io.rs b/rust/lance/src/dataset/tests/dataset_io.rs index dbe03ed5122..306580f4aaa 100644 --- a/rust/lance/src/dataset/tests/dataset_io.rs +++ b/rust/lance/src/dataset/tests/dataset_io.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::collections::HashMap; use std::sync::Arc; use std::vec; @@ -35,12 +36,13 @@ use lance_datagen::{BatchCount, RowCount, array, gen_batch}; use lance_file::version::LanceFileVersion; use lance_io::assert_io_eq; use lance_table::feature_flags; +use lance_table::format::BasePath; use crate::index::DatasetIndexExt; use futures::TryStreamExt; use lance_index::IndexType; use lance_index::scalar::ScalarIndexParams; -use lance_io::object_store::{ObjectStore, ObjectStoreParams}; +use lance_io::object_store::{ObjectStore, ObjectStoreParams, StorageOptionsAccessor}; use lance_io::utils::tracking_store::IOTracker; use lance_table::io::manifest::read_manifest; use object_store::path::Path; @@ -167,6 +169,67 @@ async fn test_with_object_store_enables_isolated_per_request_io_tracking() { assert_eq!(tracker_b.incremental_stats().read_iops, 0); } +#[cfg(feature = "azure")] +#[tokio::test] +async fn test_object_store_for_base_uses_runtime_base_store_params() { + let test_dir = TempStdDir::default(); + create_file(&test_dir, WriteMode::Create, LanceFileVersion::Stable).await; + let uri = test_dir.to_str().unwrap(); + let dataset = Arc::new(Dataset::open(uri).await.unwrap()); + + let base_a = BasePath::new( + 1, + "az://container/path-a".to_string(), + Some("base-a".to_string()), + true, + ); + let base_b = BasePath::new( + 2, + "az://container/path-b".to_string(), + Some("base-b".to_string()), + true, + ); + dataset + .add_bases(vec![base_a.clone(), base_b.clone()], None) + .await + .unwrap(); + + let base_a_store_params = ObjectStoreParams { + storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options( + HashMap::from([ + ("account_name".to_string(), "account-a".to_string()), + ("account_key".to_string(), "dGVzdA==".to_string()), + ]), + ))), + ..Default::default() + }; + let default_store_params = ObjectStoreParams { + storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options( + HashMap::from([ + ("account_name".to_string(), "account-b".to_string()), + ("account_key".to_string(), "dGVzdA==".to_string()), + ]), + ))), + ..Default::default() + }; + + let dataset = DatasetBuilder::from_uri(uri) + .with_store_params(default_store_params) + .with_base_store_params(&base_a.path, base_a_store_params) + .load() + .await + .unwrap(); + + let store_a = dataset.object_store_for_base(1).await.unwrap(); + let store_a_again = dataset.object_store_for_base(1).await.unwrap(); + let store_b = dataset.object_store_for_base(2).await.unwrap(); + + assert!(Arc::ptr_eq(&store_a, &store_a_again)); + assert!(!Arc::ptr_eq(&store_a, &store_b)); + assert_eq!(store_a.store_prefix, "az$container@account-a"); + assert_eq!(store_b.store_prefix, "az$container@account-b"); +} + #[rstest] #[lance_test_macros::test(tokio::test)] async fn test_create_dataset( diff --git a/rust/lance/src/dataset/write.rs b/rust/lance/src/dataset/write.rs index 49e645bbfb5..f31b1e2bf2a 100644 --- a/rust/lance/src/dataset/write.rs +++ b/rust/lance/src/dataset/write.rs @@ -213,6 +213,8 @@ pub struct WriteParams { pub store_params: Option, + pub base_store_params: Option>, + pub progress: Arc, /// Optional callback invoked after each batch is written. @@ -315,6 +317,7 @@ impl Default for WriteParams { max_bytes_per_file: 90 * 1024 * 1024 * 1024, // 90 GB mode: WriteMode::Create, store_params: None, + base_store_params: None, progress: Arc::new(NoopFragmentWriteProgress::new()), write_progress: None, commit_handler: None, @@ -356,6 +359,21 @@ impl WriteParams { .unwrap_or_default() } + /// Set exact runtime object store params for a registered base path. + /// + /// These params are used as-is for that base. The write-level default + /// `store_params` remain the fallback for bases without an explicit binding. + pub fn with_base_store_params( + mut self, + base_path: impl AsRef, + store_params: ObjectStoreParams, + ) -> Self { + self.base_store_params + .get_or_insert_with(HashMap::new) + .insert(base_path.as_ref().to_string(), store_params); + self + } + /// Set the properties for this WriteParams. pub fn with_transaction_properties(self, properties: HashMap) -> Self { Self { @@ -642,7 +660,6 @@ pub async fn validate_and_resolve_target_bases( .unwrap_or_default(); if let Some(target_bases) = &target_base_ids { - let store_params = params.store_params.clone().unwrap_or_default(); let mut bases_info = Vec::new(); for &target_base_id in target_bases { @@ -653,6 +670,7 @@ pub async fn validate_and_resolve_target_bases( )) })?; + let store_params = write_store_params_for_base(params, &base_path.path); let (target_object_store, extracted_path) = ObjectStore::from_uri_and_params( store_registry.clone(), &base_path.path, @@ -678,6 +696,7 @@ fn append_external_base_candidate( base_path: &BasePath, store_prefix: String, extracted_path: Path, + store_params: ObjectStoreParams, candidates: &mut Vec, seen_base_ids: &mut HashSet, ) { @@ -689,29 +708,50 @@ fn append_external_base_candidate( base_id: base_path.id, store_prefix, base_path: extracted_path, + store_params, }); } } +fn write_store_params_for_base(params: &WriteParams, base_path: &str) -> ObjectStoreParams { + params + .base_store_params + .as_ref() + .and_then(|base_store_params| base_store_params.get(base_path)) + .cloned() + .unwrap_or_else(|| params.store_params.clone().unwrap_or_default()) +} + +fn dataset_store_params_for_base(dataset: &Dataset, base_path: &str) -> ObjectStoreParams { + dataset + .base_store_params + .as_ref() + .and_then(|base_store_params| base_store_params.get(base_path)) + .cloned() + .unwrap_or_else(|| dataset.store_params.as_deref().cloned().unwrap_or_default()) +} + async fn append_external_initial_bases( initial_bases: Option<&Vec>, store_registry: Arc, - store_params: &ObjectStoreParams, + params: &WriteParams, candidates: &mut Vec, seen_base_ids: &mut HashSet, ) -> Result<()> { if let Some(initial_bases) = initial_bases { for base_path in initial_bases { + let store_params = write_store_params_for_base(params, &base_path.path); let (store, extracted_path) = ObjectStore::from_uri_and_params( store_registry.clone(), &base_path.path, - store_params, + &store_params, ) .await?; append_external_base_candidate( base_path, store.store_prefix.clone(), extracted_path, + store_params, candidates, seen_base_ids, ); @@ -727,13 +767,13 @@ async fn build_external_base_resolver( let store_registry = dataset .map(|ds| ds.session.store_registry()) .unwrap_or_else(|| params.store_registry()); - let store_params = params.store_params.clone().unwrap_or_default(); let mut seen_base_ids = HashSet::new(); let mut candidates = vec![]; if let Some(dataset) = dataset { for base_path in dataset.manifest.base_paths.values() { + let store_params = dataset_store_params_for_base(dataset, &base_path.path); let (store, extracted_path) = ObjectStore::from_uri_and_params( store_registry.clone(), &base_path.path, @@ -744,6 +784,7 @@ async fn build_external_base_resolver( base_path, store.store_prefix.clone(), extracted_path, + store_params, &mut candidates, &mut seen_base_ids, ); @@ -753,17 +794,13 @@ async fn build_external_base_resolver( append_external_initial_bases( params.initial_bases.as_ref(), store_registry.clone(), - &store_params, + params, &mut candidates, &mut seen_base_ids, ) .await?; - Ok(ExternalBaseResolver::new( - candidates, - store_registry, - store_params, - )) + Ok(ExternalBaseResolver::new(candidates, store_registry)) } /// Writes the given data to the dataset and returns fragments. @@ -1353,6 +1390,7 @@ impl Iterator for SpillStreamIter { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; use arrow_array::{Int32Array, RecordBatchIterator, RecordBatchReader, StructArray}; use arrow_schema::{DataType, Field as ArrowField, Fields, Schema as ArrowSchema}; @@ -1361,7 +1399,9 @@ mod tests { use futures::TryStreamExt; use lance_datagen::{BatchCount, RowCount, array, gen_batch}; use lance_file::previous::reader::FileReader as PreviousFileReader; + use lance_io::object_store::StorageOptionsAccessor; use lance_io::traits::Reader; + use lance_table::format::BasePath; #[tokio::test] async fn test_chunking_large_batches() { @@ -1807,6 +1847,65 @@ mod tests { assert_eq!(batch, data); } + #[cfg(feature = "azure")] + fn azure_store_params(account_name: &str) -> ObjectStoreParams { + ObjectStoreParams { + storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options( + HashMap::from([ + ("account_name".to_string(), account_name.to_string()), + ("account_key".to_string(), "dGVzdA==".to_string()), + ]), + ))), + ..Default::default() + } + } + + #[cfg(feature = "azure")] + #[tokio::test] + async fn test_validate_and_resolve_target_bases_uses_base_store_params() { + let mut params = WriteParams::default() + .with_target_bases(vec![1, 2]) + .with_base_store_params("az://container/path-a", azure_store_params("account-a")) + .with_base_store_params("az://container/path-b", azure_store_params("account-b")); + + let existing_base_paths = HashMap::from([ + ( + 1, + BasePath::new( + 1, + "az://container/path-a".to_string(), + Some("base-a".to_string()), + false, + ), + ), + ( + 2, + BasePath::new( + 2, + "az://container/path-b".to_string(), + Some("base-b".to_string()), + false, + ), + ), + ]); + + let target_bases = + validate_and_resolve_target_bases(&mut params, Some(&existing_base_paths)) + .await + .unwrap() + .unwrap(); + + assert_eq!(target_bases.len(), 2); + assert_eq!( + target_bases[0].object_store.store_prefix, + "az$container@account-a" + ); + assert_eq!( + target_bases[1].object_store.store_prefix, + "az$container@account-b" + ); + } + #[tokio::test] async fn test_explicit_data_file_bases_writer_generator() { use arrow::datatypes::{DataType, Field as ArrowField, Schema as ArrowSchema}; diff --git a/rust/lance/src/dataset/write/commit.rs b/rust/lance/src/dataset/write/commit.rs index f51833e5861..08a4f8f8f02 100644 --- a/rust/lance/src/dataset/write/commit.rs +++ b/rust/lance/src/dataset/write/commit.rs @@ -404,6 +404,7 @@ impl<'a> CommitBuilder<'a> { metadata_cache, file_reader_options: None, store_params: self.store_params.clone().map(Box::new), + base_store_params: None, }) } } From 65ac541f2143b6cddcc7fb4f4585bac112bbba93 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 15 Apr 2026 14:05:02 +0800 Subject: [PATCH 189/206] refactor!: vendor the tokenizer stack into lance (#6512) This PR vendors the tokenizer stack Lance actually uses into a new `rust/lance-tokenizer` crate and rewires FTS and inverted-index code to depend on it instead of `tantivy` and `lindera-tantivy`. It keeps the existing document and query tokenization semantics in-tree, renames the old FTS document adapter module to `document_tokenizer`, and preserves upstream license headers on vendored code. --- .typos.toml | 1 + Cargo.lock | 540 +---- Cargo.toml | 4 +- python/Cargo.lock | 509 +---- rust/lance-index/Cargo.toml | 10 +- rust/lance-index/benches/inverted.rs | 2 +- rust/lance-index/src/scalar/inverted.rs | 2 +- .../src/scalar/inverted/builder.rs | 18 +- rust/lance-index/src/scalar/inverted/index.rs | 12 +- rust/lance-index/src/scalar/inverted/query.rs | 4 +- .../src/scalar/inverted/tokenizer.rs | 69 +- ...nce_tokenizer.rs => document_tokenizer.rs} | 32 +- .../src/scalar/inverted/tokenizer/jieba.rs | 61 +- .../src/scalar/inverted/tokenizer/lindera.rs | 8 +- rust/lance-index/src/scalar/ngram.rs | 16 +- rust/lance-tokenizer/Cargo.toml | 28 + rust/lance-tokenizer/README.md | 3 + rust/lance-tokenizer/src/alphanum_only.rs | 60 + rust/lance-tokenizer/src/analyzer.rs | 92 + .../src/ascii_folding_filter.rs | 152 ++ rust/lance-tokenizer/src/jieba.rs | 70 + rust/lance-tokenizer/src/lib.rs | 37 + rust/lance-tokenizer/src/lindera.rs | 97 + rust/lance-tokenizer/src/lower_caser.rs | 77 + rust/lance-tokenizer/src/ngram_tokenizer.rs | 221 ++ rust/lance-tokenizer/src/raw_tokenizer.rs | 51 + rust/lance-tokenizer/src/remove_long.rs | 76 + rust/lance-tokenizer/src/simple_tokenizer.rs | 68 + rust/lance-tokenizer/src/stemmer.rs | 141 ++ rust/lance-tokenizer/src/stop_word_filter.rs | 108 + .../src/stop_word_filter/stopwords.rs | 1900 +++++++++++++++++ rust/lance-tokenizer/src/tokenizer_api.rs | 146 ++ .../src/whitespace_tokenizer.rs | 68 + rust/lance/Cargo.toml | 2 +- rust/lance/src/dataset/mem_wal/index/fts.rs | 4 +- rust/lance/src/io/exec/fts.rs | 14 +- rust/lance/tests/query/inverted.rs | 2 +- 37 files changed, 3664 insertions(+), 1041 deletions(-) rename rust/lance-index/src/scalar/inverted/tokenizer/{lance_tokenizer.rs => document_tokenizer.rs} (92%) create mode 100644 rust/lance-tokenizer/Cargo.toml create mode 100644 rust/lance-tokenizer/README.md create mode 100644 rust/lance-tokenizer/src/alphanum_only.rs create mode 100644 rust/lance-tokenizer/src/analyzer.rs create mode 100644 rust/lance-tokenizer/src/ascii_folding_filter.rs create mode 100644 rust/lance-tokenizer/src/jieba.rs create mode 100644 rust/lance-tokenizer/src/lib.rs create mode 100644 rust/lance-tokenizer/src/lindera.rs create mode 100644 rust/lance-tokenizer/src/lower_caser.rs create mode 100644 rust/lance-tokenizer/src/ngram_tokenizer.rs create mode 100644 rust/lance-tokenizer/src/raw_tokenizer.rs create mode 100644 rust/lance-tokenizer/src/remove_long.rs create mode 100644 rust/lance-tokenizer/src/simple_tokenizer.rs create mode 100644 rust/lance-tokenizer/src/stemmer.rs create mode 100644 rust/lance-tokenizer/src/stop_word_filter.rs create mode 100644 rust/lance-tokenizer/src/stop_word_filter/stopwords.rs create mode 100644 rust/lance-tokenizer/src/tokenizer_api.rs create mode 100644 rust/lance-tokenizer/src/whitespace_tokenizer.rs diff --git a/.typos.toml b/.typos.toml index 0baff74f8ac..9285dd52a23 100644 --- a/.typos.toml +++ b/.typos.toml @@ -23,5 +23,6 @@ nprob = "nprobe" extend-exclude = [ "notebooks/*.ipynb", "*_THIRD_PARTY_LICENSES.*", + "rust/lance-tokenizer/src/stop_word_filter/stopwords.rs", ] # If a line ends with # or // and has spellchecker:disable-line, ignore it diff --git a/Cargo.lock b/Cargo.lock index 1a8ffd37012..016e40cabcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,15 +173,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -322,7 +313,7 @@ dependencies = [ "arrow-schema", "arrow-select", "flatbuffers", - "lz4_flex 0.12.1", + "lz4_flex", "zstd", ] @@ -382,7 +373,7 @@ version = "57.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c872d36b7bf2a6a6a2b40de9156265f0242910791db366a2c17476ba8330d68" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "serde_core", "serde_json", ] @@ -476,7 +467,7 @@ dependencies = [ "futures-lite 2.6.1", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -507,7 +498,7 @@ dependencies = [ "cfg-if", "event-listener 5.4.1", "futures-lite 2.6.1", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -533,7 +524,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -729,7 +720,7 @@ dependencies = [ "http 0.2.12", "http 1.4.0", "http-body 1.0.1", - "lru 0.16.3", + "lru", "percent-encoding", "regex-lite", "sha2", @@ -1313,9 +1304,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitpacking" @@ -1392,31 +1383,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 0.23.0", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", -] - [[package]] name = "brotli" version = "8.0.2" @@ -1508,12 +1474,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "census" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" - [[package]] name = "cfg-if" version = "1.0.4" @@ -1975,16 +1935,6 @@ dependencies = [ "darling_macro 0.20.11", ] -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - [[package]] name = "darling_core" version = "0.14.4" @@ -2013,19 +1963,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.14.4" @@ -2048,17 +1985,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.117", -] - [[package]] name = "dashmap" version = "6.1.0" @@ -2115,7 +2041,7 @@ dependencies = [ "object_store", "parking_lot", "parquet", - "rand 0.9.3", + "rand 0.9.4", "regex", "sqlparser", "tempfile", @@ -2230,7 +2156,7 @@ dependencies = [ "itertools 0.14.0", "log", "object_store", - "rand 0.9.3", + "rand 0.9.4", "tokio", "url", ] @@ -2356,7 +2282,7 @@ dependencies = [ "log", "object_store", "parking_lot", - "rand 0.9.3", + "rand 0.9.4", "tempfile", "url", ] @@ -2420,7 +2346,7 @@ dependencies = [ "log", "md-5", "num-traits", - "rand 0.9.3", + "rand 0.9.4", "regex", "sha2", "unicode-segmentation", @@ -2946,12 +2872,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" -[[package]] -name = "downcast-rs" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" - [[package]] name = "dunce" version = "1.0.5" @@ -3175,12 +3095,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" -[[package]] -name = "fastdivide" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" - [[package]] name = "fastrand" version = "1.9.0" @@ -3237,7 +3151,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "rustc_version", ] @@ -3309,16 +3223,6 @@ dependencies = [ "futures-core", ] -[[package]] -name = "fs4" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" -dependencies = [ - "rustix 0.38.44", - "windows-sys 0.52.0", -] - [[package]] name = "fs_extra" version = "1.3.0" @@ -3331,7 +3235,7 @@ version = "5.1.0-beta.3" dependencies = [ "arrow-array", "lance-datagen", - "rand 0.9.3", + "rand 0.9.4", "rand_xoshiro", "test-log", "tokio", @@ -3528,9 +3432,9 @@ dependencies = [ [[package]] name = "geo-types" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" dependencies = [ "approx", "num-traits", @@ -3768,8 +3672,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -3832,7 +3734,7 @@ dependencies = [ "log", "native-tls", "num_cpus", - "rand 0.9.3", + "rand 0.9.4", "reqwest", "serde", "serde_json", @@ -3860,12 +3762,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "htmlescape" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" - [[package]] name = "http" version = "0.2.12" @@ -3983,16 +3879,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -4311,7 +4206,7 @@ version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd7bddefd0a8833b88a4b68f90dae22c7450d11b354198baee3874fd811b344" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "libc", ] @@ -4499,7 +4394,7 @@ dependencies = [ "nom 8.0.0", "num-traits", "ordered-float 5.3.0", - "rand 0.9.3", + "rand 0.9.4", "serde", "serde_json", "zmij", @@ -4590,6 +4485,7 @@ dependencies = [ "lance-table", "lance-test-macros", "lance-testing", + "lance-tokenizer", "lapack", "libc", "log", @@ -4604,15 +4500,14 @@ dependencies = [ "pretty_assertions", "prost", "prost-types", - "rand 0.9.3", - "rand_distr 0.5.1", + "rand 0.9.4", + "rand_distr", "roaring", "rstest", "semver", "serde", "serde_json", "snafu", - "tantivy", "tempfile", "test-log", "tokio", @@ -4644,7 +4539,7 @@ dependencies = [ "half", "jsonb", "num-traits", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -4712,7 +4607,7 @@ dependencies = [ "pin-project", "proptest", "prost", - "rand 0.9.3", + "rand 0.9.4", "roaring", "rstest", "serde_json", @@ -4774,8 +4669,8 @@ dependencies = [ "half", "hex", "pprof", - "rand 0.9.3", - "rand_distr 0.5.1", + "rand 0.9.4", + "rand_distr", "rand_xoshiro", "random_word", ] @@ -4815,7 +4710,7 @@ dependencies = [ "prost-build", "prost-types", "protobuf-src", - "rand 0.9.3", + "rand 0.9.4", "rand_xoshiro", "rstest", "serial_test", @@ -4848,7 +4743,7 @@ dependencies = [ "lance-linalg", "object_store", "parquet", - "rand 0.9.3", + "rand 0.9.4", "tempfile", "tokenizers", "tokio", @@ -4889,7 +4784,7 @@ dependencies = [ "prost-build", "prost-types", "protobuf-src", - "rand 0.9.3", + "rand 0.9.4", "rstest", "snafu", "test-log", @@ -4960,10 +4855,9 @@ dependencies = [ "lance-linalg", "lance-table", "lance-testing", + "lance-tokenizer", "libc", "libm", - "lindera", - "lindera-tantivy", "log", "ndarray", "num-traits", @@ -4973,8 +4867,8 @@ dependencies = [ "prost-build", "prost-types", "protobuf-src", - "rand 0.9.3", - "rand_distr 0.5.1", + "rand 0.9.4", + "rand_distr", "rangemap", "rayon", "roaring", @@ -4983,7 +4877,6 @@ dependencies = [ "serde_json", "smallvec", "snafu", - "tantivy", "tempfile", "test-log", "tokio", @@ -5031,7 +4924,7 @@ dependencies = [ "pin-project", "pprof", "prost", - "rand 0.9.3", + "rand 0.9.4", "rstest", "serde", "snafu", @@ -5061,7 +4954,7 @@ dependencies = [ "num-traits", "pprof", "proptest", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -5127,7 +5020,7 @@ dependencies = [ "lance-table", "log", "object_store", - "rand 0.9.3", + "rand 0.9.4", "reqwest", "rstest", "serde", @@ -5188,7 +5081,7 @@ dependencies = [ "prost-build", "prost-types", "protobuf-src", - "rand 0.9.3", + "rand 0.9.4", "rangemap", "roaring", "rstest", @@ -5219,7 +5112,18 @@ dependencies = [ "arrow-schema", "lance-arrow", "num-traits", - "rand 0.9.3", + "rand 0.9.4", +] + +[[package]] +name = "lance-tokenizer" +version = "5.1.0-beta.3" +dependencies = [ + "jieba-rs", + "lindera", + "rust-stemmers", + "serde", + "unicode-normalization", ] [[package]] @@ -5271,12 +5175,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "levenshtein_automata" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" - [[package]] name = "lexical-core" version = "1.0.6" @@ -5336,9 +5234,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libm" @@ -5352,7 +5250,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.4", @@ -5421,7 +5319,7 @@ dependencies = [ "md5", "memmap2", "once_cell", - "rand 0.9.3", + "rand 0.9.4", "reqwest", "serde", "tar", @@ -5469,17 +5367,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "lindera-tantivy" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d0d17afa51b4f4cd2f3e50d4c0845800f3a774ed7f706612fbd91eba0c84b41" -dependencies = [ - "lindera", - "tantivy", - "tantivy-tokenizer-api", -] - [[package]] name = "lindera-unidic" version = "0.44.1" @@ -5493,12 +5380,6 @@ dependencies = [ "tokio", ] -[[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" @@ -5541,18 +5422,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "lru" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] @@ -5582,12 +5454,6 @@ dependencies = [ "libc", ] -[[package]] -name = "lz4_flex" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" - [[package]] name = "lz4_flex" version = "0.12.1" @@ -5668,15 +5534,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" -[[package]] -name = "measure_time" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" -dependencies = [ - "log", -] - [[package]] name = "memchr" version = "2.8.0" @@ -5815,12 +5672,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "murmurhash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" - [[package]] name = "native-tls" version = "0.2.18" @@ -6070,7 +5921,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml 0.38.4", - "rand 0.9.3", + "rand 0.9.4", "reqwest", "ring", "rustls-pemfile", @@ -6114,19 +5965,13 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "oneshot" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" - [[package]] name = "onig" version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "libc", "once_cell", "onig_sys", @@ -6180,11 +6025,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -6212,9 +6057,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" dependencies = [ "cc", "libc", @@ -6262,15 +6107,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "ownedbytes" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "parking" version = "2.2.1" @@ -6322,7 +6158,7 @@ dependencies = [ "futures", "half", "hashbrown 0.16.1", - "lz4_flex 0.12.1", + "lz4_flex", "num-bigint", "num-integer", "num-traits", @@ -6585,9 +6421,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -6633,7 +6469,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -6771,9 +6607,9 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.11.0", + "bitflags 2.11.1", "num-traits", - "rand 0.9.3", + "rand 0.9.4", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -6916,7 +6752,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -6995,9 +6831,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -7060,16 +6896,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "rand_distr" version = "0.5.1" @@ -7077,7 +6903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -7116,7 +6942,7 @@ dependencies = [ "ahash", "brotli", "paste", - "rand 0.9.3", + "rand 0.9.4", "unicase", ] @@ -7134,9 +6960,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -7169,7 +6995,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -7178,7 +7004,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -7477,37 +7303,24 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "log", @@ -7552,9 +7365,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.11" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", @@ -7681,7 +7494,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7951,15 +7764,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" -[[package]] -name = "sketches-ddsketch" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" -dependencies = [ - "serde", -] - [[package]] name = "slab" version = "0.4.12" @@ -8202,9 +8006,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symbolic-common" -version = "12.17.4" +version = "12.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d14d7cb9c6fa5b95a6f3de8af95b356a528d391998fa45a07d320a5573e51" +checksum = "6aba7211a1803a826f108af9f4d86d25abe880712f2a6449479279e861b293f8" dependencies = [ "debugid", "memmap2", @@ -8214,9 +8018,9 @@ dependencies = [ [[package]] name = "symbolic-demangle" -version = "12.17.4" +version = "12.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39505731ae891b2dde47b0e4ae2ec40a7ced3476ab1129f1bf829e3fba62bb83" +checksum = "595bddd9d363c2ef6fc9fb33b98416ff209c5aae8bdca89a7dbbf2ef5e0ecc45" dependencies = [ "cpp_demangle", "rustc-demangle", @@ -8271,7 +8075,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8292,152 +8096,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "tantivy" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a966cb0e76e311f09cf18507c9af192f15d34886ee43d7ba7c7e3803660c43" -dependencies = [ - "aho-corasick", - "arc-swap", - "base64 0.22.1", - "bitpacking", - "bon", - "byteorder", - "census", - "crc32fast", - "crossbeam-channel", - "downcast-rs", - "fastdivide", - "fnv", - "fs4", - "htmlescape", - "hyperloglogplus", - "itertools 0.14.0", - "levenshtein_automata", - "log", - "lru 0.12.5", - "lz4_flex 0.11.6", - "measure_time", - "memmap2", - "once_cell", - "oneshot", - "rayon", - "regex", - "rust-stemmers", - "rustc-hash", - "serde", - "serde_json", - "sketches-ddsketch", - "smallvec", - "tantivy-bitpacker", - "tantivy-columnar", - "tantivy-common", - "tantivy-fst", - "tantivy-query-grammar", - "tantivy-stacker", - "tantivy-tokenizer-api", - "tempfile", - "thiserror 2.0.18", - "time", - "uuid", - "winapi", -] - -[[package]] -name = "tantivy-bitpacker" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494" -dependencies = [ - "bitpacking", -] - -[[package]] -name = "tantivy-columnar" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344" -dependencies = [ - "downcast-rs", - "fastdivide", - "itertools 0.14.0", - "serde", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-sstable", - "tantivy-stacker", -] - -[[package]] -name = "tantivy-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f" -dependencies = [ - "async-trait", - "byteorder", - "ownedbytes", - "serde", - "time", -] - -[[package]] -name = "tantivy-fst" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" -dependencies = [ - "byteorder", - "regex-syntax", - "utf8-ranges", -] - -[[package]] -name = "tantivy-query-grammar" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" -dependencies = [ - "nom 7.1.3", - "serde", - "serde_json", -] - -[[package]] -name = "tantivy-sstable" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" -dependencies = [ - "futures-util", - "itertools 0.14.0", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-fst", - "zstd", -] - -[[package]] -name = "tantivy-stacker" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" -dependencies = [ - "murmurhash32", - "rand_distr 0.4.3", - "tantivy-common", -] - -[[package]] -name = "tantivy-tokenizer-api" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" -dependencies = [ - "serde", -] - [[package]] name = "tap" version = "1.0.1" @@ -8464,7 +8122,7 @@ dependencies = [ "fastrand 2.4.1", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -8678,9 +8336,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" dependencies = [ "bytes", "libc", @@ -8800,7 +8458,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "http 1.4.0", "http-body 1.0.1", @@ -8818,7 +8476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -8951,7 +8609,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" dependencies = [ - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -9359,7 +9017,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -9825,7 +9483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -9902,7 +9560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 224ec4ee2fb..6b5d2842a37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "rust/lance-namespace", "rust/lance-namespace-impls", "rust/lance-namespace-datafusion", + "rust/lance-tokenizer", "rust/lance-table", "rust/lance-test-macros", "rust/lance-testing", @@ -68,6 +69,7 @@ lance-namespace = { version = "=5.1.0-beta.3", path = "./rust/lance-namespace" } lance-namespace-impls = { version = "=5.1.0-beta.3", path = "./rust/lance-namespace-impls" } lance-namespace-datafusion = { version = "=5.1.0-beta.3", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" +lance-tokenizer = { version = "=5.1.0-beta.3", path = "./rust/lance-tokenizer" } lance-table = { version = "=5.1.0-beta.3", path = "./rust/lance-table" } lance-test-macros = { version = "=5.1.0-beta.3", path = "./rust/lance-test-macros" } lance-testing = { version = "=5.1.0-beta.3", path = "./rust/lance-testing" } @@ -184,9 +186,7 @@ serial_test = "3" slatedb = "0.3" snafu = "0.9" strum = "0.26" -tantivy = { version = "0.24.1", features = ["stopwords"] } lindera = { version = "0.44.0" } -lindera-tantivy = { version = "0.44.0" } tempfile = "3" test-log = { version = "0.2.15" } tokio = { version = "1.23", features = [ diff --git a/python/Cargo.lock b/python/Cargo.lock index a7f860cbd96..f539539bfd2 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -194,15 +194,6 @@ dependencies = [ "object", ] -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -344,7 +335,7 @@ dependencies = [ "arrow-schema", "arrow-select", "flatbuffers", - "lz4_flex 0.12.1", + "lz4_flex", "zstd", ] @@ -416,7 +407,7 @@ version = "57.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c872d36b7bf2a6a6a2b40de9156265f0242910791db366a2c17476ba8330d68" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "serde_core", "serde_json", ] @@ -1072,9 +1063,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitpacking" @@ -1138,31 +1129,6 @@ dependencies = [ "generic-array", ] -[[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 0.23.0", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", -] - [[package]] name = "brotli" version = "8.0.2" @@ -1257,12 +1223,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "census" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" - [[package]] name = "cfg-if" version = "1.0.4" @@ -1568,18 +1528,8 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core", + "darling_macro", ] [[package]] @@ -1596,37 +1546,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.117", ] @@ -1690,7 +1616,7 @@ dependencies = [ "object_store", "parking_lot", "parquet", - "rand 0.9.3", + "rand 0.9.4", "regex", "sqlparser", "tempfile", @@ -1811,7 +1737,7 @@ dependencies = [ "liblzma", "log", "object_store", - "rand 0.9.3", + "rand 0.9.4", "tokio", "tokio-util", "url", @@ -1939,7 +1865,7 @@ dependencies = [ "log", "object_store", "parking_lot", - "rand 0.9.3", + "rand 0.9.4", "tempfile", "url", ] @@ -2034,7 +1960,7 @@ dependencies = [ "log", "md-5", "num-traits", - "rand 0.9.3", + "rand 0.9.4", "regex", "sha2", "unicode-segmentation", @@ -2424,7 +2350,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -2442,7 +2367,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling 0.20.11", + "darling", "proc-macro2", "quote", "syn 2.0.117", @@ -2511,12 +2436,6 @@ dependencies = [ "const-random", ] -[[package]] -name = "downcast-rs" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" - [[package]] name = "dunce" version = "1.0.5" @@ -2699,12 +2618,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" -[[package]] -name = "fastdivide" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" - [[package]] name = "fastrand" version = "2.4.1" @@ -2740,7 +2653,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "rustc_version", ] @@ -2788,16 +2701,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs4" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" -dependencies = [ - "rustix 0.38.44", - "windows-sys 0.52.0", -] - [[package]] name = "fs_extra" version = "1.3.0" @@ -2809,7 +2712,7 @@ name = "fsst" version = "5.1.0-beta.3" dependencies = [ "arrow-array", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -3181,8 +3084,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -3249,12 +3150,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "htmlescape" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" - [[package]] name = "http" version = "0.2.12" @@ -3352,16 +3247,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" dependencies = [ "http 1.4.0", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -3625,7 +3519,7 @@ version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd7bddefd0a8833b88a4b68f90dae22c7450d11b354198baee3874fd811b344" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "libc", ] @@ -3781,10 +3675,10 @@ dependencies = [ "fast-float2", "itoa", "jiff", - "nom 8.0.0", + "nom", "num-traits", "ordered-float 5.3.0", - "rand 0.9.3", + "rand 0.9.4", "serde", "serde_json", "zmij", @@ -3860,6 +3754,7 @@ dependencies = [ "lance-linalg", "lance-namespace", "lance-table", + "lance-tokenizer", "log", "moka", "object_store", @@ -3867,13 +3762,12 @@ dependencies = [ "pin-project", "prost", "prost-types", - "rand 0.9.3", + "rand 0.9.4", "roaring", "semver", "serde", "serde_json", "snafu", - "tantivy", "tokio", "tokio-stream", "tokio-util", @@ -3900,7 +3794,7 @@ dependencies = [ "half", "jsonb", "num-traits", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -3937,7 +3831,7 @@ dependencies = [ "object_store", "pin-project", "prost", - "rand 0.9.3", + "rand 0.9.4", "roaring", "serde_json", "snafu", @@ -3995,8 +3889,8 @@ dependencies = [ "futures", "half", "hex", - "rand 0.9.3", - "rand_distr 0.5.1", + "rand 0.9.4", + "rand_distr", "rand_xoshiro", "random_word", ] @@ -4029,7 +3923,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.3", + "rand 0.9.4", "snafu", "strum 0.26.3", "tokio", @@ -4128,9 +4022,8 @@ dependencies = [ "lance-io", "lance-linalg", "lance-table", + "lance-tokenizer", "libm", - "lindera", - "lindera-tantivy", "log", "ndarray", "num-traits", @@ -4138,8 +4031,8 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.3", - "rand_distr 0.5.1", + "rand 0.9.4", + "rand_distr", "rangemap", "rayon", "roaring", @@ -4147,7 +4040,6 @@ dependencies = [ "serde_json", "smallvec", "snafu", - "tantivy", "tempfile", "tokio", "tracing", @@ -4190,7 +4082,7 @@ dependencies = [ "path_abs", "pin-project", "prost", - "rand 0.9.3", + "rand 0.9.4", "serde", "snafu", "tempfile", @@ -4212,7 +4104,7 @@ dependencies = [ "lance-arrow", "lance-core", "num-traits", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -4249,7 +4141,7 @@ dependencies = [ "lance-table", "log", "object_store", - "rand 0.9.3", + "rand 0.9.4", "reqwest", "serde", "serde_json", @@ -4299,7 +4191,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.3", + "rand 0.9.4", "rangemap", "roaring", "semver", @@ -4312,6 +4204,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "lance-tokenizer" +version = "5.1.0-beta.3" +dependencies = [ + "jieba-rs", + "lindera", + "rust-stemmers", + "serde", + "unicode-normalization", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -4327,12 +4230,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "levenshtein_automata" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" - [[package]] name = "lexical-core" version = "1.0.6" @@ -4398,9 +4295,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libloading" @@ -4444,7 +4341,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.4", @@ -4513,7 +4410,7 @@ dependencies = [ "md5", "memmap2", "once_cell", - "rand 0.9.3", + "rand 0.9.4", "reqwest", "serde", "tar", @@ -4561,17 +4458,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "lindera-tantivy" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d0d17afa51b4f4cd2f3e50d4c0845800f3a774ed7f706612fbd91eba0c84b41" -dependencies = [ - "lindera", - "tantivy", - "tantivy-tokenizer-api", -] - [[package]] name = "lindera-unidic" version = "0.44.1" @@ -4585,12 +4471,6 @@ dependencies = [ "tokio", ] -[[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" @@ -4631,15 +4511,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -4665,12 +4536,6 @@ dependencies = [ "libc", ] -[[package]] -name = "lz4_flex" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" - [[package]] name = "lz4_flex" version = "0.12.1" @@ -4724,15 +4589,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" -[[package]] -name = "measure_time" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" -dependencies = [ - "log", -] - [[package]] name = "memchr" version = "2.8.0" @@ -4773,12 +4629,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4832,12 +4682,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "murmurhash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" - [[package]] name = "ndarray" version = "0.16.1" @@ -4853,16 +4697,6 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nom" version = "8.0.0" @@ -5015,7 +4849,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml 0.38.4", - "rand 0.9.3", + "rand 0.9.4", "reqwest", "ring", "rustls-pemfile", @@ -5059,12 +4893,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "oneshot" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" - [[package]] name = "opendal" version = "0.55.0" @@ -5141,15 +4969,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "ownedbytes" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "parking" version = "2.2.1" @@ -5201,7 +5020,7 @@ dependencies = [ "futures", "half", "hashbrown 0.16.1", - "lz4_flex 0.12.1", + "lz4_flex", "num-bigint", "num-integer", "num-traits", @@ -5453,9 +5272,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -5764,7 +5583,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -5830,9 +5649,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -5876,16 +5695,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "rand_distr" version = "0.5.1" @@ -5893,7 +5702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -5914,7 +5723,7 @@ dependencies = [ "ahash", "brotli", "paste", - "rand 0.9.3", + "rand 0.9.4", "unicase", ] @@ -5932,9 +5741,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -5976,7 +5785,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -5985,7 +5794,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -6228,37 +6037,24 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "once_cell", @@ -6302,9 +6098,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.11" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", @@ -6404,7 +6200,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -6637,15 +6433,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" -[[package]] -name = "sketches-ddsketch" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" -dependencies = [ - "serde", -] - [[package]] name = "slab" version = "0.4.12" @@ -6904,152 +6691,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "tantivy" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a966cb0e76e311f09cf18507c9af192f15d34886ee43d7ba7c7e3803660c43" -dependencies = [ - "aho-corasick", - "arc-swap", - "base64", - "bitpacking", - "bon", - "byteorder", - "census", - "crc32fast", - "crossbeam-channel", - "downcast-rs", - "fastdivide", - "fnv", - "fs4", - "htmlescape", - "hyperloglogplus", - "itertools 0.14.0", - "levenshtein_automata", - "log", - "lru", - "lz4_flex 0.11.6", - "measure_time", - "memmap2", - "once_cell", - "oneshot", - "rayon", - "regex", - "rust-stemmers", - "rustc-hash", - "serde", - "serde_json", - "sketches-ddsketch", - "smallvec", - "tantivy-bitpacker", - "tantivy-columnar", - "tantivy-common", - "tantivy-fst", - "tantivy-query-grammar", - "tantivy-stacker", - "tantivy-tokenizer-api", - "tempfile", - "thiserror 2.0.18", - "time", - "uuid", - "winapi", -] - -[[package]] -name = "tantivy-bitpacker" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494" -dependencies = [ - "bitpacking", -] - -[[package]] -name = "tantivy-columnar" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344" -dependencies = [ - "downcast-rs", - "fastdivide", - "itertools 0.14.0", - "serde", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-sstable", - "tantivy-stacker", -] - -[[package]] -name = "tantivy-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f" -dependencies = [ - "async-trait", - "byteorder", - "ownedbytes", - "serde", - "time", -] - -[[package]] -name = "tantivy-fst" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" -dependencies = [ - "byteorder", - "regex-syntax", - "utf8-ranges", -] - -[[package]] -name = "tantivy-query-grammar" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" -dependencies = [ - "nom 7.1.3", - "serde", - "serde_json", -] - -[[package]] -name = "tantivy-sstable" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" -dependencies = [ - "futures-util", - "itertools 0.14.0", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-fst", - "zstd", -] - -[[package]] -name = "tantivy-stacker" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" -dependencies = [ - "murmurhash32", - "rand_distr 0.4.3", - "tantivy-common", -] - -[[package]] -name = "tantivy-tokenizer-api" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" -dependencies = [ - "serde", -] - [[package]] name = "tap" version = "1.0.1" @@ -7082,7 +6723,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -7334,7 +6975,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "http 1.4.0", "http-body 1.0.1", @@ -7352,7 +6993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -7480,7 +7121,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" dependencies = [ - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -7828,7 +7469,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -8185,7 +7826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -8262,7 +7903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix", ] [[package]] diff --git a/rust/lance-index/Cargo.toml b/rust/lance-index/Cargo.toml index c79760f0f07..18968f77b01 100644 --- a/rust/lance-index/Cargo.toml +++ b/rust/lance-index/Cargo.toml @@ -45,6 +45,7 @@ lance-file.workspace = true lance-geo = { workspace = true, optional = true } lance-io.workspace = true lance-linalg.workspace = true +lance-tokenizer.workspace = true lance-table.workspace = true libm.workspace = true log.workspace = true @@ -60,9 +61,6 @@ serde_json.workspace = true serde.workspace = true snafu.workspace = true smallvec = "1.15" -tantivy.workspace = true -lindera = { workspace = true, optional = true } -lindera-tantivy = { workspace = true, optional = true } tokio.workspace = true tracing.workspace = true tempfile.workspace = true @@ -92,8 +90,10 @@ chrono.workspace = true [features] geo = ["dep:lance-geo", "lance-geo/geo", "dep:geoarrow-array", "dep:geoarrow-schema", "dep:geo-types"] protoc = ["dep:protobuf-src"] -tokenizer-lindera = ["dep:lindera", "dep:lindera-tantivy"] -tokenizer-jieba = ["dep:jieba-rs"] +jieba-rs = ["tokenizer-jieba"] +lindera = ["tokenizer-lindera"] +tokenizer-lindera = ["lance-tokenizer/tokenizer-lindera"] +tokenizer-jieba = ["dep:jieba-rs", "lance-tokenizer/tokenizer-jieba"] [build-dependencies] prost-build.workspace = true diff --git a/rust/lance-index/benches/inverted.rs b/rust/lance-index/benches/inverted.rs index bce3fac4414..0647cca0c14 100644 --- a/rust/lance-index/benches/inverted.rs +++ b/rust/lance-index/benches/inverted.rs @@ -15,7 +15,7 @@ use itertools::Itertools; use lance_core::ROW_ID; use lance_core::cache::LanceCache; use lance_index::prefilter::NoFilter; -use lance_index::scalar::inverted::lance_tokenizer::DocType; +use lance_index::scalar::inverted::document_tokenizer::DocType; use lance_index::scalar::inverted::query::{FtsSearchParams, Operator, Tokens}; use lance_index::scalar::inverted::{InvertedIndex, InvertedIndexBuilder}; use lance_index::scalar::lance_format::LanceIndexStore; diff --git a/rust/lance-index/src/scalar/inverted.rs b/rust/lance-index/src/scalar/inverted.rs index 725b06ebde3..5057b587000 100644 --- a/rust/lance-index/src/scalar/inverted.rs +++ b/rust/lance-index/src/scalar/inverted.rs @@ -20,8 +20,8 @@ pub use builder::InvertedIndexBuilder; use datafusion::execution::SendableRecordBatchStream; pub use index::*; use lance_core::{Result, cache::LanceCache}; +pub use lance_tokenizer::Language; pub use scorer::MemBM25Scorer; -use tantivy::tokenizer::Language; pub use tokenizer::*; use lance_core::Error; diff --git a/rust/lance-index/src/scalar/inverted/builder.rs b/rust/lance-index/src/scalar/inverted/builder.rs index 111c150d988..1da3a423bb8 100644 --- a/rust/lance-index/src/scalar/inverted/builder.rs +++ b/rust/lance-index/src/scalar/inverted/builder.rs @@ -2,9 +2,9 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use super::{InvertedIndexParams, index::*}; +use crate::scalar::inverted::document_tokenizer::DocType; use crate::scalar::inverted::json::JsonTextStream; -use crate::scalar::inverted::lance_tokenizer::DocType; -use crate::scalar::inverted::tokenizer::lance_tokenizer::LanceTokenizer; +use crate::scalar::inverted::tokenizer::document_tokenizer::LanceTokenizer; #[cfg(test)] use crate::scalar::lance_format::LanceIndexStore; use crate::scalar::{IndexStore, OldIndexDataFilter}; @@ -2156,14 +2156,12 @@ mod tests { let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)])); let stream = Box::pin(stream); - let params = InvertedIndexParams::new( - "whitespace".to_string(), - tantivy::tokenizer::Language::English, - ) - .with_position(false) - .remove_stop_words(false) - .stem(false) - .max_token_length(None); + let params = + InvertedIndexParams::new("whitespace".to_string(), lance_tokenizer::Language::English) + .with_position(false) + .remove_stop_words(false) + .stem(false) + .max_token_length(None); let mut builder = InvertedIndexBuilder::new(params); builder.update(stream, store.as_ref(), None).await?; diff --git a/rust/lance-index/src/scalar/inverted/index.rs b/rust/lance-index/src/scalar/inverted/index.rs index caa5745f623..4f60ebd05f5 100644 --- a/rust/lance-index/src/scalar/inverted/index.rs +++ b/rust/lance-index/src/scalar/inverted/index.rs @@ -64,15 +64,16 @@ use super::{ }; use crate::frag_reuse::FragReuseIndex; use crate::pbold; -use crate::scalar::inverted::lance_tokenizer::TextTokenizer; +use crate::scalar::inverted::document_tokenizer::TextTokenizer; use crate::scalar::inverted::scorer::MemBM25Scorer; -use crate::scalar::inverted::tokenizer::lance_tokenizer::LanceTokenizer; +use crate::scalar::inverted::tokenizer::document_tokenizer::LanceTokenizer; use crate::scalar::{ AnyQuery, BuiltinIndexType, CreatedIndex, IndexReader, IndexStore, MetricsCollector, ScalarIndex, ScalarIndexParams, SearchResult, TokenQuery, UpdateCriteria, }; use crate::{FtsPrewarmOptions, Index}; use crate::{prefilter::PreFilter, scalar::inverted::iter::take_fst_keys}; +use lance_tokenizer::{SimpleTokenizer, TextAnalyzer}; use std::str::FromStr; // Version 0: Arrow TokenSetFormat (legacy) @@ -4126,10 +4127,7 @@ pub async fn flat_bm25_search_stream( let mut tokenizer = match index { Some(index) => index.tokenizer(), None => Box::new(TextTokenizer::new( - tantivy::tokenizer::TextAnalyzer::builder( - tantivy::tokenizer::SimpleTokenizer::default(), - ) - .build(), + TextAnalyzer::builder(SimpleTokenizer::default()).build(), )), }; let query_tokens = Arc::new(collect_query_tokens(&query, &mut tokenizer)); @@ -4182,7 +4180,7 @@ pub fn is_phrase_query(query: &str) -> bool { #[cfg(test)] mod tests { - use crate::scalar::inverted::lance_tokenizer::DocType; + use crate::scalar::inverted::document_tokenizer::DocType; use datafusion::physical_plan::stream::RecordBatchStreamAdapter; use futures::stream; use lance_core::cache::LanceCache; diff --git a/rust/lance-index/src/scalar/inverted/query.rs b/rust/lance-index/src/scalar/inverted/query.rs index 6a8ebb07840..21b631d12ad 100644 --- a/rust/lance-index/src/scalar/inverted/query.rs +++ b/rust/lance-index/src/scalar/inverted/query.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use crate::scalar::inverted::lance_tokenizer::DocType; -use crate::scalar::inverted::tokenizer::lance_tokenizer::LanceTokenizer; +use crate::scalar::inverted::document_tokenizer::DocType; +use crate::scalar::inverted::tokenizer::document_tokenizer::LanceTokenizer; use lance_core::{Error, Result}; use serde::ser::SerializeMap; use serde::{Deserialize, Serialize}; diff --git a/rust/lance-index/src/scalar/inverted/tokenizer.rs b/rust/lance-index/src/scalar/inverted/tokenizer.rs index 4da46b60670..fb1e8cc3639 100644 --- a/rust/lance-index/src/scalar/inverted/tokenizer.rs +++ b/rust/lance-index/src/scalar/inverted/tokenizer.rs @@ -8,7 +8,7 @@ use std::{env, path::PathBuf}; #[cfg(feature = "tokenizer-jieba")] mod jieba; -pub mod lance_tokenizer; +pub mod document_tokenizer; #[cfg(feature = "tokenizer-lindera")] mod lindera; @@ -19,9 +19,15 @@ use jieba::JiebaTokenizerBuilder; use lindera::LinderaTokenizerBuilder; use crate::pbold; -use crate::scalar::inverted::tokenizer::lance_tokenizer::{ +use crate::scalar::inverted::tokenizer::document_tokenizer::{ JsonTokenizer, LanceTokenizer, TextTokenizer, }; +pub use lance_tokenizer::Language; +use lance_tokenizer::{ + AsciiFoldingFilter, LowerCaser, NgramTokenizer, RawTokenizer, RemoveLongFilter, + SimpleTokenizer, Stemmer, StopWordFilter, TextAnalyzer, TextAnalyzerBuilder, + WhitespaceTokenizer, +}; /// Tokenizer configs #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -43,7 +49,7 @@ pub struct InvertedIndexParams { /// language for stemming and stop words /// this is only used when `stem` or `remove_stop_words` is true - pub(crate) language: tantivy::tokenizer::Language, + pub(crate) language: Language, /// If true, store the position of the term in the document /// This can significantly increase the size of the index @@ -177,7 +183,7 @@ fn default_max_ngram_length() -> u32 { impl Default for InvertedIndexParams { fn default() -> Self { - Self::new("simple".to_owned(), tantivy::tokenizer::Language::English) + Self::new("simple".to_owned(), Language::English) } } @@ -195,7 +201,7 @@ impl InvertedIndexParams { /// The `language` is used for stemming and removing stop words, /// this is not used for `lindera/*` and `jieba/*` tokenizers. /// Default to `English`. - pub fn new(base_tokenizer: String, language: tantivy::tokenizer::Language) -> Self { + pub fn new(base_tokenizer: String, language: Language) -> Self { Self { lance_tokenizer: None, base_tokenizer, @@ -338,32 +344,28 @@ impl InvertedIndexParams { pub fn build(&self) -> Result> { let mut builder = self.build_base_tokenizer()?; if let Some(max_token_length) = self.max_token_length { - builder = builder.filter_dynamic(tantivy::tokenizer::RemoveLongFilter::limit( - max_token_length, - )); + builder = builder.filter_dynamic(RemoveLongFilter::limit(max_token_length)); } if self.lower_case { - builder = builder.filter_dynamic(tantivy::tokenizer::LowerCaser); + builder = builder.filter_dynamic(LowerCaser); } if self.stem { - builder = builder.filter_dynamic(tantivy::tokenizer::Stemmer::new(self.language)); + builder = builder.filter_dynamic(Stemmer::new(self.language)); } if self.remove_stop_words { let stop_word_filter = match &self.custom_stop_words { - Some(words) => tantivy::tokenizer::StopWordFilter::remove(words.iter().cloned()), - None => { - tantivy::tokenizer::StopWordFilter::new(self.language).ok_or_else(|| { - Error::invalid_input(format!( - "removing stop words for language {:?} is not supported yet", - self.language - )) - })? - } + Some(words) => StopWordFilter::remove(words.iter().cloned()), + None => StopWordFilter::new(self.language).ok_or_else(|| { + Error::invalid_input(format!( + "removing stop words for language {:?} is not supported yet", + self.language + )) + })?, }; builder = builder.filter_dynamic(stop_word_filter); } if self.ascii_folding { - builder = builder.filter_dynamic(tantivy::tokenizer::AsciiFoldingFilter); + builder = builder.filter_dynamic(AsciiFoldingFilter); } let tokenizer = builder.build(); @@ -378,29 +380,20 @@ impl InvertedIndexParams { } } - fn build_base_tokenizer(&self) -> Result { + fn build_base_tokenizer(&self) -> Result { match self.base_tokenizer.as_str() { - "simple" => Ok(tantivy::tokenizer::TextAnalyzer::builder( - tantivy::tokenizer::SimpleTokenizer::default(), - ) - .dynamic()), - "whitespace" => Ok(tantivy::tokenizer::TextAnalyzer::builder( - tantivy::tokenizer::WhitespaceTokenizer::default(), - ) - .dynamic()), - "raw" => Ok(tantivy::tokenizer::TextAnalyzer::builder( - tantivy::tokenizer::RawTokenizer::default(), - ) - .dynamic()), - "ngram" => Ok(tantivy::tokenizer::TextAnalyzer::builder( - tantivy::tokenizer::NgramTokenizer::new( + "simple" => Ok(TextAnalyzer::builder(SimpleTokenizer::default()).dynamic()), + "whitespace" => Ok(TextAnalyzer::builder(WhitespaceTokenizer::default()).dynamic()), + "raw" => Ok(TextAnalyzer::builder(RawTokenizer::default()).dynamic()), + "ngram" => { + let tokenizer = NgramTokenizer::new( self.min_ngram_length as usize, self.max_ngram_length as usize, self.prefix_only, ) - .map_err(|e| Error::invalid_input(e.to_string()))?, - ) - .dynamic()), + .map_err(|e| Error::invalid_input(e.to_string()))?; + Ok(TextAnalyzer::builder(tokenizer).dynamic()) + } #[cfg(feature = "tokenizer-lindera")] s if s.starts_with("lindera/") => { let Some(home) = language_model_home() else { diff --git a/rust/lance-index/src/scalar/inverted/tokenizer/lance_tokenizer.rs b/rust/lance-index/src/scalar/inverted/tokenizer/document_tokenizer.rs similarity index 92% rename from rust/lance-index/src/scalar/inverted/tokenizer/lance_tokenizer.rs rename to rust/lance-index/src/scalar/inverted/tokenizer/document_tokenizer.rs index c7a6000c5b7..62dd7b1aa3b 100644 --- a/rust/lance-index/src/scalar/inverted/tokenizer/lance_tokenizer.rs +++ b/rust/lance-index/src/scalar/inverted/tokenizer/document_tokenizer.rs @@ -4,8 +4,8 @@ use arrow_schema::{DataType, Field}; use lance_arrow::ARROW_EXT_NAME_KEY; use lance_arrow::json::JSON_EXT_NAME; +use lance_tokenizer::{BoxTokenStream, TextAnalyzer, Token, TokenStream}; use serde_json::Value; -use tantivy::tokenizer::{BoxTokenStream, Token, TokenStream}; /// Document type for full text search. #[derive(Debug, Clone)] @@ -92,7 +92,7 @@ impl Clone for Box { #[derive(Clone)] pub struct TextTokenizer { - tokenizer: tantivy::tokenizer::TextAnalyzer, + tokenizer: TextAnalyzer, } impl std::fmt::Debug for TextTokenizer { @@ -102,7 +102,7 @@ impl std::fmt::Debug for TextTokenizer { } impl TextTokenizer { - pub fn new(tokenizer: tantivy::tokenizer::TextAnalyzer) -> Self { + pub fn new(tokenizer: TextAnalyzer) -> Self { Self { tokenizer } } } @@ -127,11 +127,11 @@ impl LanceTokenizer for TextTokenizer { #[derive(Clone)] pub struct JsonTokenizer { - tokenizer: tantivy::tokenizer::TextAnalyzer, + tokenizer: TextAnalyzer, } impl JsonTokenizer { - pub fn new(tokenizer: tantivy::tokenizer::TextAnalyzer) -> Self { + pub fn new(tokenizer: TextAnalyzer) -> Self { Self { tokenizer } } } @@ -170,10 +170,7 @@ impl LanceTokenizer for JsonTokenizer { } } -fn flatten_triplet( - text: &str, - tokenizer: &mut tantivy::tokenizer::TextAnalyzer, -) -> lance_core::Result> { +fn flatten_triplet(text: &str, tokenizer: &mut TextAnalyzer) -> lance_core::Result> { let mut token_vec = Vec::new(); let mut idx = 0; @@ -228,7 +225,7 @@ fn flatten_json( prefix: &str, out: &mut Vec, position: &mut usize, - tokenizer: &mut tantivy::tokenizer::TextAnalyzer, + tokenizer: &mut TextAnalyzer, ) { match value { Value::Object(map) => { @@ -306,11 +303,11 @@ impl TokenStream for TTStream { #[cfg(test)] mod tests { - use crate::scalar::inverted::tokenizer::lance_tokenizer::{ + use crate::scalar::inverted::tokenizer::document_tokenizer::{ JsonTokenizer, LanceTokenizer, flatten_json, flatten_triplet, }; + use lance_tokenizer::{SimpleTokenizer, TextAnalyzer, Token}; use serde_json::Value; - use tantivy::tokenizer::{SimpleTokenizer, Token}; #[test] fn test_json_tokenizer() { @@ -321,9 +318,8 @@ mod tests { {"c": "e"} ] }"#; - let mut tokenizer = JsonTokenizer::new( - tantivy::tokenizer::TextAnalyzer::builder(SimpleTokenizer::default()).build(), - ); + let mut tokenizer = + JsonTokenizer::new(TextAnalyzer::builder(SimpleTokenizer::default()).build()); let mut stream = tokenizer.token_stream_for_doc(text); let mut tokens: Vec = vec![]; @@ -354,8 +350,7 @@ mod tests { let value: Value = serde_json::from_str(json).unwrap(); let mut tokens = vec![]; - let mut tokenizer = - tantivy::tokenizer::TextAnalyzer::builder(SimpleTokenizer::default()).build(); + let mut tokenizer = TextAnalyzer::builder(SimpleTokenizer::default()).build(); let mut position = 0; flatten_json(&value, "", &mut tokens, &mut position, &mut tokenizer); @@ -372,8 +367,7 @@ mod tests { #[test] fn test_flatten_triplet() { let text = r#"a,number,1;b.c,str,d;b.c,str,e;d,str,hello world;e,number,1.0"#; - let mut tokenizer = - tantivy::tokenizer::TextAnalyzer::builder(SimpleTokenizer::default()).build(); + let mut tokenizer = TextAnalyzer::builder(SimpleTokenizer::default()).build(); let tokens = flatten_triplet(text, &mut tokenizer).unwrap(); assert_eq!(tokens.len(), 6); diff --git a/rust/lance-index/src/scalar/inverted/tokenizer/jieba.rs b/rust/lance-index/src/scalar/inverted/tokenizer/jieba.rs index 43af65cd210..2bfd61ec7fc 100644 --- a/rust/lance-index/src/scalar/inverted/tokenizer/jieba.rs +++ b/rust/lance-index/src/scalar/inverted/tokenizer/jieba.rs @@ -4,6 +4,7 @@ use std::{fs::File, io::BufReader, path::Path, path::PathBuf}; use lance_core::{Error, Result}; +use lance_tokenizer::{JiebaTokenizer, TextAnalyzerBuilder}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; #[derive(Serialize, Deserialize, Default)] @@ -37,7 +38,7 @@ pub trait JiebaTokenizerBuilder: Sized { fn new(config: Self::Config, root: &Path) -> Result; - fn build(&self) -> Result; + fn build(&self) -> Result; } pub struct JiebaBuilder { @@ -71,7 +72,7 @@ impl JiebaTokenizerBuilder for JiebaBuilder { }) } - fn build(&self) -> Result { + fn build(&self) -> Result { let main_dict_path = &self.main_dict_path(); let file = std::fs::File::open(main_dict_path)?; let mut f = std::io::BufReader::new(file); @@ -93,60 +94,6 @@ impl JiebaTokenizerBuilder for JiebaBuilder { )) })? } - let tokenizer = JiebaTokenizer { jieba }; - Ok(tantivy::tokenizer::TextAnalyzer::builder(tokenizer).dynamic()) - } -} - -#[derive(Clone)] -struct JiebaTokenizer { - jieba: jieba_rs::Jieba, -} - -struct JiebaTokenStream { - tokens: Vec, - index: usize, -} - -impl tantivy::tokenizer::TokenStream for JiebaTokenStream { - fn advance(&mut self) -> bool { - if self.index < self.tokens.len() { - self.index += 1; - true - } else { - false - } - } - - fn token(&self) -> &tantivy::tokenizer::Token { - &self.tokens[self.index - 1] - } - - fn token_mut(&mut self) -> &mut tantivy::tokenizer::Token { - &mut self.tokens[self.index - 1] - } -} - -#[cfg(feature = "tokenizer-jieba")] -impl tantivy::tokenizer::Tokenizer for JiebaTokenizer { - type TokenStream<'a> = JiebaTokenStream; - - fn token_stream(&mut self, text: &str) -> JiebaTokenStream { - let mut indices = text.char_indices().collect::>(); - indices.push((text.len(), '\0')); - let orig_tokens = self - .jieba - .tokenize(text, jieba_rs::TokenizeMode::Search, true); - let mut tokens = Vec::new(); - for token in orig_tokens { - tokens.push(tantivy::tokenizer::Token { - offset_from: indices[token.start].0, - offset_to: indices[token.end].0, - position: token.start, - text: String::from(&text[(indices[token.start].0)..(indices[token.end].0)]), - position_length: token.end - token.start, - }); - } - JiebaTokenStream { tokens, index: 0 } + Ok(JiebaTokenizer::new(jieba).analyzer_builder()) } } diff --git a/rust/lance-index/src/scalar/inverted/tokenizer/lindera.rs b/rust/lance-index/src/scalar/inverted/tokenizer/lindera.rs index c39cab9ad68..e294819dca3 100644 --- a/rust/lance-index/src/scalar/inverted/tokenizer/lindera.rs +++ b/rust/lance-index/src/scalar/inverted/tokenizer/lindera.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use lance_core::{Error, Result}; -use lindera_tantivy::tokenizer::LinderaTokenizer; +use lance_tokenizer::{LinderaTokenizer, TextAnalyzerBuilder}; pub const LINDERA_LANGUAGE_MODEL_CONFIG_FILE: &str = "config.yml"; @@ -22,7 +22,7 @@ pub trait LinderaTokenizerBuilder: Sized { fn new(config_path: &Path) -> Result; - fn build(&self) -> Result; + fn build(&self) -> Result; } pub struct LinderaBuilder { @@ -36,7 +36,7 @@ impl LinderaTokenizerBuilder for LinderaBuilder { }) } - fn build(&self) -> Result { + fn build(&self) -> Result { let tokenizer = if self.config_path.exists() { match LinderaTokenizer::from_file(&self.config_path) { Ok(tok) => tok, @@ -56,6 +56,6 @@ impl LinderaTokenizerBuilder for LinderaBuilder { LinderaTokenizer::new() .map_err(|e| Error::io(format!("Failed to initialize default tokenizer: {}", e)))? }; - Ok(tantivy::tokenizer::TextAnalyzer::builder(tokenizer).dynamic()) + Ok(tokenizer.analyzer_builder()) } } diff --git a/rust/lance-index/src/scalar/ngram.rs b/rust/lance-index/src/scalar/ngram.rs index 4e614d99d99..d14f1f2b4ae 100644 --- a/rust/lance-index/src/scalar/ngram.rs +++ b/rust/lance-index/src/scalar/ngram.rs @@ -41,10 +41,12 @@ use lance_core::utils::tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS}; use lance_core::{Error, utils::mask::RowAddrTreeMap}; use lance_core::{ROW_ID, Result}; use lance_io::object_store::ObjectStore; +use lance_tokenizer::{ + AlphaNumOnlyFilter, AsciiFoldingFilter, LowerCaser, NgramTokenizer, RawTokenizer, TextAnalyzer, +}; use log::info; use roaring::{RoaringBitmap, RoaringTreemap}; use serde::Serialize; -use tantivy::tokenizer::TextAnalyzer; use tracing::instrument; const TOKENS_COL: &str = "tokens"; @@ -65,15 +67,15 @@ pub static POSTINGS_SCHEMA: LazyLock = LazyLock::new(|| { ])) }); pub static TEXT_PREPPER: LazyLock = LazyLock::new(|| { - TextAnalyzer::builder(tantivy::tokenizer::RawTokenizer::default()) - .filter(tantivy::tokenizer::LowerCaser) - .filter(tantivy::tokenizer::AsciiFoldingFilter) + TextAnalyzer::builder(RawTokenizer::default()) + .filter(LowerCaser) + .filter(AsciiFoldingFilter) .build() }); /// Currently we ALWAYS use trigrams with ascii folding and lower casing. We may want to make this configurable in the future. pub static NGRAM_TOKENIZER: LazyLock = LazyLock::new(|| { - TextAnalyzer::builder(tantivy::tokenizer::NgramTokenizer::all_ngrams(3, 3).unwrap()) - .filter(tantivy::tokenizer::AlphaNumOnlyFilter) + TextAnalyzer::builder(NgramTokenizer::all_ngrams(3, 3).unwrap()) + .filter(AlphaNumOnlyFilter) .build() }); @@ -1335,7 +1337,7 @@ mod tests { }; use lance_datagen::{BatchCount, ByteCount, RowCount}; use lance_io::object_store::ObjectStore; - use tantivy::tokenizer::TextAnalyzer; + use lance_tokenizer::TextAnalyzer; use crate::scalar::{ ScalarIndex, SearchResult, TextQuery, diff --git a/rust/lance-tokenizer/Cargo.toml b/rust/lance-tokenizer/Cargo.toml new file mode 100644 index 00000000000..0f9909522ce --- /dev/null +++ b/rust/lance-tokenizer/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "lance-tokenizer" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +readme = "README.md" +description = "Tokenizer abstractions and implementations for Lance" +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true + +[dependencies] +jieba-rs = { workspace = true, optional = true } +lindera = { workspace = true, optional = true } +rust-stemmers = "1.2.0" +serde = { workspace = true, features = ["derive"] } +unicode-normalization = "0.1.25" + +[features] +jieba-rs = ["dep:jieba-rs"] +lindera = ["dep:lindera"] +tokenizer-jieba = ["jieba-rs"] +tokenizer-lindera = ["lindera"] + +[lints] +workspace = true diff --git a/rust/lance-tokenizer/README.md b/rust/lance-tokenizer/README.md new file mode 100644 index 00000000000..99541efd133 --- /dev/null +++ b/rust/lance-tokenizer/README.md @@ -0,0 +1,3 @@ +# lance-tokenizer + +Internal tokenizer abstractions and implementations used by Lance. diff --git a/rust/lance-tokenizer/src/alphanum_only.rs b/rust/lance-tokenizer/src/alphanum_only.rs new file mode 100644 index 00000000000..6c83db1b412 --- /dev/null +++ b/rust/lance-tokenizer/src/alphanum_only.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 alphanum-only filter. +// Copyright (c) 2017-present Tantivy contributors. + +use crate::{Token, TokenFilter, TokenStream, Tokenizer}; + +#[derive(Clone)] +pub struct AlphaNumOnlyFilter; + +pub struct AlphaNumOnlyFilterStream { + tail: T, +} + +impl AlphaNumOnlyFilterStream { + fn predicate(&self, token: &Token) -> bool { + token.text.chars().all(|ch| ch.is_ascii_alphanumeric()) + } +} + +impl TokenFilter for AlphaNumOnlyFilter { + type Tokenizer = AlphaNumOnlyFilterWrapper; + + fn transform(self, tokenizer: T) -> Self::Tokenizer { + AlphaNumOnlyFilterWrapper(tokenizer) + } +} + +#[derive(Clone)] +pub struct AlphaNumOnlyFilterWrapper(T); + +impl Tokenizer for AlphaNumOnlyFilterWrapper { + type TokenStream<'a> = AlphaNumOnlyFilterStream>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + AlphaNumOnlyFilterStream { + tail: self.0.token_stream(text), + } + } +} + +impl TokenStream for AlphaNumOnlyFilterStream { + fn advance(&mut self) -> bool { + while self.tail.advance() { + if self.predicate(self.tail.token()) { + return true; + } + } + false + } + + fn token(&self) -> &Token { + self.tail.token() + } + + fn token_mut(&mut self) -> &mut Token { + self.tail.token_mut() + } +} diff --git a/rust/lance-tokenizer/src/analyzer.rs b/rust/lance-tokenizer/src/analyzer.rs new file mode 100644 index 00000000000..6d119d47871 --- /dev/null +++ b/rust/lance-tokenizer/src/analyzer.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 tokenizer analyzer. +// Copyright (c) 2017-present Tantivy contributors. + +use crate::{BoxTokenStream, TokenFilter, Tokenizer}; + +#[derive(Clone)] +pub struct TextAnalyzer { + tokenizer: Box, +} + +impl From for TextAnalyzer { + fn from(tokenizer: T) -> Self { + Self::builder(tokenizer).build() + } +} + +impl Default for TextAnalyzer { + fn default() -> Self { + Self::from(crate::RawTokenizer::default()) + } +} + +impl TextAnalyzer { + pub fn builder(tokenizer: T) -> TextAnalyzerBuilder { + TextAnalyzerBuilder { tokenizer } + } + + pub fn token_stream<'a>(&'a mut self, text: &'a str) -> BoxTokenStream<'a> { + self.tokenizer.token_stream(text) + } +} + +pub trait BoxableTokenizer: 'static + Send + Sync { + fn box_token_stream<'a>(&'a mut self, text: &'a str) -> BoxTokenStream<'a>; + + fn box_clone(&self) -> Box; +} + +impl BoxableTokenizer for T { + fn box_token_stream<'a>(&'a mut self, text: &'a str) -> BoxTokenStream<'a> { + BoxTokenStream::new(self.token_stream(text)) + } + + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } +} + +impl Clone for Box { + fn clone(&self) -> Self { + (**self).box_clone() + } +} + +impl Tokenizer for Box { + type TokenStream<'a> = BoxTokenStream<'a>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + (**self).box_token_stream(text) + } +} + +pub struct TextAnalyzerBuilder> { + tokenizer: T, +} + +impl TextAnalyzerBuilder { + pub fn filter(self, token_filter: F) -> TextAnalyzerBuilder> { + TextAnalyzerBuilder { + tokenizer: token_filter.transform(self.tokenizer), + } + } + + pub fn dynamic(self) -> TextAnalyzerBuilder { + TextAnalyzerBuilder { + tokenizer: Box::new(self.tokenizer), + } + } + + pub fn filter_dynamic(self, token_filter: F) -> TextAnalyzerBuilder { + self.filter(token_filter).dynamic() + } + + pub fn build(self) -> TextAnalyzer { + TextAnalyzer { + tokenizer: Box::new(self.tokenizer), + } + } +} diff --git a/rust/lance-tokenizer/src/ascii_folding_filter.rs b/rust/lance-tokenizer/src/ascii_folding_filter.rs new file mode 100644 index 00000000000..376c0e1ebdb --- /dev/null +++ b/rust/lance-tokenizer/src/ascii_folding_filter.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::mem; + +use unicode_normalization::{UnicodeNormalization, char::is_combining_mark}; + +use crate::{Token, TokenFilter, TokenStream, Tokenizer}; + +#[derive(Clone)] +pub struct AsciiFoldingFilter; + +impl TokenFilter for AsciiFoldingFilter { + type Tokenizer = AsciiFoldingFilterWrapper; + + fn transform(self, tokenizer: T) -> Self::Tokenizer { + AsciiFoldingFilterWrapper { + tokenizer, + buffer: String::new(), + } + } +} + +#[derive(Clone)] +pub struct AsciiFoldingFilterWrapper { + tokenizer: T, + buffer: String, +} + +impl Tokenizer for AsciiFoldingFilterWrapper { + type TokenStream<'a> = AsciiFoldingFilterTokenStream<'a, T::TokenStream<'a>>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + self.buffer.clear(); + AsciiFoldingFilterTokenStream { + buffer: &mut self.buffer, + tail: self.tokenizer.token_stream(text), + } + } +} + +pub struct AsciiFoldingFilterTokenStream<'a, T> { + buffer: &'a mut String, + tail: T, +} + +impl TokenStream for AsciiFoldingFilterTokenStream<'_, T> { + fn advance(&mut self) -> bool { + if !self.tail.advance() { + return false; + } + if !self.token_mut().text.is_ascii() { + to_ascii(&self.tail.token().text, self.buffer); + mem::swap(&mut self.tail.token_mut().text, self.buffer); + } + true + } + + fn token(&self) -> &Token { + self.tail.token() + } + + fn token_mut(&mut self) -> &mut Token { + self.tail.token_mut() + } +} + +fn to_ascii(text: &str, output: &mut String) { + output.clear(); + for ch in text.chars() { + if ch.is_ascii() { + output.push(ch); + continue; + } + + if let Some(mapped) = fold_char(ch) { + output.push_str(mapped); + continue; + } + + let original_len = output.len(); + for decomposed in ch.nfkd() { + if decomposed.is_ascii() { + output.push(decomposed); + } else if is_combining_mark(decomposed) { + continue; + } else if let Some(mapped) = fold_char(decomposed) { + output.push_str(mapped); + } + } + + if output.len() == original_len { + output.push(ch); + } + } +} + +fn fold_char(ch: char) -> Option<&'static str> { + match ch { + 'ß' => Some("ss"), + 'ẞ' => Some("SS"), + 'Æ' => Some("AE"), + 'æ' => Some("ae"), + 'Œ' => Some("OE"), + 'œ' => Some("oe"), + 'Ø' => Some("O"), + 'ø' => Some("o"), + 'Ł' => Some("L"), + 'ł' => Some("l"), + 'Đ' | 'Ð' => Some("D"), + 'đ' | 'ð' => Some("d"), + 'Þ' => Some("TH"), + 'þ' => Some("th"), + 'Ħ' => Some("H"), + 'ħ' => Some("h"), + 'Ŧ' => Some("T"), + 'ŧ' => Some("t"), + 'Ŋ' => Some("N"), + 'ŋ' => Some("n"), + 'ı' => Some("i"), + 'ĸ' => Some("k"), + 'ſ' => Some("s"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::{AsciiFoldingFilter, RawTokenizer, TextAnalyzer, Token}; + + fn collect_tokens(text: &str) -> Vec { + let mut analyzer = TextAnalyzer::builder(RawTokenizer::default()) + .filter(AsciiFoldingFilter) + .build(); + let mut stream = analyzer.token_stream(text); + let mut tokens = Vec::new(); + stream.process(&mut |token| tokens.push(token.clone())); + tokens + } + + #[test] + fn test_ascii_folding_accents() { + let tokens = collect_tokens("café"); + assert_eq!(tokens[0].text, "cafe"); + } + + #[test] + fn test_ascii_folding_sharp_s() { + let tokens = collect_tokens("straße"); + assert_eq!(tokens[0].text, "strasse"); + } +} diff --git a/rust/lance-tokenizer/src/jieba.rs b/rust/lance-tokenizer/src/jieba.rs new file mode 100644 index 00000000000..5ba51bf6a06 --- /dev/null +++ b/rust/lance-tokenizer/src/jieba.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use crate::{TextAnalyzer, TextAnalyzerBuilder, Token, TokenStream, Tokenizer}; + +#[derive(Clone)] +pub struct JiebaTokenizer { + jieba: jieba_rs::Jieba, +} + +impl JiebaTokenizer { + pub fn new(jieba: jieba_rs::Jieba) -> Self { + Self { jieba } + } + + pub fn analyzer(self) -> TextAnalyzer { + TextAnalyzer::builder(self).build() + } + + pub fn analyzer_builder(self) -> TextAnalyzerBuilder { + TextAnalyzer::builder(self).dynamic() + } +} + +pub struct JiebaTokenStream { + tokens: Vec, + index: usize, +} + +impl TokenStream for JiebaTokenStream { + fn advance(&mut self) -> bool { + if self.index < self.tokens.len() { + self.index += 1; + true + } else { + false + } + } + + fn token(&self) -> &Token { + &self.tokens[self.index - 1] + } + + fn token_mut(&mut self) -> &mut Token { + &mut self.tokens[self.index - 1] + } +} + +impl Tokenizer for JiebaTokenizer { + type TokenStream<'a> = JiebaTokenStream; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + let mut indices = text.char_indices().collect::>(); + indices.push((text.len(), '\0')); + let orig_tokens = self + .jieba + .tokenize(text, jieba_rs::TokenizeMode::Search, true); + let tokens = orig_tokens + .into_iter() + .map(|token| Token { + offset_from: indices[token.start].0, + offset_to: indices[token.end].0, + position: token.start, + text: text[indices[token.start].0..indices[token.end].0].to_owned(), + position_length: token.end - token.start, + }) + .collect(); + JiebaTokenStream { tokens, index: 0 } + } +} diff --git a/rust/lance-tokenizer/src/lib.rs b/rust/lance-tokenizer/src/lib.rs new file mode 100644 index 00000000000..54cc45dbbf4 --- /dev/null +++ b/rust/lance-tokenizer/src/lib.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +mod alphanum_only; +mod analyzer; +mod ascii_folding_filter; +#[cfg(feature = "tokenizer-jieba")] +mod jieba; +mod lower_caser; +mod ngram_tokenizer; +mod raw_tokenizer; +mod remove_long; +mod simple_tokenizer; +mod stemmer; +mod stop_word_filter; +mod tokenizer_api; +mod whitespace_tokenizer; + +#[cfg(feature = "tokenizer-lindera")] +mod lindera; + +pub use alphanum_only::AlphaNumOnlyFilter; +pub use analyzer::{TextAnalyzer, TextAnalyzerBuilder}; +pub use ascii_folding_filter::AsciiFoldingFilter; +#[cfg(feature = "tokenizer-jieba")] +pub use jieba::JiebaTokenizer; +#[cfg(feature = "tokenizer-lindera")] +pub use lindera::LinderaTokenizer; +pub use lower_caser::LowerCaser; +pub use ngram_tokenizer::NgramTokenizer; +pub use raw_tokenizer::RawTokenizer; +pub use remove_long::RemoveLongFilter; +pub use simple_tokenizer::{SimpleTokenStream, SimpleTokenizer}; +pub use stemmer::{Language, Stemmer}; +pub use stop_word_filter::StopWordFilter; +pub use tokenizer_api::{BoxTokenStream, Token, TokenFilter, TokenStream, Tokenizer}; +pub use whitespace_tokenizer::WhitespaceTokenizer; diff --git a/rust/lance-tokenizer/src/lindera.rs b/rust/lance-tokenizer/src/lindera.rs new file mode 100644 index 00000000000..14d210217e4 --- /dev/null +++ b/rust/lance-tokenizer/src/lindera.rs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from lindera-tantivy v0.44.1. +// Copyright (c) lindera-tantivy contributors. + +use std::path::Path; + +use lindera::token::Token as LinderaToken; +use lindera::tokenizer::{Tokenizer as LinderaCoreTokenizer, TokenizerBuilder}; + +use crate::{TextAnalyzer, TextAnalyzerBuilder, Token, TokenStream, Tokenizer}; + +#[derive(Clone)] +pub struct LinderaTokenizer { + tokenizer: LinderaCoreTokenizer, + token: Token, +} + +impl LinderaTokenizer { + pub fn new() -> std::io::Result { + let builder = TokenizerBuilder::new().map_err(invalid_data)?; + let tokenizer = builder.build().map_err(invalid_data)?; + Ok(Self { + tokenizer, + token: Token::default(), + }) + } + + pub fn from_file(file_path: &Path) -> std::io::Result { + let builder = TokenizerBuilder::from_file(file_path).map_err(invalid_data)?; + let tokenizer = builder.build().map_err(invalid_data)?; + Ok(Self { + tokenizer, + token: Token::default(), + }) + } + + pub fn from_segmenter(segmenter: lindera::segmenter::Segmenter) -> Self { + Self { + tokenizer: LinderaCoreTokenizer::new(segmenter), + token: Token::default(), + } + } + + pub fn analyzer(self) -> TextAnalyzer { + TextAnalyzer::builder(self).build() + } + + pub fn analyzer_builder(self) -> TextAnalyzerBuilder { + TextAnalyzer::builder(self).dynamic() + } +} + +pub struct LinderaTokenStream<'a> { + tokens: Vec>, + token: &'a mut Token, +} + +impl<'a> TokenStream for LinderaTokenStream<'a> { + fn advance(&mut self) -> bool { + if self.tokens.is_empty() { + return false; + } + let token = self.tokens.remove(0); + self.token.text = token.text.to_string(); + self.token.offset_from = token.byte_start; + self.token.offset_to = token.byte_end; + self.token.position = token.position; + self.token.position_length = token.position_length; + true + } + + fn token(&self) -> &Token { + self.token + } + + fn token_mut(&mut self) -> &mut Token { + self.token + } +} + +impl Tokenizer for LinderaTokenizer { + type TokenStream<'a> = LinderaTokenStream<'a>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + self.token.reset(); + LinderaTokenStream { + tokens: self.tokenizer.tokenize(text).unwrap_or_default(), + token: &mut self.token, + } + } +} + +fn invalid_data(err: impl std::fmt::Debug) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{err:?}")) +} diff --git a/rust/lance-tokenizer/src/lower_caser.rs b/rust/lance-tokenizer/src/lower_caser.rs new file mode 100644 index 00000000000..a041ac04e1f --- /dev/null +++ b/rust/lance-tokenizer/src/lower_caser.rs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 lower caser. +// Copyright (c) 2017-present Tantivy contributors. + +use std::mem; + +use crate::{Token, TokenFilter, TokenStream, Tokenizer}; + +#[derive(Clone)] +pub struct LowerCaser; + +impl TokenFilter for LowerCaser { + type Tokenizer = LowerCaserFilter; + + fn transform(self, tokenizer: T) -> Self::Tokenizer { + LowerCaserFilter { + tokenizer, + buffer: String::new(), + } + } +} + +#[derive(Clone)] +pub struct LowerCaserFilter { + tokenizer: T, + buffer: String, +} + +impl Tokenizer for LowerCaserFilter { + type TokenStream<'a> = LowerCaserTokenStream<'a, T::TokenStream<'a>>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + self.buffer.clear(); + LowerCaserTokenStream { + buffer: &mut self.buffer, + tail: self.tokenizer.token_stream(text), + } + } +} + +pub struct LowerCaserTokenStream<'a, T> { + buffer: &'a mut String, + tail: T, +} + +fn to_lowercase_unicode(text: &str, output: &mut String) { + output.clear(); + output.reserve(50); + for ch in text.chars() { + output.extend(ch.to_lowercase()); + } +} + +impl TokenStream for LowerCaserTokenStream<'_, T> { + fn advance(&mut self) -> bool { + if !self.tail.advance() { + return false; + } + if self.token_mut().text.is_ascii() { + self.token_mut().text.make_ascii_lowercase(); + } else { + to_lowercase_unicode(&self.tail.token().text, self.buffer); + mem::swap(&mut self.tail.token_mut().text, self.buffer); + } + true + } + + fn token(&self) -> &Token { + self.tail.token() + } + + fn token_mut(&mut self) -> &mut Token { + self.tail.token_mut() + } +} diff --git a/rust/lance-tokenizer/src/ngram_tokenizer.rs b/rust/lance-tokenizer/src/ngram_tokenizer.rs new file mode 100644 index 00000000000..1cfdd052977 --- /dev/null +++ b/rust/lance-tokenizer/src/ngram_tokenizer.rs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 ngram tokenizer. +// Copyright (c) 2017-present Tantivy contributors. + +use std::fmt::{Display, Formatter}; + +use crate::{Token, TokenStream, Tokenizer}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NgramError { + message: String, +} + +impl NgramError { + fn invalid_argument(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl Display for NgramError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for NgramError {} + +#[derive(Clone, Debug)] +pub struct NgramTokenizer { + min_gram: usize, + max_gram: usize, + prefix_only: bool, + token: Token, +} + +impl NgramTokenizer { + pub fn new(min_gram: usize, max_gram: usize, prefix_only: bool) -> Result { + if min_gram == 0 { + return Err(NgramError::invalid_argument( + "min_gram must be greater than 0", + )); + } + if min_gram > max_gram { + return Err(NgramError::invalid_argument( + "min_gram must not be greater than max_gram", + )); + } + Ok(Self { + min_gram, + max_gram, + prefix_only, + token: Token::default(), + }) + } + + pub fn all_ngrams(min_gram: usize, max_gram: usize) -> Result { + Self::new(min_gram, max_gram, false) + } + + pub fn prefix_only(min_gram: usize, max_gram: usize) -> Result { + Self::new(min_gram, max_gram, true) + } +} + +pub struct NgramTokenStream<'a> { + ngram_charidx_iterator: StutteringIterator>, + prefix_only: bool, + text: &'a str, + token: &'a mut Token, +} + +impl Tokenizer for NgramTokenizer { + type TokenStream<'a> = NgramTokenStream<'a>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + self.token.reset(); + NgramTokenStream { + ngram_charidx_iterator: StutteringIterator::new( + CodepointFrontiers::for_str(text), + self.min_gram, + self.max_gram, + ), + prefix_only: self.prefix_only, + text, + token: &mut self.token, + } + } +} + +impl TokenStream for NgramTokenStream<'_> { + fn advance(&mut self) -> bool { + if let Some((offset_from, offset_to)) = self.ngram_charidx_iterator.next() { + if self.prefix_only && offset_from > 0 { + return false; + } + self.token.position = 0; + self.token.offset_from = offset_from; + self.token.offset_to = offset_to; + self.token.text.clear(); + self.token.text.push_str(&self.text[offset_from..offset_to]); + true + } else { + false + } + } + + fn token(&self) -> &Token { + self.token + } + + fn token_mut(&mut self) -> &mut Token { + self.token + } +} + +struct StutteringIterator { + underlying: T, + min_gram: usize, + max_gram: usize, + memory: Vec, + cursor: usize, + gram_len: usize, +} + +impl StutteringIterator +where + T: Iterator, +{ + fn new(mut underlying: T, min_gram: usize, max_gram: usize) -> Self { + debug_assert!(min_gram > 0, "min_gram must be positive"); + let memory: Vec = (&mut underlying).take(max_gram + 1).collect(); + if memory.len() <= min_gram { + Self { + underlying, + min_gram: 1, + max_gram: 0, + memory, + cursor: 0, + gram_len: 0, + } + } else { + Self { + underlying, + min_gram, + max_gram: memory.len() - 1, + memory, + cursor: 0, + gram_len: min_gram, + } + } + } +} + +impl Iterator for StutteringIterator +where + T: Iterator, +{ + type Item = (usize, usize); + + fn next(&mut self) -> Option { + if self.gram_len > self.max_gram { + self.gram_len = self.min_gram; + if let Some(next_val) = self.underlying.next() { + self.memory[self.cursor] = next_val; + } else { + self.max_gram -= 1; + } + self.cursor += 1; + if self.cursor >= self.memory.len() { + self.cursor = 0; + } + } + if self.max_gram < self.min_gram { + return None; + } + let start = self.memory[self.cursor % self.memory.len()]; + let stop = self.memory[(self.cursor + self.gram_len) % self.memory.len()]; + self.gram_len += 1; + Some((start, stop)) + } +} + +struct CodepointFrontiers<'a> { + text: &'a str, + next_offset: Option, +} + +impl<'a> CodepointFrontiers<'a> { + fn for_str(text: &'a str) -> Self { + Self { + text, + next_offset: Some(0), + } + } +} + +impl Iterator for CodepointFrontiers<'_> { + type Item = usize; + + fn next(&mut self) -> Option { + let offset = self.next_offset?; + if self.text.is_empty() { + self.next_offset = None; + } else { + let width = utf8_codepoint_width(self.text.as_bytes()[0]); + self.text = &self.text[width..]; + self.next_offset = Some(offset + width); + } + Some(offset) + } +} + +const CODEPOINT_UTF8_WIDTH: [u8; 16] = [1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 4]; + +fn utf8_codepoint_width(byte: u8) -> usize { + CODEPOINT_UTF8_WIDTH[(byte as usize) >> 4] as usize +} diff --git a/rust/lance-tokenizer/src/raw_tokenizer.rs b/rust/lance-tokenizer/src/raw_tokenizer.rs new file mode 100644 index 00000000000..31a684e2a84 --- /dev/null +++ b/rust/lance-tokenizer/src/raw_tokenizer.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 raw tokenizer. +// Copyright (c) 2017-present Tantivy contributors. + +use crate::{Token, TokenStream, Tokenizer}; + +#[derive(Clone, Default)] +pub struct RawTokenizer { + token: Token, +} + +pub struct RawTokenStream<'a> { + token: &'a mut Token, + has_token: bool, +} + +impl Tokenizer for RawTokenizer { + type TokenStream<'a> = RawTokenStream<'a>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + self.token.reset(); + self.token.position = 0; + self.token.position_length = 1; + self.token.offset_from = 0; + self.token.offset_to = text.len(); + self.token.text.clear(); + self.token.text.push_str(text); + RawTokenStream { + token: &mut self.token, + has_token: true, + } + } +} + +impl TokenStream for RawTokenStream<'_> { + fn advance(&mut self) -> bool { + let has_token = self.has_token; + self.has_token = false; + has_token + } + + fn token(&self) -> &Token { + self.token + } + + fn token_mut(&mut self) -> &mut Token { + self.token + } +} diff --git a/rust/lance-tokenizer/src/remove_long.rs b/rust/lance-tokenizer/src/remove_long.rs new file mode 100644 index 00000000000..4bc6e5e7887 --- /dev/null +++ b/rust/lance-tokenizer/src/remove_long.rs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 remove-long filter. +// Copyright (c) 2017-present Tantivy contributors. + +use crate::{Token, TokenFilter, TokenStream, Tokenizer}; + +#[derive(Clone)] +pub struct RemoveLongFilter { + length_limit: usize, +} + +impl RemoveLongFilter { + pub fn limit(length_limit: usize) -> Self { + Self { length_limit } + } +} + +impl TokenFilter for RemoveLongFilter { + type Tokenizer = RemoveLongFilterWrapper; + + fn transform(self, tokenizer: T) -> Self::Tokenizer { + RemoveLongFilterWrapper { + length_limit: self.length_limit, + inner: tokenizer, + } + } +} + +#[derive(Clone)] +pub struct RemoveLongFilterWrapper { + length_limit: usize, + inner: T, +} + +impl Tokenizer for RemoveLongFilterWrapper { + type TokenStream<'a> = RemoveLongFilterStream>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + RemoveLongFilterStream { + token_length_limit: self.length_limit, + tail: self.inner.token_stream(text), + } + } +} + +pub struct RemoveLongFilterStream { + token_length_limit: usize, + tail: T, +} + +impl RemoveLongFilterStream { + fn predicate(&self, token: &Token) -> bool { + token.text.len() < self.token_length_limit + } +} + +impl TokenStream for RemoveLongFilterStream { + fn advance(&mut self) -> bool { + while self.tail.advance() { + if self.predicate(self.tail.token()) { + return true; + } + } + false + } + + fn token(&self) -> &Token { + self.tail.token() + } + + fn token_mut(&mut self) -> &mut Token { + self.tail.token_mut() + } +} diff --git a/rust/lance-tokenizer/src/simple_tokenizer.rs b/rust/lance-tokenizer/src/simple_tokenizer.rs new file mode 100644 index 00000000000..e2be3dc5dbd --- /dev/null +++ b/rust/lance-tokenizer/src/simple_tokenizer.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 simple tokenizer. +// Copyright (c) 2017-present Tantivy contributors. + +use std::str::CharIndices; + +use crate::{Token, TokenStream, Tokenizer}; + +#[derive(Clone, Default)] +pub struct SimpleTokenizer { + token: Token, +} + +pub struct SimpleTokenStream<'a> { + text: &'a str, + chars: CharIndices<'a>, + token: &'a mut Token, +} + +impl Tokenizer for SimpleTokenizer { + type TokenStream<'a> = SimpleTokenStream<'a>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + self.token.reset(); + SimpleTokenStream { + text, + chars: text.char_indices(), + token: &mut self.token, + } + } +} + +impl SimpleTokenStream<'_> { + fn search_token_end(&mut self) -> usize { + (&mut self.chars) + .filter(|(_, c)| !c.is_alphanumeric()) + .map(|(offset, _)| offset) + .next() + .unwrap_or(self.text.len()) + } +} + +impl TokenStream for SimpleTokenStream<'_> { + fn advance(&mut self) -> bool { + self.token.text.clear(); + self.token.position = self.token.position.wrapping_add(1); + while let Some((offset_from, c)) = self.chars.next() { + if c.is_alphanumeric() { + let offset_to = self.search_token_end(); + self.token.offset_from = offset_from; + self.token.offset_to = offset_to; + self.token.text.push_str(&self.text[offset_from..offset_to]); + return true; + } + } + false + } + + fn token(&self) -> &Token { + self.token + } + + fn token_mut(&mut self) -> &mut Token { + self.token + } +} diff --git a/rust/lance-tokenizer/src/stemmer.rs b/rust/lance-tokenizer/src/stemmer.rs new file mode 100644 index 00000000000..03fcf118019 --- /dev/null +++ b/rust/lance-tokenizer/src/stemmer.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 stemmer filter. +// Copyright (c) 2017-present Tantivy contributors. + +use std::borrow::Cow; +use std::mem; + +use rust_stemmers::Algorithm; +use serde::{Deserialize, Serialize}; + +use crate::{Token, TokenFilter, TokenStream, Tokenizer}; + +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] +pub enum Language { + Arabic, + Danish, + Dutch, + English, + Finnish, + French, + German, + Greek, + Hungarian, + Italian, + Norwegian, + Portuguese, + Romanian, + Russian, + Spanish, + Swedish, + Tamil, + Turkish, +} + +impl Language { + fn algorithm(self) -> Algorithm { + match self { + Self::Arabic => Algorithm::Arabic, + Self::Danish => Algorithm::Danish, + Self::Dutch => Algorithm::Dutch, + Self::English => Algorithm::English, + Self::Finnish => Algorithm::Finnish, + Self::French => Algorithm::French, + Self::German => Algorithm::German, + Self::Greek => Algorithm::Greek, + Self::Hungarian => Algorithm::Hungarian, + Self::Italian => Algorithm::Italian, + Self::Norwegian => Algorithm::Norwegian, + Self::Portuguese => Algorithm::Portuguese, + Self::Romanian => Algorithm::Romanian, + Self::Russian => Algorithm::Russian, + Self::Spanish => Algorithm::Spanish, + Self::Swedish => Algorithm::Swedish, + Self::Tamil => Algorithm::Tamil, + Self::Turkish => Algorithm::Turkish, + } + } +} + +#[derive(Clone)] +pub struct Stemmer { + stemmer_algorithm: Algorithm, +} + +impl Stemmer { + pub fn new(language: Language) -> Self { + Self { + stemmer_algorithm: language.algorithm(), + } + } +} + +impl Default for Stemmer { + fn default() -> Self { + Self::new(Language::English) + } +} + +impl TokenFilter for Stemmer { + type Tokenizer = StemmerFilter; + + fn transform(self, tokenizer: T) -> Self::Tokenizer { + StemmerFilter { + stemmer_algorithm: self.stemmer_algorithm, + inner: tokenizer, + } + } +} + +#[derive(Clone)] +pub struct StemmerFilter { + stemmer_algorithm: Algorithm, + inner: T, +} + +impl Tokenizer for StemmerFilter { + type TokenStream<'a> = StemmerTokenStream>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + StemmerTokenStream { + tail: self.inner.token_stream(text), + stemmer: rust_stemmers::Stemmer::create(self.stemmer_algorithm), + buffer: String::new(), + } + } +} + +pub struct StemmerTokenStream { + tail: T, + stemmer: rust_stemmers::Stemmer, + buffer: String, +} + +impl TokenStream for StemmerTokenStream { + fn advance(&mut self) -> bool { + if !self.tail.advance() { + return false; + } + let token = self.tail.token_mut(); + let stemmed = self.stemmer.stem(&token.text); + match stemmed { + Cow::Owned(stemmed) => token.text = stemmed, + Cow::Borrowed(stemmed) => { + self.buffer.clear(); + self.buffer.push_str(stemmed); + mem::swap(&mut token.text, &mut self.buffer); + } + } + true + } + + fn token(&self) -> &Token { + self.tail.token() + } + + fn token_mut(&mut self) -> &mut Token { + self.tail.token_mut() + } +} diff --git a/rust/lance-tokenizer/src/stop_word_filter.rs b/rust/lance-tokenizer/src/stop_word_filter.rs new file mode 100644 index 00000000000..0c49330a619 --- /dev/null +++ b/rust/lance-tokenizer/src/stop_word_filter.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 stop-word filter. +// Copyright (c) 2017-present Tantivy contributors. + +#[path = "stop_word_filter/stopwords.rs"] +mod stopwords; + +use std::collections::HashSet; +use std::sync::Arc; + +use crate::{Language, Token, TokenFilter, TokenStream, Tokenizer}; + +#[derive(Clone)] +pub struct StopWordFilter { + words: Arc>, +} + +impl StopWordFilter { + pub fn new(language: Language) -> Option { + let words = match language { + Language::Danish => stopwords::DANISH, + Language::Dutch => stopwords::DUTCH, + Language::English => &[ + "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", + "is", "it", "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", + "there", "these", "they", "this", "to", "was", "will", "with", + ], + Language::Finnish => stopwords::FINNISH, + Language::French => stopwords::FRENCH, + Language::German => stopwords::GERMAN, + Language::Hungarian => stopwords::HUNGARIAN, + Language::Italian => stopwords::ITALIAN, + Language::Norwegian => stopwords::NORWEGIAN, + Language::Portuguese => stopwords::PORTUGUESE, + Language::Russian => stopwords::RUSSIAN, + Language::Spanish => stopwords::SPANISH, + Language::Swedish => stopwords::SWEDISH, + _ => return None, + }; + Some(Self::remove(words.iter().map(|word| (*word).to_owned()))) + } + + pub fn remove>(words: W) -> Self { + Self { + words: Arc::new(words.into_iter().collect()), + } + } +} + +impl TokenFilter for StopWordFilter { + type Tokenizer = StopWordFilterWrapper; + + fn transform(self, tokenizer: T) -> Self::Tokenizer { + StopWordFilterWrapper { + words: self.words, + inner: tokenizer, + } + } +} + +#[derive(Clone)] +pub struct StopWordFilterWrapper { + words: Arc>, + inner: T, +} + +impl Tokenizer for StopWordFilterWrapper { + type TokenStream<'a> = StopWordFilterStream>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + StopWordFilterStream { + words: self.words.clone(), + tail: self.inner.token_stream(text), + } + } +} + +pub struct StopWordFilterStream { + words: Arc>, + tail: T, +} + +impl StopWordFilterStream { + fn predicate(&self, token: &Token) -> bool { + !self.words.contains(&token.text) + } +} + +impl TokenStream for StopWordFilterStream { + fn advance(&mut self) -> bool { + while self.tail.advance() { + if self.predicate(self.tail.token()) { + return true; + } + } + false + } + + fn token(&self) -> &Token { + self.tail.token() + } + + fn token_mut(&mut self) -> &mut Token { + self.tail.token_mut() + } +} diff --git a/rust/lance-tokenizer/src/stop_word_filter/stopwords.rs b/rust/lance-tokenizer/src/stop_word_filter/stopwords.rs new file mode 100644 index 00000000000..2ac3f4a28aa --- /dev/null +++ b/rust/lance-tokenizer/src/stop_word_filter/stopwords.rs @@ -0,0 +1,1900 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: BSD-3-Clause +// Vendored stop-word lists from Tantivy v0.24.2 / Snowball. +/* +These stop word lists are from the Snowball project (https://snowballstem.org/) +which carries the following copyright and license: + +Copyright (c) 2001, Dr Martin Porter +Copyright (c) 2004,2005, Richard Boulton +Copyright (c) 2013, Yoshiki Shibukawa +Copyright (c) 2006,2007,2009,2010,2011,2014-2019, Olly Betts +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + 3. Neither the name of the Snowball project nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +pub const DANISH: &[&str] = &[ + "og", "i", "jeg", "det", "at", "en", "den", "til", "er", "som", "på", "de", "med", "han", "af", + "for", "ikke", "der", "var", "mig", "sig", "men", "et", "har", "om", "vi", "min", "havde", + "ham", "hun", "nu", "over", "da", "fra", "du", "ud", "sin", "dem", "os", "op", "man", "hans", + "hvor", "eller", "hvad", "skal", "selv", "her", "alle", "vil", "blev", "kunne", "ind", "når", + "være", "dog", "noget", "ville", "jo", "deres", "efter", "ned", "skulle", "denne", "end", + "dette", "mit", "også", "under", "have", "dig", "anden", "hende", "mine", "alt", "meget", + "sit", "sine", "vor", "mod", "disse", "hvis", "din", "nogle", "hos", "blive", "mange", "ad", + "bliver", "hendes", "været", "thi", "jer", "sådan", +]; + +pub const DUTCH: &[&str] = &[ + "de", "en", "van", "ik", "te", "dat", "die", "in", "een", "hij", "het", "niet", "zijn", "is", + "was", "op", "aan", "met", "als", "voor", "had", "er", "maar", "om", "hem", "dan", "zou", "of", + "wat", "mijn", "men", "dit", "zo", "door", "over", "ze", "zich", "bij", "ook", "tot", "je", + "mij", "uit", "der", "daar", "haar", "naar", "heb", "hoe", "heeft", "hebben", "deze", "u", + "want", "nog", "zal", "me", "zij", "nu", "ge", "geen", "omdat", "iets", "worden", "toch", "al", + "waren", "veel", "meer", "doen", "toen", "moet", "ben", "zonder", "kan", "hun", "dus", "alles", + "onder", "ja", "eens", "hier", "wie", "werd", "altijd", "doch", "wordt", "wezen", "kunnen", + "ons", "zelf", "tegen", "na", "reeds", "wil", "kon", "niets", "uw", "iemand", "geweest", + "andere", +]; + +pub const FINNISH: &[&str] = &[ + "olla", + "olen", + "olet", + "on", + "olemme", + "olette", + "ovat", + "ole", + "oli", + "olisi", + "olisit", + "olisin", + "olisimme", + "olisitte", + "olisivat", + "olit", + "olin", + "olimme", + "olitte", + "olivat", + "ollut", + "olleet", + "en", + "et", + "ei", + "emme", + "ette", + "eivät", + "minä", + "minun", + "minut", + "minua", + "minussa", + "minusta", + "minuun", + "minulla", + "minulta", + "minulle", + "sinä", + "sinun", + "sinut", + "sinua", + "sinussa", + "sinusta", + "sinuun", + "sinulla", + "sinulta", + "sinulle", + "hän", + "hänen", + "hänet", + "häntä", + "hänessä", + "hänestä", + "häneen", + "hänellä", + "häneltä", + "hänelle", + "me", + "meidän", + "meidät", + "meitä", + "meissä", + "meistä", + "meihin", + "meillä", + "meiltä", + "meille", + "te", + "teidän", + "teidät", + "teitä", + "teissä", + "teistä", + "teihin", + "teillä", + "teiltä", + "teille", + "he", + "heidän", + "heidät", + "heitä", + "heissä", + "heistä", + "heihin", + "heillä", + "heiltä", + "heille", + "tämä", + "tämän", + "tätä", + "tässä", + "tästä", + "tähän", + "tällä", + "tältä", + "tälle", + "tänä", + "täksi", + "tuo", + "tuon", + "tuota", + "tuossa", + "tuosta", + "tuohon", + "tuolla", + "tuolta", + "tuolle", + "tuona", + "tuoksi", + "se", + "sen", + "sitä", + "siinä", + "siitä", + "siihen", + "sillä", + "siltä", + "sille", + "sinä", + "siksi", + "nämä", + "näiden", + "näitä", + "näissä", + "näistä", + "näihin", + "näillä", + "näiltä", + "näille", + "näinä", + "näiksi", + "nuo", + "noiden", + "noita", + "noissa", + "noista", + "noihin", + "noilla", + "noilta", + "noille", + "noina", + "noiksi", + "ne", + "niiden", + "niitä", + "niissä", + "niistä", + "niihin", + "niillä", + "niiltä", + "niille", + "niinä", + "niiksi", + "kuka", + "kenen", + "kenet", + "ketä", + "kenessä", + "kenestä", + "keneen", + "kenellä", + "keneltä", + "kenelle", + "kenenä", + "keneksi", + "ketkä", + "keiden", + "ketkä", + "keitä", + "keissä", + "keistä", + "keihin", + "keillä", + "keiltä", + "keille", + "keinä", + "keiksi", + "mikä", + "minkä", + "minkä", + "mitä", + "missä", + "mistä", + "mihin", + "millä", + "miltä", + "mille", + "minä", + "miksi", + "mitkä", + "joka", + "jonka", + "jota", + "jossa", + "josta", + "johon", + "jolla", + "jolta", + "jolle", + "jona", + "joksi", + "jotka", + "joiden", + "joita", + "joissa", + "joista", + "joihin", + "joilla", + "joilta", + "joille", + "joina", + "joiksi", + "että", + "ja", + "jos", + "koska", + "kuin", + "mutta", + "niin", + "sekä", + "sillä", + "tai", + "vaan", + "vai", + "vaikka", + "kanssa", + "mukaan", + "noin", + "poikki", + "yli", + "kun", + "nyt", + "itse", +]; + +pub const FRENCH: &[&str] = &[ + "au", "aux", "avec", "ce", "ces", "dans", "de", "des", "du", "elle", "en", "et", "eux", "il", + "je", "la", "le", "leur", "lui", "ma", "mais", "me", "même", "mes", "moi", "mon", "ne", "nos", + "notre", "nous", "on", "ou", "par", "pas", "pour", "qu", "que", "qui", "sa", "se", "ses", + "sur", "ta", "te", "tes", "toi", "ton", "tu", "un", "une", "vos", "votre", "vous", "c", "d", + "j", "l", "à", "m", "n", "s", "t", "y", "étée", "étées", "étant", "suis", "es", "êtes", "sont", + "serai", "seras", "sera", "serons", "serez", "seront", "serais", "serait", "serions", "seriez", + "seraient", "étais", "était", "étions", "étiez", "étaient", "fus", "fut", "fûmes", "fûtes", + "furent", "sois", "soit", "soyons", "soyez", "soient", "fusse", "fusses", "fussions", + "fussiez", "fussent", "ayant", "eu", "eue", "eues", "eus", "ai", "avons", "avez", "ont", + "aurai", "aurons", "aurez", "auront", "aurais", "aurait", "aurions", "auriez", "auraient", + "avais", "avait", "aviez", "avaient", "eut", "eûmes", "eûtes", "eurent", "aie", "aies", "ait", + "ayons", "ayez", "aient", "eusse", "eusses", "eût", "eussions", "eussiez", "eussent", "ceci", + "cela", "celà", "cet", "cette", "ici", "ils", "les", "leurs", "quel", "quels", "quelle", + "quelles", "sans", "soi", +]; + +pub const GERMAN: &[&str] = &[ + "aber", + "alle", + "allem", + "allen", + "aller", + "alles", + "als", + "also", + "am", + "an", + "ander", + "andere", + "anderem", + "anderen", + "anderer", + "anderes", + "anderm", + "andern", + "anderr", + "anders", + "auch", + "auf", + "aus", + "bei", + "bin", + "bis", + "bist", + "da", + "damit", + "dann", + "der", + "den", + "des", + "dem", + "die", + "das", + "daß", + "derselbe", + "derselben", + "denselben", + "desselben", + "demselben", + "dieselbe", + "dieselben", + "dasselbe", + "dazu", + "dein", + "deine", + "deinem", + "deinen", + "deiner", + "deines", + "denn", + "derer", + "dessen", + "dich", + "dir", + "du", + "dies", + "diese", + "diesem", + "diesen", + "dieser", + "dieses", + "doch", + "dort", + "durch", + "ein", + "eine", + "einem", + "einen", + "einer", + "eines", + "einig", + "einige", + "einigem", + "einigen", + "einiger", + "einiges", + "einmal", + "er", + "ihn", + "ihm", + "es", + "etwas", + "euer", + "eure", + "eurem", + "euren", + "eurer", + "eures", + "für", + "gegen", + "gewesen", + "hab", + "habe", + "haben", + "hat", + "hatte", + "hatten", + "hier", + "hin", + "hinter", + "ich", + "mich", + "mir", + "ihr", + "ihre", + "ihrem", + "ihren", + "ihrer", + "ihres", + "euch", + "im", + "in", + "indem", + "ins", + "ist", + "jede", + "jedem", + "jeden", + "jeder", + "jedes", + "jene", + "jenem", + "jenen", + "jener", + "jenes", + "jetzt", + "kann", + "kein", + "keine", + "keinem", + "keinen", + "keiner", + "keines", + "können", + "könnte", + "machen", + "man", + "manche", + "manchem", + "manchen", + "mancher", + "manches", + "mein", + "meine", + "meinem", + "meinen", + "meiner", + "meines", + "mit", + "muss", + "musste", + "nach", + "nicht", + "nichts", + "noch", + "nun", + "nur", + "ob", + "oder", + "ohne", + "sehr", + "sein", + "seine", + "seinem", + "seinen", + "seiner", + "seines", + "selbst", + "sich", + "sie", + "ihnen", + "sind", + "so", + "solche", + "solchem", + "solchen", + "solcher", + "solches", + "soll", + "sollte", + "sondern", + "sonst", + "über", + "um", + "und", + "uns", + "unse", + "unsem", + "unsen", + "unser", + "unses", + "unter", + "viel", + "vom", + "von", + "vor", + "während", + "war", + "waren", + "warst", + "was", + "weg", + "weil", + "weiter", + "welche", + "welchem", + "welchen", + "welcher", + "welches", + "wenn", + "werde", + "werden", + "wie", + "wieder", + "will", + "wir", + "wird", + "wirst", + "wo", + "wollen", + "wollte", + "würde", + "würden", + "zu", + "zum", + "zur", + "zwar", + "zwischen", +]; + +pub const HUNGARIAN: &[&str] = &[ + "a", + "ahogy", + "ahol", + "aki", + "akik", + "akkor", + "alatt", + "által", + "általában", + "amely", + "amelyek", + "amelyekben", + "amelyeket", + "amelyet", + "amelynek", + "ami", + "amit", + "amolyan", + "amíg", + "amikor", + "át", + "abban", + "ahhoz", + "annak", + "arra", + "arról", + "az", + "azok", + "azon", + "azt", + "azzal", + "azért", + "aztán", + "azután", + "azonban", + "bár", + "be", + "belül", + "benne", + "cikk", + "cikkek", + "cikkeket", + "csak", + "de", + "e", + "eddig", + "egész", + "egy", + "egyes", + "egyetlen", + "egyéb", + "egyik", + "egyre", + "ekkor", + "el", + "elég", + "ellen", + "elő", + "először", + "előtt", + "első", + "én", + "éppen", + "ebben", + "ehhez", + "emilyen", + "ennek", + "erre", + "ez", + "ezt", + "ezek", + "ezen", + "ezzel", + "ezért", + "és", + "fel", + "felé", + "hanem", + "hiszen", + "hogy", + "hogyan", + "igen", + "így", + "illetve", + "ill.", + "ill", + "ilyen", + "ilyenkor", + "ison", + "ismét", + "itt", + "jó", + "jól", + "jobban", + "kell", + "kellett", + "keresztül", + "keressünk", + "ki", + "kívül", + "között", + "közül", + "legalább", + "lehet", + "lehetett", + "legyen", + "lenne", + "lenni", + "lesz", + "lett", + "maga", + "magát", + "majd", + "majd", + "már", + "más", + "másik", + "meg", + "még", + "mellett", + "mert", + "mely", + "melyek", + "mi", + "mit", + "míg", + "miért", + "milyen", + "mikor", + "minden", + "mindent", + "mindenki", + "mindig", + "mint", + "mintha", + "mivel", + "most", + "nagy", + "nagyobb", + "nagyon", + "ne", + "néha", + "nekem", + "neki", + "nem", + "néhány", + "nélkül", + "nincs", + "olyan", + "ott", + "össze", + "ő", + "ők", + "őket", + "pedig", + "persze", + "rá", + "s", + "saját", + "sem", + "semmi", + "sok", + "sokat", + "sokkal", + "számára", + "szemben", + "szerint", + "szinte", + "talán", + "tehát", + "teljes", + "tovább", + "továbbá", + "több", + "úgy", + "ugyanis", + "új", + "újabb", + "újra", + "után", + "utána", + "utolsó", + "vagy", + "vagyis", + "valaki", + "valami", + "valamint", + "való", + "vagyok", + "van", + "vannak", + "volt", + "voltam", + "voltak", + "voltunk", + "vissza", + "vele", + "viszont", + "volna", +]; + +pub const ITALIAN: &[&str] = &[ + "ad", + "al", + "allo", + "ai", + "agli", + "all", + "agl", + "alla", + "alle", + "con", + "col", + "coi", + "da", + "dal", + "dallo", + "dai", + "dagli", + "dall", + "dagl", + "dalla", + "dalle", + "di", + "del", + "dello", + "dei", + "degli", + "dell", + "degl", + "della", + "delle", + "in", + "nel", + "nello", + "nei", + "negli", + "nell", + "negl", + "nella", + "nelle", + "su", + "sul", + "sullo", + "sui", + "sugli", + "sull", + "sugl", + "sulla", + "sulle", + "per", + "tra", + "contro", + "io", + "tu", + "lui", + "lei", + "noi", + "voi", + "loro", + "mio", + "mia", + "miei", + "mie", + "tuo", + "tua", + "tuoi", + "tue", + "suo", + "sua", + "suoi", + "sue", + "nostro", + "nostra", + "nostri", + "nostre", + "vostro", + "vostra", + "vostri", + "vostre", + "mi", + "ti", + "ci", + "vi", + "lo", + "la", + "li", + "le", + "gli", + "ne", + "il", + "un", + "uno", + "una", + "ma", + "ed", + "se", + "perché", + "anche", + "come", + "dov", + "dove", + "che", + "chi", + "cui", + "non", + "più", + "quale", + "quanto", + "quanti", + "quanta", + "quante", + "quello", + "quelli", + "quella", + "quelle", + "questo", + "questi", + "questa", + "queste", + "si", + "tutto", + "tutti", + "a", + "c", + "e", + "i", + "l", + "o", + "ho", + "hai", + "ha", + "abbiamo", + "avete", + "hanno", + "abbia", + "abbiate", + "abbiano", + "avrò", + "avrai", + "avrà", + "avremo", + "avrete", + "avranno", + "avrei", + "avresti", + "avrebbe", + "avremmo", + "avreste", + "avrebbero", + "avevo", + "avevi", + "aveva", + "avevamo", + "avevate", + "avevano", + "ebbi", + "avesti", + "ebbe", + "avemmo", + "aveste", + "ebbero", + "avessi", + "avesse", + "avessimo", + "avessero", + "avendo", + "avuto", + "avuta", + "avuti", + "avute", + "sono", + "sei", + "è", + "siamo", + "siete", + "sia", + "siate", + "siano", + "sarò", + "sarai", + "sarà", + "saremo", + "sarete", + "saranno", + "sarei", + "saresti", + "sarebbe", + "saremmo", + "sareste", + "sarebbero", + "ero", + "eri", + "era", + "eravamo", + "eravate", + "erano", + "fui", + "fosti", + "fu", + "fummo", + "foste", + "furono", + "fossi", + "fosse", + "fossimo", + "fossero", + "essendo", + "faccio", + "fai", + "facciamo", + "fanno", + "faccia", + "facciate", + "facciano", + "farò", + "farai", + "farà", + "faremo", + "farete", + "faranno", + "farei", + "faresti", + "farebbe", + "faremmo", + "fareste", + "farebbero", + "facevo", + "facevi", + "faceva", + "facevamo", + "facevate", + "facevano", + "feci", + "facesti", + "fece", + "facemmo", + "faceste", + "fecero", + "facessi", + "facesse", + "facessimo", + "facessero", + "facendo", + "sto", + "stai", + "sta", + "stiamo", + "stanno", + "stia", + "stiate", + "stiano", + "starò", + "starai", + "starà", + "staremo", + "starete", + "staranno", + "starei", + "staresti", + "starebbe", + "staremmo", + "stareste", + "starebbero", + "stavo", + "stavi", + "stava", + "stavamo", + "stavate", + "stavano", + "stetti", + "stesti", + "stette", + "stemmo", + "steste", + "stettero", + "stessi", + "stesse", + "stessimo", + "stessero", + "stando", +]; + +pub const NORWEGIAN: &[&str] = &[ + "og", + "i", + "jeg", + "det", + "at", + "en", + "et", + "den", + "til", + "er", + "som", + "på", + "de", + "med", + "han", + "av", + "ikke", + "ikkje", + "der", + "så", + "var", + "meg", + "seg", + "men", + "ett", + "har", + "om", + "vi", + "min", + "mitt", + "ha", + "hadde", + "hun", + "nå", + "over", + "da", + "ved", + "fra", + "du", + "ut", + "sin", + "dem", + "oss", + "opp", + "man", + "kan", + "hans", + "hvor", + "eller", + "hva", + "skal", + "selv", + "sjøl", + "her", + "alle", + "vil", + "bli", + "ble", + "blei", + "blitt", + "kunne", + "inn", + "når", + "være", + "kom", + "noen", + "noe", + "ville", + "dere", + "deres", + "kun", + "ja", + "etter", + "ned", + "skulle", + "denne", + "for", + "deg", + "si", + "sine", + "sitt", + "mot", + "å", + "meget", + "hvorfor", + "dette", + "disse", + "uten", + "hvordan", + "ingen", + "din", + "ditt", + "blir", + "samme", + "hvilken", + "hvilke", + "sånn", + "inni", + "mellom", + "vår", + "hver", + "hvem", + "vors", + "hvis", + "både", + "bare", + "enn", + "fordi", + "før", + "mange", + "også", + "slik", + "vært", + "båe", + "begge", + "siden", + "dykk", + "dykkar", + "dei", + "deira", + "deires", + "deim", + "di", + "då", + "eg", + "ein", + "eit", + "eitt", + "elles", + "honom", + "hjå", + "ho", + "hoe", + "henne", + "hennar", + "hennes", + "hoss", + "hossen", + "ingi", + "inkje", + "korleis", + "korso", + "kva", + "kvar", + "kvarhelst", + "kven", + "kvi", + "kvifor", + "me", + "medan", + "mi", + "mine", + "mykje", + "no", + "nokon", + "noka", + "nokor", + "noko", + "nokre", + "sia", + "sidan", + "so", + "somt", + "somme", + "um", + "upp", + "vere", + "vore", + "verte", + "vort", + "varte", + "vart", +]; + +pub const PORTUGUESE: &[&str] = &[ + "de", + "a", + "o", + "que", + "e", + "do", + "da", + "em", + "um", + "para", + "com", + "não", + "uma", + "os", + "no", + "se", + "na", + "por", + "mais", + "as", + "dos", + "como", + "mas", + "ao", + "ele", + "das", + "à", + "seu", + "sua", + "ou", + "quando", + "muito", + "nos", + "já", + "eu", + "também", + "só", + "pelo", + "pela", + "até", + "isso", + "ela", + "entre", + "depois", + "sem", + "mesmo", + "aos", + "seus", + "quem", + "nas", + "me", + "esse", + "eles", + "você", + "essa", + "num", + "nem", + "suas", + "meu", + "às", + "minha", + "numa", + "pelos", + "elas", + "qual", + "nós", + "lhe", + "deles", + "essas", + "esses", + "pelas", + "este", + "dele", + "tu", + "te", + "vocês", + "vos", + "lhes", + "meus", + "minhas", + "teu", + "tua", + "teus", + "tuas", + "nosso", + "nossa", + "nossos", + "nossas", + "dela", + "delas", + "esta", + "estes", + "estas", + "aquele", + "aquela", + "aqueles", + "aquelas", + "isto", + "aquilo", + "estou", + "está", + "estamos", + "estão", + "estive", + "esteve", + "estivemos", + "estiveram", + "estava", + "estávamos", + "estavam", + "estivera", + "estivéramos", + "esteja", + "estejamos", + "estejam", + "estivesse", + "estivéssemos", + "estivessem", + "estiver", + "estivermos", + "estiverem", + "hei", + "há", + "havemos", + "hão", + "houve", + "houvemos", + "houveram", + "houvera", + "houvéramos", + "haja", + "hajamos", + "hajam", + "houvesse", + "houvéssemos", + "houvessem", + "houver", + "houvermos", + "houverem", + "houverei", + "houverá", + "houveremos", + "houverão", + "houveria", + "houveríamos", + "houveriam", + "sou", + "somos", + "são", + "era", + "éramos", + "eram", + "fui", + "foi", + "fomos", + "foram", + "fora", + "fôramos", + "seja", + "sejamos", + "sejam", + "fosse", + "fôssemos", + "fossem", + "for", + "formos", + "forem", + "serei", + "será", + "seremos", + "serão", + "seria", + "seríamos", + "seriam", + "tenho", + "tem", + "temos", + "tém", + "tinha", + "tínhamos", + "tinham", + "tive", + "teve", + "tivemos", + "tiveram", + "tivera", + "tivéramos", + "tenha", + "tenhamos", + "tenham", + "tivesse", + "tivéssemos", + "tivessem", + "tiver", + "tivermos", + "tiverem", + "terei", + "terá", + "teremos", + "terão", + "teria", + "teríamos", + "teriam", +]; + +pub const RUSSIAN: &[&str] = &[ + "и", + "в", + "во", + "не", + "что", + "он", + "на", + "я", + "с", + "со", + "как", + "а", + "то", + "все", + "она", + "так", + "его", + "но", + "да", + "ты", + "к", + "у", + "же", + "вы", + "за", + "бы", + "по", + "только", + "ее", + "мне", + "было", + "вот", + "от", + "меня", + "еще", + "нет", + "о", + "из", + "ему", + "теперь", + "когда", + "даже", + "ну", + "вдруг", + "ли", + "если", + "уже", + "или", + "ни", + "быть", + "был", + "него", + "до", + "вас", + "нибудь", + "опять", + "уж", + "вам", + "сказал", + "ведь", + "там", + "потом", + "себя", + "ничего", + "ей", + "может", + "они", + "тут", + "где", + "есть", + "надо", + "ней", + "для", + "мы", + "тебя", + "их", + "чем", + "была", + "сам", + "чтоб", + "без", + "будто", + "человек", + "чего", + "раз", + "тоже", + "себе", + "под", + "жизнь", + "будет", + "ж", + "тогда", + "кто", + "этот", + "говорил", + "того", + "потому", + "этого", + "какой", + "совсем", + "ним", + "здесь", + "этом", + "один", + "почти", + "мой", + "тем", + "чтобы", + "нее", + "кажется", + "сейчас", + "были", + "куда", + "зачем", + "сказать", + "всех", + "никогда", + "сегодня", + "можно", + "при", + "наконец", + "два", + "об", + "другой", + "хоть", + "после", + "над", + "больше", + "тот", + "через", + "эти", + "нас", + "про", + "всего", + "них", + "какая", + "много", + "разве", + "сказала", + "три", + "эту", + "моя", + "впрочем", + "хорошо", + "свою", + "этой", + "перед", + "иногда", + "лучше", + "чуть", + "том", + "нельзя", + "такой", + "им", + "более", + "всегда", + "конечно", + "всю", + "между", +]; + +pub const SPANISH: &[&str] = &[ + "de", + "la", + "que", + "el", + "en", + "y", + "a", + "los", + "del", + "se", + "las", + "por", + "un", + "para", + "con", + "no", + "una", + "su", + "al", + "lo", + "como", + "más", + "pero", + "sus", + "le", + "ya", + "o", + "este", + "sí", + "porque", + "esta", + "entre", + "cuando", + "muy", + "sin", + "sobre", + "también", + "me", + "hasta", + "hay", + "donde", + "quien", + "desde", + "todo", + "nos", + "durante", + "todos", + "uno", + "les", + "ni", + "contra", + "otros", + "ese", + "eso", + "ante", + "ellos", + "e", + "esto", + "mí", + "antes", + "algunos", + "qué", + "unos", + "yo", + "otro", + "otras", + "otra", + "él", + "tanto", + "esa", + "estos", + "mucho", + "quienes", + "nada", + "muchos", + "cual", + "poco", + "ella", + "estar", + "estas", + "algunas", + "algo", + "nosotros", + "mi", + "mis", + "tú", + "te", + "ti", + "tu", + "tus", + "ellas", + "nosotras", + "vosotros", + "vosotras", + "os", + "mío", + "mía", + "míos", + "mías", + "tuyo", + "tuya", + "tuyos", + "tuyas", + "suyo", + "suya", + "suyos", + "suyas", + "nuestro", + "nuestra", + "nuestros", + "nuestras", + "vuestro", + "vuestra", + "vuestros", + "vuestras", + "esos", + "esas", + "estoy", + "estás", + "está", + "estamos", + "estáis", + "están", + "esté", + "estés", + "estemos", + "estéis", + "estén", + "estaré", + "estarás", + "estará", + "estaremos", + "estaréis", + "estarán", + "estaría", + "estarías", + "estaríamos", + "estaríais", + "estarían", + "estaba", + "estabas", + "estábamos", + "estabais", + "estaban", + "estuve", + "estuviste", + "estuvo", + "estuvimos", + "estuvisteis", + "estuvieron", + "estuviera", + "estuvieras", + "estuviéramos", + "estuvierais", + "estuvieran", + "estuviese", + "estuvieses", + "estuviésemos", + "estuvieseis", + "estuviesen", + "estando", + "estado", + "estada", + "estados", + "estadas", + "estad", + "he", + "has", + "ha", + "hemos", + "habéis", + "han", + "haya", + "hayas", + "hayamos", + "hayáis", + "hayan", + "habré", + "habrás", + "habrá", + "habremos", + "habréis", + "habrán", + "habría", + "habrías", + "habríamos", + "habríais", + "habrían", + "había", + "habías", + "habíamos", + "habíais", + "habían", + "hube", + "hubiste", + "hubo", + "hubimos", + "hubisteis", + "hubieron", + "hubiera", + "hubieras", + "hubiéramos", + "hubierais", + "hubieran", + "hubiese", + "hubieses", + "hubiésemos", + "hubieseis", + "hubiesen", + "habiendo", + "habido", + "habida", + "habidos", + "habidas", + "soy", + "eres", + "es", + "somos", + "sois", + "son", + "sea", + "seas", + "seamos", + "seáis", + "sean", + "seré", + "serás", + "será", + "seremos", + "seréis", + "serán", + "sería", + "serías", + "seríamos", + "seríais", + "serían", + "era", + "eras", + "éramos", + "erais", + "eran", + "fui", + "fuiste", + "fue", + "fuimos", + "fuisteis", + "fueron", + "fuera", + "fueras", + "fuéramos", + "fuerais", + "fueran", + "fuese", + "fueses", + "fuésemos", + "fueseis", + "fuesen", + "siendo", + "sido", + "tengo", + "tienes", + "tiene", + "tenemos", + "tenéis", + "tienen", + "tenga", + "tengas", + "tengamos", + "tengáis", + "tengan", + "tendré", + "tendrás", + "tendrá", + "tendremos", + "tendréis", + "tendrán", + "tendría", + "tendrías", + "tendríamos", + "tendríais", + "tendrían", + "tenía", + "tenías", + "teníamos", + "teníais", + "tenían", + "tuve", + "tuviste", + "tuvo", + "tuvimos", + "tuvisteis", + "tuvieron", + "tuviera", + "tuvieras", + "tuviéramos", + "tuvierais", + "tuvieran", + "tuviese", + "tuvieses", + "tuviésemos", + "tuvieseis", + "tuviesen", + "teniendo", + "tenido", + "tenida", + "tenidos", + "tenidas", + "tened", +]; + +pub const SWEDISH: &[&str] = &[ + "och", "det", "att", "i", "en", "jag", "hon", "som", "han", "på", "den", "med", "var", "sig", + "för", "så", "till", "är", "men", "ett", "om", "hade", "de", "av", "icke", "mig", "du", + "henne", "då", "sin", "nu", "har", "inte", "hans", "honom", "skulle", "hennes", "där", "min", + "man", "ej", "vid", "kunde", "något", "från", "ut", "när", "efter", "upp", "vi", "dem", "vara", + "vad", "över", "än", "dig", "kan", "sina", "här", "ha", "mot", "alla", "under", "någon", + "eller", "allt", "mycket", "sedan", "ju", "denna", "själv", "detta", "åt", "utan", "varit", + "hur", "ingen", "mitt", "ni", "bli", "blev", "oss", "din", "dessa", "några", "deras", "blir", + "mina", "samma", "vilken", "er", "sådan", "vår", "blivit", "dess", "inom", "mellan", "sådant", + "varför", "varje", "vilka", "ditt", "vem", "vilket", "sitt", "sådana", "vart", "dina", "vars", + "vårt", "våra", "ert", "era", "vilkas", +]; diff --git a/rust/lance-tokenizer/src/tokenizer_api.rs b/rust/lance-tokenizer/src/tokenizer_api.rs new file mode 100644 index 00000000000..82dc781e798 --- /dev/null +++ b/rust/lance-tokenizer/src/tokenizer_api.rs @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 tokenizer API. +// Copyright (c) 2017-present Tantivy contributors. + +use std::borrow::{Borrow, BorrowMut}; +use std::ops::{Deref, DerefMut}; + +/// Token emitted by a tokenizer. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Token { + /// Byte offset of the first character. + pub offset_from: usize, + /// Byte offset after the last character. + pub offset_to: usize, + /// Logical token position. + pub position: usize, + /// Token text. + pub text: String, + /// Position length measured in original tokens. + pub position_length: usize, +} + +impl Default for Token { + fn default() -> Self { + Self { + offset_from: 0, + offset_to: 0, + position: usize::MAX, + text: String::new(), + position_length: 1, + } + } +} + +impl Token { + /// Reset the token to its default state. + pub fn reset(&mut self) { + self.offset_from = 0; + self.offset_to = 0; + self.position = usize::MAX; + self.text.clear(); + self.position_length = 1; + } +} + +/// Tokenizer splits text into a token stream. +pub trait Tokenizer: 'static + Clone + Send + Sync { + /// Stream type emitted by the tokenizer. + type TokenStream<'a>: TokenStream; + + /// Create a token stream for the provided text. + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a>; +} + +/// Token stream object-safe wrapper. +pub struct BoxTokenStream<'a>(Box); + +impl<'a> BoxTokenStream<'a> { + pub fn new(token_stream: T) -> Self { + Self(Box::new(token_stream)) + } +} + +impl TokenStream for BoxTokenStream<'_> { + fn advance(&mut self) -> bool { + self.0.advance() + } + + fn token(&self) -> &Token { + self.0.token() + } + + fn token_mut(&mut self) -> &mut Token { + self.0.token_mut() + } +} + +impl<'a> Deref for BoxTokenStream<'a> { + type Target = dyn TokenStream + 'a; + + fn deref(&self) -> &Self::Target { + &*self.0 + } +} + +impl DerefMut for BoxTokenStream<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut *self.0 + } +} + +impl<'a> TokenStream for Box { + fn advance(&mut self) -> bool { + let token_stream: &mut dyn TokenStream = self.borrow_mut(); + token_stream.advance() + } + + fn token(&self) -> &Token { + let token_stream: &(dyn TokenStream + 'a) = self.borrow(); + token_stream.token() + } + + fn token_mut(&mut self) -> &mut Token { + let token_stream: &mut (dyn TokenStream + 'a) = self.borrow_mut(); + token_stream.token_mut() + } +} + +/// Consumable token stream. +pub trait TokenStream { + /// Advance to the next token. + fn advance(&mut self) -> bool; + + /// Access the current token. + fn token(&self) -> &Token; + + /// Mutate the current token. + fn token_mut(&mut self) -> &mut Token; + + /// Iterate to the next token and return it. + fn next(&mut self) -> Option<&Token> { + if self.advance() { + Some(self.token()) + } else { + None + } + } + + /// Consume the remaining stream into the provided sink. + fn process(&mut self, sink: &mut dyn FnMut(&Token)) { + while self.advance() { + sink(self.token()); + } + } +} + +/// Filter that wraps a tokenizer with additional token-processing behavior. +pub trait TokenFilter: 'static + Send + Sync { + /// Tokenizer produced by this filter. + type Tokenizer: Tokenizer; + + /// Wrap the tokenizer. + fn transform(self, tokenizer: T) -> Self::Tokenizer; +} diff --git a/rust/lance-tokenizer/src/whitespace_tokenizer.rs b/rust/lance-tokenizer/src/whitespace_tokenizer.rs new file mode 100644 index 00000000000..80b793f2ed4 --- /dev/null +++ b/rust/lance-tokenizer/src/whitespace_tokenizer.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors +// SPDX-License-Identifier: MIT +// Adapted from Tantivy v0.24.2 whitespace tokenizer. +// Copyright (c) 2017-present Tantivy contributors. + +use std::str::CharIndices; + +use crate::{Token, TokenStream, Tokenizer}; + +#[derive(Clone, Default)] +pub struct WhitespaceTokenizer { + token: Token, +} + +pub struct WhitespaceTokenStream<'a> { + text: &'a str, + chars: CharIndices<'a>, + token: &'a mut Token, +} + +impl Tokenizer for WhitespaceTokenizer { + type TokenStream<'a> = WhitespaceTokenStream<'a>; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + self.token.reset(); + WhitespaceTokenStream { + text, + chars: text.char_indices(), + token: &mut self.token, + } + } +} + +impl WhitespaceTokenStream<'_> { + fn search_token_end(&mut self) -> usize { + (&mut self.chars) + .filter(|(_, c)| c.is_ascii_whitespace()) + .map(|(offset, _)| offset) + .next() + .unwrap_or(self.text.len()) + } +} + +impl TokenStream for WhitespaceTokenStream<'_> { + fn advance(&mut self) -> bool { + self.token.text.clear(); + self.token.position = self.token.position.wrapping_add(1); + while let Some((offset_from, c)) = self.chars.next() { + if !c.is_ascii_whitespace() { + let offset_to = self.search_token_end(); + self.token.offset_from = offset_from; + self.token.offset_to = offset_to; + self.token.text.push_str(&self.text[offset_from..offset_to]); + return true; + } + } + false + } + + fn token(&self) -> &Token { + self.token + } + + fn token_mut(&mut self) -> &mut Token { + self.token + } +} diff --git a/rust/lance/Cargo.toml b/rust/lance/Cargo.toml index afb7886b5cf..1e2e6893670 100644 --- a/rust/lance/Cargo.toml +++ b/rust/lance/Cargo.toml @@ -25,6 +25,7 @@ lance-io = { workspace = true } lance-linalg = { workspace = true } lance-index = { workspace = true } lance-namespace = { workspace = true } +lance-tokenizer = { workspace = true } lance-table = { workspace = true } lance-geo = { workspace = true, optional = true } arrow-arith = { workspace = true } @@ -76,7 +77,6 @@ serde_json = { workspace = true } serde = { workspace = true } moka.workspace = true permutation = { version = "0.4.0" } -tantivy.workspace = true aws-sdk-dynamodb = { workspace = true, optional = true, default-features = false, features = ["default-https-client", "rt-tokio"] } tracing.workspace = true humantime = { workspace = true } diff --git a/rust/lance/src/dataset/mem_wal/index/fts.rs b/rust/lance/src/dataset/mem_wal/index/fts.rs index 59b9817a4f0..59200654b32 100644 --- a/rust/lance/src/dataset/mem_wal/index/fts.rs +++ b/rust/lance/src/dataset/mem_wal/index/fts.rs @@ -33,8 +33,8 @@ use crossbeam_skiplist::SkipMap; use datafusion::common::ScalarValue; use lance_core::{Error, Result}; use lance_index::scalar::InvertedIndexParams; -use lance_index::scalar::inverted::tokenizer::lance_tokenizer::LanceTokenizer; -use tantivy::tokenizer::TokenStream; +use lance_index::scalar::inverted::tokenizer::document_tokenizer::LanceTokenizer; +use lance_tokenizer::TokenStream; use super::RowPosition; diff --git a/rust/lance/src/io/exec/fts.rs b/rust/lance/src/io/exec/fts.rs index 1e7938d15f4..1cf8eddc3ec 100644 --- a/rust/lance/src/io/exec/fts.rs +++ b/rust/lance/src/io/exec/fts.rs @@ -30,16 +30,17 @@ use crate::{Dataset, index::DatasetIndexInternalExt}; use lance_index::IndexCriteria; use lance_index::metrics::MetricsCollector; use lance_index::scalar::inverted::builder::document_input; -use lance_index::scalar::inverted::lance_tokenizer::{DocType, JsonTokenizer, LanceTokenizer}; +use lance_index::scalar::inverted::document_tokenizer::{DocType, JsonTokenizer, LanceTokenizer}; use lance_index::scalar::inverted::query::{ BoostQuery, FtsSearchParams, MatchQuery, PhraseQuery, Tokens, collect_query_tokens, has_query_token, }; -use lance_index::scalar::inverted::tokenizer::lance_tokenizer::TextTokenizer; +use lance_index::scalar::inverted::tokenizer::document_tokenizer::TextTokenizer; use lance_index::scalar::inverted::{ FTS_SCHEMA, InvertedIndex, SCORE_COL, flat_bm25_search_stream, }; use lance_index::{prefilter::PreFilter, scalar::inverted::query::BooleanQuery}; +use lance_tokenizer::{SimpleTokenizer, TextAnalyzer}; use tracing::instrument; pub struct FtsIndexMetrics { @@ -275,9 +276,7 @@ impl ExecutionPlan for MatchQueryExec { let mut tokenizer = match is_fuzzy { false => inverted_idx.tokenizer(), true => { - let tokenizer = tantivy::tokenizer::TextAnalyzer::from( - tantivy::tokenizer::SimpleTokenizer::default(), - ); + let tokenizer = TextAnalyzer::from(SimpleTokenizer::default()); match inverted_idx.tokenizer().doc_type() { DocType::Text => { Box::new(TextTokenizer::new(tokenizer)) as Box @@ -397,10 +396,7 @@ impl FlatMatchFilterExec { } // Else, no index, use text tokenzier Ok(Box::new(TextTokenizer::new( - tantivy::tokenizer::TextAnalyzer::builder( - tantivy::tokenizer::SimpleTokenizer::default(), - ) - .build(), + TextAnalyzer::builder(SimpleTokenizer::default()).build(), ))) } diff --git a/rust/lance/tests/query/inverted.rs b/rust/lance/tests/query/inverted.rs index c9ce1231d92..4215292ca7a 100644 --- a/rust/lance/tests/query/inverted.rs +++ b/rust/lance/tests/query/inverted.rs @@ -9,9 +9,9 @@ use lance::dataset::scanner::ColumnOrdering; use lance::dataset::{InsertBuilder, WriteParams}; use lance::index::DatasetIndexExt; use lance_index::IndexType; +use lance_index::scalar::inverted::Language; use lance_index::scalar::inverted::query::{FtsQuery, PhraseQuery}; use lance_index::scalar::{FullTextSearchQuery, InvertedIndexParams}; -use tantivy::tokenizer::Language; use super::{strip_score_column, test_fts, test_scan, test_take}; use crate::utils::DatasetTestCases; From 5c83b8456846bd94f56ed6280b2e0c4d63516cb5 Mon Sep 17 00:00:00 2001 From: Justin Miller Date: Tue, 14 Apr 2026 23:24:26 -0700 Subject: [PATCH 190/206] perf: add SIMD-accelerated u8 L2 and cosine distance kernels (#6517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add hand-written AVX2 and AVX-512 VNNI backends for u8 squared L2 distance (`Σ(a-b)²`) in new `l2_u8.rs` - Add fused single-pass u8 cosine distance kernel in new `cosine_u8.rs` — computes `dot(a,b)`, `‖a‖²`, `‖b‖²` simultaneously, halving memory traffic vs the previous 2-3 pass approach - Wire both into the `L2 for u8` and `Cosine for u8` trait impls - Add benchmarks comparing scalar vs SIMD for both kernels ### Algorithmic approach (adapted from [NumKong](https://github.com/ashvardanian/NumKong)) **L2 (AVX2):** Saturating subtraction for `|a-b|`, zero-extend u8→i16, `VPMADDWD(diff, diff)` to square and accumulate into i32. 32 elements/iter. **L2 (AVX-512 VNNI):** Same abs-diff approach with `VPDPWSSD` for fused square-accumulate. 64 elements/iter. **Cosine (AVX2):** Zero-extend both vectors to i16, triple `VPMADDWD` per half (a·b, a·a, b·b). 32 elements/iter, single pass. **Cosine (AVX-512 VNNI):** Same three-accumulator approach with `VPDPWSSD`. 64 elements/iter. Both kernels use `OnceLock`-based runtime CPU dispatch, falling back to portable scalar on non-x86 platforms. ### Benchmarks *1M × 1024-dim u8 vectors.* **x86_64 — AMD Ryzen 5 4500 6-Core (AVX2, no AVX-512)** | Kernel | Scalar | SIMD | Speedup | |--------|--------|------|---------| | L2(u8) | 73.5 ms | 58.2 ms | **1.26x** | | Cosine(u8) | 122.2 ms | 82.1 ms | **1.49x** | L2 auto-vectorization baseline was 91.5 ms, so SIMD is 1.57x faster than that path. **aarch64 — Apple Silicon M3 Max (no AVX2, scalar fallback)** | Kernel | Scalar | SIMD (dispatch) | |--------|--------|-----------------| | L2(u8) | 26.8 ms | 27.3 ms | | Cosine(u8) | 90.1 ms | 90.4 ms | On aarch64 the SIMD path falls through to scalar (no AVX2), so times are identical — confirms no regression on non-x86 platforms. AVX-512 VNNI systems (Ice Lake+, Zen 4+) should see larger gains. ## Test plan - [x] All 11 new tests pass: SIMD backends verified against scalar reference across 18 vector sizes (0–4097), boundary values (0/255), alternating patterns, random seeds - [x] All 63 existing lance-linalg tests pass (no regressions) - [x] Clippy clean, fmt clean - [x] Benchmarked on x86_64 AVX2 (AMD Ryzen 5 4500) — L2 1.26x, Cosine 1.49x faster - [ ] Verify on AVX-512 VNNI system for additional speedup data 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-linalg/benches/cosine.rs | 37 ++ rust/lance-linalg/benches/l2.rs | 12 + rust/lance-linalg/src/distance.rs | 2 + rust/lance-linalg/src/distance/cosine.rs | 7 +- rust/lance-linalg/src/distance/cosine_u8.rs | 369 ++++++++++++++++++++ rust/lance-linalg/src/distance/l2.rs | 2 +- rust/lance-linalg/src/distance/l2_u8.rs | 272 +++++++++++++++ 7 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 rust/lance-linalg/src/distance/cosine_u8.rs create mode 100644 rust/lance-linalg/src/distance/l2_u8.rs diff --git a/rust/lance-linalg/benches/cosine.rs b/rust/lance-linalg/benches/cosine.rs index 57b26538d09..aa812339b86 100644 --- a/rust/lance-linalg/benches/cosine.rs +++ b/rust/lance-linalg/benches/cosine.rs @@ -8,6 +8,7 @@ use arrow_array::{ use criterion::{Criterion, black_box, criterion_group, criterion_main}; use lance_arrow::{ArrowFloatType, FloatArray, bfloat16::BFloat16Type}; use lance_linalg::distance::cosine::{Cosine, cosine_distance_batch}; +use lance_linalg::distance::cosine_u8::{cosine_u8, cosine_u8_scalar}; use num_traits::Float; #[cfg(target_os = "linux")] @@ -76,6 +77,42 @@ fn bench_distance(c: &mut Criterion) { black_box(cosine_distance_batch(key.values(), target.values(), 8).collect::>()) }) }); + + // u8 cosine benchmarks + { + use rand::Rng; + use std::iter::repeat_with; + + const DIMENSION: usize = 1024; + const TOTAL: usize = 1024 * 1024; + let mut rng = rand::rng(); + let key_u8: Vec = repeat_with(|| rng.random()).take(DIMENSION).collect(); + let target_u8: Vec = repeat_with(|| rng.random()) + .take(TOTAL * DIMENSION) + .collect(); + + c.bench_function("Cosine(u8, scalar)", |b| { + b.iter(|| { + black_box( + target_u8 + .chunks_exact(DIMENSION) + .map(|tgt| cosine_u8_scalar(&key_u8, tgt)) + .fold(0.0, |acc: f32, v| acc + v), + ); + }); + }); + + c.bench_function("Cosine(u8, SIMD)", |b| { + b.iter(|| { + black_box( + target_u8 + .chunks_exact(DIMENSION) + .map(|tgt| cosine_u8(&key_u8, tgt)) + .fold(0.0, |acc: f32, v| acc + v), + ); + }); + }); + } } #[cfg(target_os = "linux")] diff --git a/rust/lance-linalg/benches/l2.rs b/rust/lance-linalg/benches/l2.rs index 81d364cc9a9..ac869c08981 100644 --- a/rust/lance-linalg/benches/l2.rs +++ b/rust/lance-linalg/benches/l2.rs @@ -15,6 +15,7 @@ use rand::Rng; use pprof::criterion::{Output, PProfProfiler}; use lance_arrow::{ArrowFloatType, FloatArray}; +use lance_linalg::distance::l2_u8::l2_u8; use lance_linalg::distance::{L2, l2::l2, l2_distance_batch, l2_distance_uint_scalar}; use lance_testing::datagen::generate_random_array_with_seed; @@ -157,6 +158,17 @@ fn bench_uint_distance(c: &mut Criterion) { ); }); }); + + c.bench_function("L2(u8, SIMD)", |b| { + b.iter(|| { + black_box( + target + .chunks_exact(DIMENSION) + .map(|tgt| l2_u8(&key, tgt) as f32) + .fold(0.0, |acc, v| acc + v), + ); + }); + }); } #[cfg(target_os = "linux")] diff --git a/rust/lance-linalg/src/distance.rs b/rust/lance-linalg/src/distance.rs index 84c81fe85ed..255abd971ed 100644 --- a/rust/lance-linalg/src/distance.rs +++ b/rust/lance-linalg/src/distance.rs @@ -17,9 +17,11 @@ use arrow_array::{Array, ArrowPrimitiveType, FixedSizeListArray, Float32Array, L use arrow_schema::{ArrowError, DataType}; pub mod cosine; +pub mod cosine_u8; pub mod dot; pub mod hamming; pub mod l2; +pub mod l2_u8; pub mod norm_l2; pub use cosine::*; diff --git a/rust/lance-linalg/src/distance/cosine.rs b/rust/lance-linalg/src/distance/cosine.rs index a010b80baed..79494199d3d 100644 --- a/rust/lance-linalg/src/distance/cosine.rs +++ b/rust/lance-linalg/src/distance/cosine.rs @@ -65,7 +65,12 @@ pub trait Cosine: Dot + Normalize { } } -impl Cosine for u8 {} +impl Cosine for u8 { + #[inline] + fn cosine(x: &[Self], other: &[Self]) -> f32 { + super::cosine_u8::cosine_u8(x, other) + } +} impl Cosine for bf16 {} diff --git a/rust/lance-linalg/src/distance/cosine_u8.rs b/rust/lance-linalg/src/distance/cosine_u8.rs new file mode 100644 index 00000000000..b2d06d35c31 --- /dev/null +++ b/rust/lance-linalg/src/distance/cosine_u8.rs @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Unsigned int8 cosine distance with runtime-dispatched SIMD backends. +//! +//! Computes `1 - dot(a,b) / (‖a‖ × ‖b‖)` for u8 slices in a single +//! pass over the data. The fused kernel maintains three accumulators +//! simultaneously — `Σ(a·b)`, `Σ(a²)`, `Σ(b²)` — so memory is only +//! traversed once instead of 2-3 times (norm + dot). +//! +//! Backends (selected at runtime, best available wins): +//! 1. scalar — portable reference, also used for tails +//! 2. avx2 — zero-extend u8→i16, triple VPMADDWD, 32 elements/iter +//! 3. avx512vnni — same with VPDPWSSD accumulation, 64 elements/iter + +use std::sync::OnceLock; + +/// Intermediate results from the fused u8 cosine kernel: (dot_ab, norm_a², norm_b²). +/// +/// Separated from the final normalization so SIMD backends can be tested +/// for exact integer equality before the f32 division. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CosineAccumulators { + pub dot_ab: u32, + pub norm_a_sq: u32, + pub norm_b_sq: u32, +} + +/// Portable scalar fused cosine accumulation. +#[inline] +pub fn cosine_u8_accum_scalar(a: &[u8], b: &[u8]) -> CosineAccumulators { + debug_assert_eq!(a.len(), b.len()); + let (mut dot_ab, mut norm_a_sq, mut norm_b_sq) = (0u32, 0u32, 0u32); + for (&x, &y) in a.iter().zip(b.iter()) { + let (xu, yu) = (x as u32, y as u32); + dot_ab += xu * yu; + norm_a_sq += xu * xu; + norm_b_sq += yu * yu; + } + CosineAccumulators { + dot_ab, + norm_a_sq, + norm_b_sq, + } +} + +/// Convert accumulators to cosine distance: `1 - dot / (‖a‖ × ‖b‖)`. +#[inline] +fn normalize(acc: CosineAccumulators) -> f32 { + let na = (acc.norm_a_sq as f32).sqrt(); + let nb = (acc.norm_b_sq as f32).sqrt(); + let denom = na * nb; + if denom == 0.0 { + // Both zero-norm → identical → distance 0. + // One zero-norm → undefined, but 0 is a safe sentinel. + return 0.0; + } + 1.0 - acc.dot_ab as f32 / denom +} + +/// Portable scalar u8 cosine distance. +#[inline] +pub fn cosine_u8_scalar(a: &[u8], b: &[u8]) -> f32 { + normalize(cosine_u8_accum_scalar(a, b)) +} + +#[cfg(target_arch = "x86_64")] +mod x86 { + use super::CosineAccumulators; + use std::arch::x86_64::*; + + /// Horizontal sum of all 8 × i32 lanes in a __m256i. + #[inline(always)] + unsafe fn hsum_epi32_avx2(v: __m256i) -> u32 { + let lo128 = _mm256_castsi256_si128(v); + let hi128 = _mm256_extracti128_si256(v, 1); + let mut sum128 = _mm_add_epi32(lo128, hi128); + sum128 = _mm_hadd_epi32(sum128, sum128); + sum128 = _mm_hadd_epi32(sum128, sum128); + _mm_cvtsi128_si32(sum128) as u32 + } + + /// AVX2 fused cosine: three VPMADDWD products per half, 32 elements/iter. + #[target_feature(enable = "avx2")] + pub unsafe fn cosine_u8_accum_avx2(a: &[u8], b: &[u8]) -> CosineAccumulators { + debug_assert_eq!(a.len(), b.len()); + let n = a.len(); + let mut acc_dot = _mm256_setzero_si256(); + let mut acc_na = _mm256_setzero_si256(); + let mut acc_nb = _mm256_setzero_si256(); + let mut i = 0usize; + + while i + 32 <= n { + let av = _mm256_loadu_si256(a.as_ptr().add(i) as *const __m256i); + let bv = _mm256_loadu_si256(b.as_ptr().add(i) as *const __m256i); + + // Zero-extend each 128-bit half to 16 × i16. + let a_lo = _mm256_cvtepu8_epi16(_mm256_castsi256_si128(av)); + let a_hi = _mm256_cvtepu8_epi16(_mm256_extracti128_si256(av, 1)); + let b_lo = _mm256_cvtepu8_epi16(_mm256_castsi256_si128(bv)); + let b_hi = _mm256_cvtepu8_epi16(_mm256_extracti128_si256(bv, 1)); + + // VPMADDWD: pairwise multiply i16 and accumulate pairs into i32. + acc_dot = _mm256_add_epi32(acc_dot, _mm256_madd_epi16(a_lo, b_lo)); + acc_dot = _mm256_add_epi32(acc_dot, _mm256_madd_epi16(a_hi, b_hi)); + acc_na = _mm256_add_epi32(acc_na, _mm256_madd_epi16(a_lo, a_lo)); + acc_na = _mm256_add_epi32(acc_na, _mm256_madd_epi16(a_hi, a_hi)); + acc_nb = _mm256_add_epi32(acc_nb, _mm256_madd_epi16(b_lo, b_lo)); + acc_nb = _mm256_add_epi32(acc_nb, _mm256_madd_epi16(b_hi, b_hi)); + i += 32; + } + + let mut dot_ab = hsum_epi32_avx2(acc_dot); + let mut norm_a_sq = hsum_epi32_avx2(acc_na); + let mut norm_b_sq = hsum_epi32_avx2(acc_nb); + + // Scalar tail + while i < n { + let (xu, yu) = (a[i] as u32, b[i] as u32); + dot_ab += xu * yu; + norm_a_sq += xu * xu; + norm_b_sq += yu * yu; + i += 1; + } + + CosineAccumulators { + dot_ab, + norm_a_sq, + norm_b_sq, + } + } + + /// AVX-512 VNNI fused cosine: VPDPWSSD for each product, 64 elements/iter. + #[target_feature(enable = "avx512f,avx512bw,avx512vnni")] + pub unsafe fn cosine_u8_accum_avx512_vnni(a: &[u8], b: &[u8]) -> CosineAccumulators { + debug_assert_eq!(a.len(), b.len()); + let n = a.len(); + let zeros = _mm512_setzero_si512(); + let mut acc_dot = _mm512_setzero_si512(); + let mut acc_na = _mm512_setzero_si512(); + let mut acc_nb = _mm512_setzero_si512(); + let mut i = 0usize; + + while i + 64 <= n { + let av = _mm512_loadu_si512(a.as_ptr().add(i) as *const __m512i); + let bv = _mm512_loadu_si512(b.as_ptr().add(i) as *const __m512i); + + // Zero-extend u8→i16 via interleave with zeros. + let a_lo = _mm512_unpacklo_epi8(av, zeros); + let a_hi = _mm512_unpackhi_epi8(av, zeros); + let b_lo = _mm512_unpacklo_epi8(bv, zeros); + let b_hi = _mm512_unpackhi_epi8(bv, zeros); + + // VPDPWSSD: signed i16 multiply-add into i32 accumulator. + acc_dot = _mm512_dpwssd_epi32(acc_dot, a_lo, b_lo); + acc_dot = _mm512_dpwssd_epi32(acc_dot, a_hi, b_hi); + acc_na = _mm512_dpwssd_epi32(acc_na, a_lo, a_lo); + acc_na = _mm512_dpwssd_epi32(acc_na, a_hi, a_hi); + acc_nb = _mm512_dpwssd_epi32(acc_nb, b_lo, b_lo); + acc_nb = _mm512_dpwssd_epi32(acc_nb, b_hi, b_hi); + i += 64; + } + + let mut dot_ab = _mm512_reduce_add_epi32(acc_dot) as u32; + let mut norm_a_sq = _mm512_reduce_add_epi32(acc_na) as u32; + let mut norm_b_sq = _mm512_reduce_add_epi32(acc_nb) as u32; + + // Scalar tail + while i < n { + let (xu, yu) = (a[i] as u32, b[i] as u32); + dot_ab += xu * yu; + norm_a_sq += xu * xu; + norm_b_sq += yu * yu; + i += 1; + } + + CosineAccumulators { + dot_ab, + norm_a_sq, + norm_b_sq, + } + } +} + +type CosineU8AccumFn = fn(&[u8], &[u8]) -> CosineAccumulators; + +static DISPATCH: OnceLock = OnceLock::new(); + +fn select_backend() -> CosineU8AccumFn { + #[cfg(target_arch = "x86_64")] + { + if is_x86_feature_detected!("avx512f") + && is_x86_feature_detected!("avx512bw") + && is_x86_feature_detected!("avx512vnni") + { + return |a, b| unsafe { x86::cosine_u8_accum_avx512_vnni(a, b) }; + } + + if is_x86_feature_detected!("avx2") { + return |a, b| unsafe { x86::cosine_u8_accum_avx2(a, b) }; + } + } + + cosine_u8_accum_scalar +} + +/// Dispatched fused u8 cosine accumulation. +#[inline] +fn cosine_u8_accum(a: &[u8], b: &[u8]) -> CosineAccumulators { + (DISPATCH.get_or_init(select_backend))(a, b) +} + +/// Dispatched u8 cosine distance, selecting the best available SIMD backend. +/// +/// Returns `1 - dot(a,b) / (‖a‖ × ‖b‖)` computed in a single pass. +#[inline] +pub fn cosine_u8(a: &[u8], b: &[u8]) -> f32 { + normalize(cosine_u8_accum(a, b)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fill_random(buf: &mut [u8], seed: &mut u32) { + for slot in buf.iter_mut() { + *seed = seed.wrapping_mul(1103515245).wrapping_add(12345); + *slot = (*seed >> 16) as u8; + } + } + + const SIZES: &[usize] = &[ + 0, 1, 7, 15, 16, 31, 32, 33, 63, 64, 65, 127, 128, 255, 256, 1024, 4096, 4097, + ]; + + /// Verify SIMD backends produce identical integer accumulators to scalar. + fn check_all_backends_accum(a: &[u8], b: &[u8], case: &str) { + let reference = cosine_u8_accum_scalar(a, b); + + #[cfg(target_arch = "x86_64")] + { + if is_x86_feature_detected!("avx2") { + let got = unsafe { x86::cosine_u8_accum_avx2(a, b) }; + assert_eq!(got, reference, "avx2 [{case}] n={}", a.len()); + } + + if is_x86_feature_detected!("avx512f") + && is_x86_feature_detected!("avx512bw") + && is_x86_feature_detected!("avx512vnni") + { + let got = unsafe { x86::cosine_u8_accum_avx512_vnni(a, b) }; + assert_eq!(got, reference, "avx512_vnni [{case}] n={}", a.len()); + } + } + + let dispatched = cosine_u8_accum(a, b); + assert_eq!(dispatched, reference, "dispatch [{case}] n={}", a.len()); + } + + #[test] + fn random_inputs_across_sizes_and_seeds() { + let mut a = vec![0u8; 4097]; + let mut b = vec![0u8; 4097]; + + for seed_idx in 0..4u32 { + let mut seed = 0xC0FFEE_u32.wrapping_add(seed_idx.wrapping_mul(7919)); + for &n in SIZES { + fill_random(&mut a[..n], &mut seed); + fill_random(&mut b[..n], &mut seed); + check_all_backends_accum(&a[..n], &b[..n], "random"); + } + } + } + + #[test] + fn boundary_values() { + let mut a = vec![0u8; 4097]; + let mut b = vec![0u8; 4097]; + + for &n in SIZES { + a[..n].fill(u8::MAX); + b[..n].fill(u8::MAX); + check_all_backends_accum(&a[..n], &b[..n], "max-max"); + + a[..n].fill(u8::MAX); + b[..n].fill(0); + check_all_backends_accum(&a[..n], &b[..n], "max-0"); + + a[..n].fill(0); + b[..n].fill(u8::MAX); + check_all_backends_accum(&a[..n], &b[..n], "0-max"); + + a[..n].fill(0); + b[..n].fill(0); + check_all_backends_accum(&a[..n], &b[..n], "0-0"); + + for i in 0..n { + a[i] = if i & 1 == 0 { 0 } else { u8::MAX }; + b[i] = if i & 1 == 0 { u8::MAX } else { 0 }; + } + check_all_backends_accum(&a[..n], &b[..n], "alt 0/max"); + } + } + + #[test] + fn one_sided_zeros() { + let mut a = vec![0u8; 4097]; + let mut b = vec![0u8; 4097]; + + for &n in SIZES { + let mut seed = 0xDEAD_BEEF_u32; + fill_random(&mut a[..n], &mut seed); + b[..n].fill(0); + check_all_backends_accum(&a[..n], &b[..n], "b=0"); + + a[..n].fill(0); + fill_random(&mut b[..n], &mut seed); + check_all_backends_accum(&a[..n], &b[..n], "a=0"); + } + } + + #[test] + fn cosine_known_values() { + // Identical vectors → distance 0 + let v = [10u8, 20, 30, 40]; + assert_eq!(cosine_u8(&v, &v), 0.0); + + // Orthogonal-ish: one vector all in first half, other in second half + let a = [255u8, 255, 0, 0]; + let b = [0u8, 0, 255, 255]; + assert_eq!(cosine_u8(&a, &b), 1.0); // dot=0 → distance=1 + + // Zero vectors → distance 0 (by convention) + assert_eq!(cosine_u8(&[0, 0], &[0, 0]), 0.0); + assert_eq!(cosine_u8(&[0, 0], &[1, 2]), 0.0); + } + + #[test] + fn cosine_distance_symmetry() { + let mut a = vec![0u8; 256]; + let mut b = vec![0u8; 256]; + let mut seed = 0xBEEF_u32; + fill_random(&mut a, &mut seed); + fill_random(&mut b, &mut seed); + + let d1 = cosine_u8(&a, &b); + let d2 = cosine_u8(&b, &a); + assert_eq!(d1, d2); + } + + #[test] + fn cosine_distance_range() { + // Cosine distance should be in [0, 1] for non-negative u8 inputs + // (all u8 values are ≥ 0, so dot product is always ≥ 0). + let mut a = vec![0u8; 1024]; + let mut b = vec![0u8; 1024]; + + for seed_idx in 0..8u32 { + let mut seed = 0xABCD_u32.wrapping_add(seed_idx.wrapping_mul(31)); + fill_random(&mut a, &mut seed); + fill_random(&mut b, &mut seed); + let d = cosine_u8(&a, &b); + assert!( + (0.0..=1.0).contains(&d), + "cosine distance {d} out of [0,1] range" + ); + } + } +} diff --git a/rust/lance-linalg/src/distance/l2.rs b/rust/lance-linalg/src/distance/l2.rs index 9aadb5472de..e90ba9a3952 100644 --- a/rust/lance-linalg/src/distance/l2.rs +++ b/rust/lance-linalg/src/distance/l2.rs @@ -93,7 +93,7 @@ pub fn l2_scalar< impl L2 for u8 { #[inline] fn l2(x: &[Self], y: &[Self]) -> f32 { - l2_distance_uint_scalar(x, y) + super::l2_u8::l2_u8(x, y) as f32 } } diff --git a/rust/lance-linalg/src/distance/l2_u8.rs b/rust/lance-linalg/src/distance/l2_u8.rs new file mode 100644 index 00000000000..1b111f91338 --- /dev/null +++ b/rust/lance-linalg/src/distance/l2_u8.rs @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Unsigned int8 squared L2 distance with runtime-dispatched SIMD backends. +//! +//! Computes `Σ(a[i] - b[i])²` for u8 slices, returning a u32 result. +//! Used by Scalar Quantization (SQ) distance computation where both L2 +//! and Cosine metric types operate on quantized u8 codes. +//! +//! Backends (selected at runtime, best available wins): +//! 1. scalar — portable reference, also used for tails +//! 2. avx2 — VPSADBW-style abs diff + VPMADDWD squaring, 32 elements/iter +//! 3. avx512vnni — same approach with VPDPWSSD accumulation, 64 elements/iter +//! +//! ## Algorithm +//! +//! Each SIMD backend computes |a - b| per element using saturating +//! subtraction: `max(a,b) - min(a,b) = (a ⊖ b) | (b ⊖ a)` where ⊖ +//! is unsigned saturating subtraction. The absolute differences are then +//! zero-extended to i16 and squared via VPMADDWD (which also pairwise- +//! accumulates adjacent products into i32). + +use std::sync::OnceLock; + +/// Portable scalar u8 squared L2 distance, also used for SIMD tail elements. +#[inline] +pub fn l2_u8_scalar(a: &[u8], b: &[u8]) -> u32 { + debug_assert_eq!(a.len(), b.len()); + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| (x.abs_diff(y) as u32).pow(2)) + .sum() +} + +#[cfg(target_arch = "x86_64")] +mod x86 { + use std::arch::x86_64::*; + + /// Horizontal sum of all 8 × i32 lanes in a __m256i. + #[inline(always)] + unsafe fn hsum_epi32_avx2(v: __m256i) -> u32 { + let lo128 = _mm256_castsi256_si128(v); + let hi128 = _mm256_extracti128_si256(v, 1); + let mut sum128 = _mm_add_epi32(lo128, hi128); + sum128 = _mm_hadd_epi32(sum128, sum128); + sum128 = _mm_hadd_epi32(sum128, sum128); + _mm_cvtsi128_si32(sum128) as u32 + } + + /// AVX2 path: saturating-sub abs diff, unpack to i16, VPMADDWD to square. + /// 32 elements/iter. + #[target_feature(enable = "avx2")] + pub unsafe fn l2_u8_avx2(a: &[u8], b: &[u8]) -> u32 { + debug_assert_eq!(a.len(), b.len()); + let n = a.len(); + let zeros = _mm256_setzero_si256(); + let mut acc = _mm256_setzero_si256(); + let mut i = 0usize; + + while i + 32 <= n { + let av = _mm256_loadu_si256(a.as_ptr().add(i) as *const __m256i); + let bv = _mm256_loadu_si256(b.as_ptr().add(i) as *const __m256i); + + // |a - b| via saturating subtraction: max(a-b, 0) | max(b-a, 0) + let abs_diff = _mm256_or_si256(_mm256_subs_epu8(av, bv), _mm256_subs_epu8(bv, av)); + + // Zero-extend u8→i16 via interleave with zeros. + // unpacklo/hi within each 128-bit lane. + let diff_lo = _mm256_unpacklo_epi8(abs_diff, zeros); + let diff_hi = _mm256_unpackhi_epi8(abs_diff, zeros); + + // VPMADDWD squares adjacent i16 pairs and sums into i32. + acc = _mm256_add_epi32(acc, _mm256_madd_epi16(diff_lo, diff_lo)); + acc = _mm256_add_epi32(acc, _mm256_madd_epi16(diff_hi, diff_hi)); + i += 32; + } + + let mut result = hsum_epi32_avx2(acc); + + // Scalar tail + while i < n { + let d = a[i].abs_diff(b[i]) as u32; + result += d * d; + i += 1; + } + result + } + + /// AVX-512 VNNI path: abs diff + VPDPWSSD for fused square-accumulate. + /// 64 elements/iter. + #[target_feature(enable = "avx512f,avx512bw,avx512vnni")] + pub unsafe fn l2_u8_avx512_vnni(a: &[u8], b: &[u8]) -> u32 { + debug_assert_eq!(a.len(), b.len()); + let n = a.len(); + let zeros = _mm512_setzero_si512(); + let mut acc = _mm512_setzero_si512(); + let mut i = 0usize; + + while i + 64 <= n { + let av = _mm512_loadu_si512(a.as_ptr().add(i) as *const __m512i); + let bv = _mm512_loadu_si512(b.as_ptr().add(i) as *const __m512i); + + // |a - b| via saturating subtraction + let abs_diff = _mm512_or_si512(_mm512_subs_epu8(av, bv), _mm512_subs_epu8(bv, av)); + + // Zero-extend u8→i16 via interleave with zeros + let diff_lo = _mm512_unpacklo_epi8(abs_diff, zeros); + let diff_hi = _mm512_unpackhi_epi8(abs_diff, zeros); + + // VPDPWSSD: signed i16 pairwise multiply-add into i32 accumulator. + // diff values are 0..255, fitting in i16 as positive, so signed is fine. + acc = _mm512_dpwssd_epi32(acc, diff_lo, diff_lo); + acc = _mm512_dpwssd_epi32(acc, diff_hi, diff_hi); + i += 64; + } + + let mut result = _mm512_reduce_add_epi32(acc) as u32; + + // Scalar tail + while i < n { + let d = a[i].abs_diff(b[i]) as u32; + result += d * d; + i += 1; + } + result + } +} + +type L2U8Fn = fn(&[u8], &[u8]) -> u32; + +static DISPATCH: OnceLock = OnceLock::new(); + +fn select_backend() -> L2U8Fn { + #[cfg(target_arch = "x86_64")] + { + if is_x86_feature_detected!("avx512f") + && is_x86_feature_detected!("avx512bw") + && is_x86_feature_detected!("avx512vnni") + { + return |a, b| unsafe { x86::l2_u8_avx512_vnni(a, b) }; + } + + if is_x86_feature_detected!("avx2") { + return |a, b| unsafe { x86::l2_u8_avx2(a, b) }; + } + } + + l2_u8_scalar +} + +/// Dispatched u8 squared L2 distance, selecting the best available SIMD backend. +#[inline] +pub fn l2_u8(a: &[u8], b: &[u8]) -> u32 { + (DISPATCH.get_or_init(select_backend))(a, b) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fill_random(buf: &mut [u8], seed: &mut u32) { + for slot in buf.iter_mut() { + *seed = seed.wrapping_mul(1103515245).wrapping_add(12345); + *slot = (*seed >> 16) as u8; + } + } + + const SIZES: &[usize] = &[ + 0, 1, 7, 15, 16, 31, 32, 33, 63, 64, 65, 127, 128, 255, 256, 1024, 4096, 4097, + ]; + + fn check_all_backends(a: &[u8], b: &[u8], case: &str) { + let reference = l2_u8_scalar(a, b); + + #[cfg(target_arch = "x86_64")] + { + if is_x86_feature_detected!("avx2") { + let got = unsafe { x86::l2_u8_avx2(a, b) }; + assert_eq!(got, reference, "avx2 [{case}] n={}", a.len()); + } + + if is_x86_feature_detected!("avx512f") + && is_x86_feature_detected!("avx512bw") + && is_x86_feature_detected!("avx512vnni") + { + let got = unsafe { x86::l2_u8_avx512_vnni(a, b) }; + assert_eq!(got, reference, "avx512_vnni [{case}] n={}", a.len()); + } + } + + assert_eq!(l2_u8(a, b), reference, "dispatch [{case}] n={}", a.len()); + } + + #[test] + fn random_inputs_across_sizes_and_seeds() { + let mut a = vec![0u8; 4097]; + let mut b = vec![0u8; 4097]; + + for seed_idx in 0..4u32 { + let mut seed = 0xC0FFEE_u32.wrapping_add(seed_idx.wrapping_mul(7919)); + for &n in SIZES { + fill_random(&mut a[..n], &mut seed); + fill_random(&mut b[..n], &mut seed); + check_all_backends(&a[..n], &b[..n], "random"); + } + } + } + + #[test] + fn boundary_values() { + let mut a = vec![0u8; 4097]; + let mut b = vec![0u8; 4097]; + + for &n in SIZES { + // max diff: |255 - 0|² × n + a[..n].fill(u8::MAX); + b[..n].fill(0); + check_all_backends(&a[..n], &b[..n], "max-0"); + + a[..n].fill(0); + b[..n].fill(u8::MAX); + check_all_backends(&a[..n], &b[..n], "0-max"); + + // identical vectors: distance = 0 + a[..n].fill(u8::MAX); + b[..n].fill(u8::MAX); + check_all_backends(&a[..n], &b[..n], "max-max"); + assert_eq!(l2_u8_scalar(&a[..n], &b[..n]), 0); + + // zeros + a[..n].fill(0); + b[..n].fill(0); + check_all_backends(&a[..n], &b[..n], "0-0"); + assert_eq!(l2_u8_scalar(&a[..n], &b[..n]), 0); + + // alternating + for i in 0..n { + a[i] = if i & 1 == 0 { 0 } else { u8::MAX }; + b[i] = if i & 1 == 0 { u8::MAX } else { 0 }; + } + check_all_backends(&a[..n], &b[..n], "alt 0/max"); + } + } + + #[test] + fn one_sided_zeros() { + let mut a = vec![0u8; 4097]; + let mut b = vec![0u8; 4097]; + + for &n in SIZES { + let mut seed = 0xDEAD_BEEF_u32; + fill_random(&mut a[..n], &mut seed); + b[..n].fill(0); + check_all_backends(&a[..n], &b[..n], "b=0"); + + a[..n].fill(0); + fill_random(&mut b[..n], &mut seed); + check_all_backends(&a[..n], &b[..n], "a=0"); + } + } + + #[test] + fn known_values() { + // 3² + 1² = 10 + assert_eq!(l2_u8_scalar(&[10, 20], &[7, 21]), 10); + assert_eq!(l2_u8(&[10, 20], &[7, 21]), 10); + + // max single-element distance: 255² = 65025 + assert_eq!(l2_u8(&[0], &[255]), 65025); + assert_eq!(l2_u8(&[255], &[0]), 65025); + } +} From 88eabc7e5cd82fce7952889f0d539c823f9bddbf Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 15 Apr 2026 15:47:27 +0800 Subject: [PATCH 191/206] fix: missing bumpversion entry for lance-tokenizer (#6526) This fixes the release bump configuration after `lance-tokenizer` was added to the workspace dependencies. `.bumpversion.toml` was missing the corresponding replacement rule, so version bumps could leave that internal dependency on the previous version. This is a targeted config-only fix to keep the release automation updating all workspace crates consistently. --- .bumpversion.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index bbfc55ab73f..e1a90370669 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -115,6 +115,11 @@ filename = "Cargo.toml" search = 'lance-testing = {{ version = "={current_version}"' replace = 'lance-testing = {{ version = "={new_version}"' +[[tool.bumpversion.files]] +filename = "Cargo.toml" +search = 'lance-tokenizer = {{ version = "={current_version}"' +replace = 'lance-tokenizer = {{ version = "={new_version}"' + [[tool.bumpversion.files]] filename = "Cargo.toml" search = 'fsst = {{ version = "={current_version}"' @@ -145,4 +150,3 @@ replace = 'version = "{new_version}"' filename = "java/pom.xml" search = "{current_version}" replace = "{new_version}" - From 4ea91666346bc3a033a824e3dc780f8cb7568754 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 15 Apr 2026 16:13:19 +0800 Subject: [PATCH 192/206] fix(namespace): serialize manifest mutations (#6525) This fixes the directory namespace CI failure where single-instance concurrent create/drop operations on `__manifest` could time out with `TooMuchWriteContention`, especially in the Windows build. Manifest mutations are now serialized within a single `ManifestNamespace` instance so concurrent operations stop racing on stale in-memory snapshots, and inline manifest maintenance now defers compaction/index merges until the table has accumulated enough fragments. Context: https://github.com/lance-format/lance/actions/runs/24439767878/job/71401857043 --- .../lance-namespace-impls/src/dir/manifest.rs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index 7046f9cfc44..f0573709d56 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -49,7 +49,7 @@ use std::{ ops::{Deref, DerefMut}, sync::Arc, }; -use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; +use tokio::sync::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}; const MANIFEST_TABLE_NAME: &str = "__manifest"; const DELIMITER: &str = "$"; @@ -61,6 +61,9 @@ const OBJECT_ID_INDEX_NAME: &str = "object_id_btree"; const OBJECT_TYPE_INDEX_NAME: &str = "object_type_bitmap"; /// LabelList index on the base_objects column for view dependencies const BASE_OBJECTS_INDEX_NAME: &str = "base_objects_label_list"; +/// Inline maintenance on the manifest table is expensive relative to a single-row mutation. +/// Wait until enough fragments accumulate before compacting files or merging indices. +const MANIFEST_INLINE_OPTIMIZATION_FRAGMENT_THRESHOLD: usize = 8; /// Object types that can be stored in the manifest #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -268,6 +271,9 @@ pub struct ManifestNamespace { /// Number of retries for commit operations on the manifest table. /// If None, defaults to [`lance_table::io::commit::CommitConfig`] default (20). commit_retries: Option, + /// Serialize manifest mutations within a single namespace instance so concurrent + /// create/drop calls do not compete with each other on the same in-memory snapshot. + manifest_mutation_lock: Arc>, } impl std::fmt::Debug for ManifestNamespace { @@ -374,6 +380,7 @@ impl ManifestNamespace { dir_listing_enabled, inline_optimization_enabled, commit_retries, + manifest_mutation_lock: Arc::new(Mutex::new(())), }) } @@ -615,6 +622,13 @@ impl ManifestNamespace { } } + let should_compact_and_optimize = + dataset.count_fragments() >= MANIFEST_INLINE_OPTIMIZATION_FRAGMENT_THRESHOLD; + + if !should_compact_and_optimize { + return Ok(()); + } + // Step 2: Run file compaction log::debug!("Running file compaction on __manifest table"); match compact_files(dataset, CompactionOptions::default(), None).await { @@ -933,6 +947,7 @@ impl ManifestNamespace { let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone()); // Use MergeInsert to ensure uniqueness on object_id + let _mutation_guard = self.manifest_mutation_lock.lock().await; let dataset_guard = self.manifest_dataset.get().await?; let dataset_arc = Arc::new(dataset_guard.clone()); drop(dataset_guard); // Drop read guard before merge insert @@ -994,6 +1009,7 @@ impl ManifestNamespace { let predicate = format!("object_id = '{}'", object_id); // Get dataset and use DeleteBuilder with configured retries + let _mutation_guard = self.manifest_mutation_lock.lock().await; let dataset_guard = self.manifest_dataset.get().await?; let dataset = Arc::new(dataset_guard.clone()); drop(dataset_guard); // Drop read guard before delete @@ -1191,6 +1207,7 @@ impl ManifestNamespace { } // Execute a single bulk delete with the combined filter + let _mutation_guard = self.manifest_mutation_lock.lock().await; let dataset_guard = self.manifest_dataset.get().await?; let dataset = Arc::new(dataset_guard.clone()); drop(dataset_guard); @@ -1267,6 +1284,7 @@ impl ManifestNamespace { } // Execute a single atomic bulk delete covering all tables + let _mutation_guard = self.manifest_mutation_lock.lock().await; let dataset_guard = self.manifest_dataset.get().await?; let dataset = Arc::new(dataset_guard.clone()); drop(dataset_guard); @@ -1304,6 +1322,7 @@ impl ManifestNamespace { /// __manifest dataset's table metadata, rather than inserting a row. /// If the property already exists with the same value, this is a no-op. pub async fn set_property(&self, name: &str, value: &str) -> Result<()> { + let _mutation_guard = self.manifest_mutation_lock.lock().await; let dataset_guard = self.manifest_dataset.get().await?; if dataset_guard.metadata().get(name) == Some(&value.to_string()) { return Ok(()); From 2a4ed6c120585df30dc507b61378413c31f79f3a Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Wed, 15 Apr 2026 16:35:03 +0800 Subject: [PATCH 193/206] fix: blob projection schema compatibility (#6521) Blob columns can be represented either as loaded values or as unloaded descriptor schemas, but our schema projection logic still treated those views as incompatible types. This change teaches field projection and intersection to recognize blob loaded/unloaded pairs as the same logical column, and adds regression coverage for both the core schema path and the projection-plan path that previously failed. --- rust/lance-core/src/datatypes/field.rs | 41 ++++++++++++++++++++++++++ rust/lance/src/dataset/take.rs | 40 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/rust/lance-core/src/datatypes/field.rs b/rust/lance-core/src/datatypes/field.rs index 917d591643e..c41eb449131 100644 --- a/rust/lance-core/src/datatypes/field.rs +++ b/rust/lance-core/src/datatypes/field.rs @@ -526,6 +526,14 @@ impl Field { .unwrap_or(false) } + // Blob columns intentionally have two schema representations: + // the loaded value view (legacy LargeBinary or blob v2 struct) and the unloaded + // descriptor view used by projection/planning. Schema set operations need to + // treat them as the same logical column instead of ordinary incompatible types. + fn is_compatible_blob_projection(&self, other: &Self) -> bool { + self.is_blob() && other.is_blob() && self.is_blob_v2() == other.is_blob_v2() + } + /// If the field is a blob, update this field with the same name and id /// but with the data type set to a struct of the blob description fields. /// @@ -640,6 +648,10 @@ impl Field { ))); }; + if self.is_compatible_blob_projection(other) { + return Ok(self.clone()); + } + match (self.data_type(), other.data_type()) { (DataType::Boolean, DataType::Boolean) => Ok(self.clone()), (dt, other_dt) @@ -768,6 +780,14 @@ impl Field { ))); } + if self.is_compatible_blob_projection(other) { + return Ok(if self.id >= 0 { + self.clone() + } else { + other.clone() + }); + } + let self_type = self.data_type(); let other_type = other.data_type(); @@ -1790,4 +1810,25 @@ mod tests { assert_eq!(field.children.len(), 5); assert_eq!(field.logical_type, BLOB_V2_DESC_LANCE_FIELD.logical_type); } + + #[test] + fn project_by_field_accepts_blob_descriptor_projection() { + let metadata = HashMap::from([(BLOB_META_KEY.to_string(), "true".to_string())]); + let field: Field = ArrowField::new("blob", DataType::LargeBinary, true) + .with_metadata(metadata) + .try_into() + .unwrap(); + let mut unloaded = field.clone(); + unloaded.unloaded_mut(); + + let projected = field + .project_by_field(&unloaded, OnTypeMismatch::Error) + .unwrap(); + assert_eq!(projected, field); + + let unloaded_projected = unloaded + .project_by_field(&field, OnTypeMismatch::Error) + .unwrap(); + assert_eq!(unloaded_projected, unloaded); + } } diff --git a/rust/lance/src/dataset/take.rs b/rust/lance/src/dataset/take.rs index 95c71b41833..83ee7f7216a 100644 --- a/rust/lance/src/dataset/take.rs +++ b/rust/lance/src/dataset/take.rs @@ -824,6 +824,46 @@ mod test { assert_eq!(struct_arr.fields()[4].name(), "blob_uri"); } + #[tokio::test] + async fn test_projection_plan_accepts_unloaded_legacy_blob_schema() { + let mut metadata = HashMap::new(); + metadata.insert(lance_arrow::BLOB_META_KEY.to_string(), "true".to_string()); + let schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("blob", DataType::LargeBinary, true).with_metadata(metadata), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(LargeBinaryArray::from(vec![Some( + b"hello".as_slice(), + )]))], + ) + .unwrap(); + let write_params = WriteParams { + data_storage_version: Some(LanceFileVersion::Legacy), + ..Default::default() + }; + let batches = RecordBatchIterator::new([Ok(batch)], schema); + let dataset = Dataset::write(batches, "memory://", Some(write_params)) + .await + .unwrap(); + + let mut projection = dataset.schema().project(&["blob"]).unwrap(); + projection.fields[0].unloaded_mut(); + + let projection = ProjectionRequest::from_schema(projection) + .into_projection_plan(Arc::new(dataset)) + .unwrap(); + + let output_schema = projection.output_schema().unwrap(); + let blob_field = output_schema.field_with_name("blob").unwrap(); + let DataType::Struct(fields) = blob_field.data_type() else { + panic!("expected blob output schema to be a struct, got {blob_field:?}"); + }; + assert_eq!(fields.len(), 2); + assert_eq!(fields[0].name(), "position"); + assert_eq!(fields[1].name(), "size"); + } + #[rstest] #[tokio::test] async fn test_take_rowid_rowaddr_with_projection_enable_stable_row_ids_projection_from_sql( From b3698e6604950a22c4e437d12b4271685308c541 Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Wed, 15 Apr 2026 12:59:31 -0700 Subject: [PATCH 194/206] feat: batch chopping fallback for filtered read (#6482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `ChopBatchesStream`, a stream wrapper that splits oversized batches (>1.5x target `batch_size_bytes`) into smaller sub-batches using zero-copy `RecordBatch::slice` - Wraps the filtered read output stream with `ChopBatchesStream` when `batch_size_bytes` is configured via `FileReaderOptions` - Serves as a safety net when the underlying file reader doesn't estimate batch sizes accurately enough **Stacked on feat/byte-sized-batches-file-reader** — wait for that to merge first, then rebase this PR. ## Test plan - [x] Unit tests for `ChopBatchesStream`: splits large batches, passes small batches through, `wrap_if_needed(None)` is a no-op - [x] `cargo clippy` clean - [x] `cargo fmt` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- python/python/tests/test_dataset.py | 39 +++++++++++++++++++++++++ rust/lance-arrow/src/stream.rs | 28 +++++++++++++++++- rust/lance/src/io/exec/filtered_read.rs | 37 ++++++++++++++++++----- 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/python/python/tests/test_dataset.py b/python/python/tests/test_dataset.py index 8d4f70d6efb..18898413561 100644 --- a/python/python/tests/test_dataset.py +++ b/python/python/tests/test_dataset.py @@ -4215,6 +4215,45 @@ def test_late_materialization_batch_size(tmp_path: Path): assert batch.num_rows == 32 +def test_batch_size_bytes_chopping_fallback(tmp_path: Path): + # Write rows with large strings (~10KiB each). The file reader's byte-size + # estimation is based on on-disk size which, for strings, can differ + # significantly from in-memory size. The rechunk fallback should still + # split oversized batches so no single batch exceeds the target by too much. + num_rows = 200 + row_text = "x" * 10 * 1024 # 10 KiB per row + table = pa.table( + { + "id": pa.array(range(num_rows), type=pa.int64()), + "text": pa.array([row_text] * num_rows, type=pa.large_string()), + } + ) + dataset = lance.write_dataset(table, tmp_path, data_storage_version="stable") + + # Target ~50 KiB per batch with ~10 KiB per row → expect ~5 rows per batch. + # Without the rechunk fallback all 200 rows would arrive in a single batch. + target_bytes = 50 * 1024 + batches = list( + dataset.to_batches( + filter="id >= 0", + batch_size_bytes=target_bytes, + late_materialization=False, + ) + ) + + total_rows = sum(b.num_rows for b in batches) + assert total_rows == num_rows + + # We should get many batches, not one giant batch. + assert len(batches) > 1, f"expected many batches, got {len(batches)}" + # Each batch should have a small number of rows (target ~5, allow up to 10). + for batch in batches: + assert batch.num_rows <= 10, ( + f"batch has {batch.num_rows} rows, expected at most ~10 " + f"for a {target_bytes} byte target with ~10KiB rows" + ) + + def test_use_scalar_index(tmp_path: Path): table = pa.table({"filter": range(100)}) dataset = lance.write_dataset(table, tmp_path) diff --git a/rust/lance-arrow/src/stream.rs b/rust/lance-arrow/src/stream.rs index 37ecd05663f..18f532d0519 100644 --- a/rust/lance-arrow/src/stream.rs +++ b/rust/lance-arrow/src/stream.rs @@ -41,7 +41,9 @@ where } // Pull batches until we reach the byte target or exhaust input. - while !state.done && state.acc_bytes < state.min_bytes { + // Always pull at least one batch so that min_bytes=0 works. + while !state.done && (state.accumulated.is_empty() || state.acc_bytes < state.min_bytes) + { match state.input.next().await { Some(Ok(batch)) => { state.acc_bytes += batch.get_array_memory_size(); @@ -291,6 +293,30 @@ mod tests { assert_eq!(values, expected); } + #[test] + fn test_min_bytes_zero_still_yields_all_rows() { + // When min_bytes=0, the stream should still yield every batch. + // This is the "chop only, don't coalesce" use case. + let batches: Vec<_> = (0..5).map(|_| make_batch(100)).collect(); + let batch_bytes = batches[0].get_array_memory_size(); + let result = collect_rechunked(batches, 0, batch_bytes * 2); + assert_eq!(total_rows(&result), 500); + } + + #[test] + fn test_min_bytes_zero_slices_oversized() { + // min_bytes=0 with a small max_bytes should still slice large batches. + let batch = make_batch(1000); + let bytes = batch.get_array_memory_size(); + let result = collect_rechunked(vec![batch], 0, bytes / 4); + assert_eq!(total_rows(&result), 1000); + assert!( + result.len() >= 4, + "expected at least 4 slices, got {}", + result.len() + ); + } + #[test] fn test_error_propagation() { let input = stream::iter(vec![ diff --git a/rust/lance/src/io/exec/filtered_read.rs b/rust/lance/src/io/exec/filtered_read.rs index 2360c1a2212..881497e9d0b 100644 --- a/rust/lance/src/io/exec/filtered_read.rs +++ b/rust/lance/src/io/exec/filtered_read.rs @@ -1707,16 +1707,18 @@ impl FilteredReadExec { let running_stream_lock = self.running_stream.clone(); let dataset = self.dataset.clone(); let options = self.options.clone(); + let batch_size_bytes = options + .file_reader_options + .as_ref() + .and_then(|o| o.batch_size_bytes); let metrics = self.metrics.clone(); let index_input = self.index_input.clone(); let plan_cell = self.plan.clone(); let stream = futures::stream::once(async move { let mut running_stream = running_stream_lock.lock().await; - if let Some(running_stream) = &*running_stream { - DataFusionResult::::Ok( - running_stream.get_stream(&metrics, partition), - ) + let inner = if let Some(running_stream) = &*running_stream { + running_stream.get_stream(&metrics, partition) } else { let plan = Self::get_or_create_plan_impl( &plan_cell, @@ -1726,13 +1728,32 @@ impl FilteredReadExec { partition, context.clone(), ) - .await?; + .await + .map_err(|e| DataFusionError::External(e.into()))?; let new_running_stream = - FilteredReadStream::try_new(dataset, options, &metrics, plan.clone()).await?; + FilteredReadStream::try_new(dataset, options, &metrics, plan.clone()) + .await + .map_err(|e| DataFusionError::External(e.into()))?; let first_stream = new_running_stream.get_stream(&metrics, partition); *running_stream = Some(new_running_stream); - DataFusionResult::Ok(first_stream) - } + first_stream + }; + let stream: SendableRecordBatchStream = match batch_size_bytes { + Some(target) => { + let schema = inner.schema(); + Box::pin(RecordBatchStreamAdapter::new( + schema.clone(), + lance_arrow::stream::rechunk_stream_by_size( + inner, + schema, + 0, + target as usize, + ), + )) + } + None => inner, + }; + DataFusionResult::::Ok(stream) }) .try_flatten(); From 65c8a9806050befb508df0a9cf1050d550b46849 Mon Sep 17 00:00:00 2001 From: LuQQiu Date: Wed, 15 Apr 2026 19:13:59 -0700 Subject: [PATCH 195/206] feat: add ANN proto codecs and extract table_identifier module (#6503) Add protobuf encode/decode for `ANNIvfSubIndexExec` --------- Co-authored-by: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 + protos/ann.proto | 41 ++ python/Cargo.lock | 1 + rust/lance/Cargo.toml | 5 + rust/lance/build.rs | 24 ++ rust/lance/src/io/exec.rs | 4 + rust/lance/src/io/exec/ann_proto.rs | 401 ++++++++++++++++++ rust/lance/src/io/exec/filtered_read_proto.rs | 108 +---- rust/lance/src/io/exec/knn.rs | 20 + rust/lance/src/io/exec/table_identifier.rs | 186 ++++++++ rust/lance/src/lib.rs | 5 + 11 files changed, 692 insertions(+), 105 deletions(-) create mode 100644 protos/ann.proto create mode 100644 rust/lance/build.rs create mode 100644 rust/lance/src/io/exec/ann_proto.rs create mode 100644 rust/lance/src/io/exec/table_identifier.rs diff --git a/Cargo.lock b/Cargo.lock index 016e40cabcc..7818ece8cb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4499,7 +4499,9 @@ dependencies = [ "pprof", "pretty_assertions", "prost", + "prost-build", "prost-types", + "protobuf-src", "rand 0.9.4", "rand_distr", "roaring", diff --git a/protos/ann.proto b/protos/ann.proto new file mode 100644 index 00000000000..37f188a7826 --- /dev/null +++ b/protos/ann.proto @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +syntax = "proto3"; + +package lance.pb; + +import "table_identifier.proto"; +import "table.proto"; +import "index.proto"; + +// Serialized vector query parameters. +message VectorQueryProto { + // Query vector as Arrow IPC bytes (supports Float16, Float32, Float64, UInt8, etc.) + bytes query_vector_arrow_ipc = 1; + string column = 2; + uint32 k = 3; + optional float lower_bound = 4; + optional float upper_bound = 5; + optional uint32 minimum_nprobes = 6; + optional uint32 maximum_nprobes = 7; + optional uint32 ef = 8; + optional uint32 refine_factor = 9; + // Distance metric type. Absent means None (use the index's default metric). + optional lance.index.pb.VectorMetricType metric_type = 10; + bool use_index = 11; + optional float dist_q_c = 12; +} + +// Serializable form of ANNIvfSubIndexExec — the IVF sub-index search node. +// +// Note: ANNIvfSubIndexExec.prefilter_source (child ExecutionPlan) is NOT +// serialized here. DataFusion's PhysicalExtensionCodec handles child plans +// automatically via children() / with_new_children(). The codec receives +// deserialized children in the `inputs` parameter of try_decode and +// reconstructs the PreFilterSource from them. +message ANNIvfSubIndexExecProto { + VectorQueryProto query = 1; + lance.datafusion.TableIdentifier table = 2; + repeated lance.table.IndexMetadata indices = 3; +} diff --git a/python/Cargo.lock b/python/Cargo.lock index f539539bfd2..ec4d20f38b0 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -3761,6 +3761,7 @@ dependencies = [ "permutation", "pin-project", "prost", + "prost-build", "prost-types", "rand 0.9.4", "roaring", diff --git a/rust/lance/Cargo.toml b/rust/lance/Cargo.toml index 1e2e6893670..f3dbb0ffd0a 100644 --- a/rust/lance/Cargo.toml +++ b/rust/lance/Cargo.toml @@ -85,6 +85,10 @@ semver.workspace = true tokio-stream = { workspace = true } tokio-util = { workspace = true } +[build-dependencies] +prost-build.workspace = true +protobuf-src = { version = "2.1", optional = true } + [target.'cfg(target_os = "linux")'.dev-dependencies] pprof.workspace = true # Need this so we can prevent dynamic linking in binaries (see cli feature) @@ -129,6 +133,7 @@ dynamodb = ["lance-table/dynamodb", "dep:aws-sdk-dynamodb"] dynamodb_tests = ["dynamodb"] substrait = ["lance-datafusion/substrait"] protoc = [ + "dep:protobuf-src", "lance-encoding/protoc", "lance-file/protoc", "lance-index/protoc", diff --git a/rust/lance/build.rs b/rust/lance/build.rs new file mode 100644 index 00000000000..4225007d46c --- /dev/null +++ b/rust/lance/build.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::io::Result; + +fn main() -> Result<()> { + println!("cargo:rerun-if-changed=protos"); + + #[cfg(feature = "protoc")] + // Use vendored protobuf compiler if requested. + unsafe { + std::env::set_var("PROTOC", protobuf_src::protoc()); + } + + let mut prost_build = prost_build::Config::new(); + prost_build.extern_path(".lance.table", "::lance_table::format::pb"); + prost_build.extern_path(".lance.index.pb", "::lance_index::pb"); + prost_build.extern_path(".lance.datafusion", "::lance_datafusion::pb"); + prost_build.protoc_arg("--experimental_allow_proto3_optional"); + prost_build.enable_type_names(); + prost_build.compile_protos(&["./protos/ann.proto"], &["./protos"])?; + + Ok(()) +} diff --git a/rust/lance/src/io/exec.rs b/rust/lance/src/io/exec.rs index ae62214857f..0b93e3c2834 100644 --- a/rust/lance/src/io/exec.rs +++ b/rust/lance/src/io/exec.rs @@ -5,6 +5,8 @@ //! //! WARNING: Internal API with no stability guarantees. +#[cfg(feature = "substrait")] +pub mod ann_proto; mod filter; pub mod filtered_read; #[cfg(feature = "substrait")] @@ -17,6 +19,8 @@ mod pushdown_scan; mod rowids; pub mod scalar_index; mod scan; +#[cfg(feature = "substrait")] +pub mod table_identifier; mod take; #[cfg(test)] pub mod testing; diff --git a/rust/lance/src/io/exec/ann_proto.rs b/rust/lance/src/io/exec/ann_proto.rs new file mode 100644 index 00000000000..493fc1a6a0a --- /dev/null +++ b/rust/lance/src/io/exec/ann_proto.rs @@ -0,0 +1,401 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Protobuf serialization for [`ANNIvfSubIndexExec`]. +//! +//! Proto message definitions live in `crate::pb` (compiled from `ann.proto`). +//! Conversion functions live here because they need access to `ANNIvfSubIndexExec` +//! and `Dataset`, which are defined in this crate. +//! +//! A DataFusion `PhysicalExtensionCodec` can call these functions in `try_encode` +//! and `try_decode` to support distributed execution (planner → executor). + +use std::sync::Arc; + +use arrow_array::RecordBatch; +use arrow_schema::{Field, Schema as ArrowSchema}; +use lance_core::{Error, Result}; +use lance_index::pb as index_pb; +use lance_index::vector::Query; +use lance_linalg::distance::DistanceType; +use lance_table::format::IndexMetadata; +use lance_table::format::pb as table_pb; + +use crate::Dataset; +use crate::pb; + +use super::knn::ANNIvfSubIndexExec; +use super::table_identifier::{resolve_dataset, table_identifier_from_dataset}; +use super::utils::PreFilterSource; + +// ============================================================================= +// VectorQueryProto helpers +// ============================================================================= + +/// Serialize a query vector array to IPC file-format bytes. +/// +/// Wraps the array in a single-column RecordBatch so that the IPC format +/// preserves the full data type (Float16, Float32, Float64, UInt8, etc.). +fn query_vector_to_ipc_bytes(array: &dyn arrow_array::Array) -> Result> { + let field = Field::new("query_vector", array.data_type().clone(), true); + let schema = Arc::new(ArrowSchema::new(vec![field])); + let batch = RecordBatch::try_new(schema, vec![arrow_array::make_array(array.to_data())]) + .map_err(|e| { + Error::internal(format!( + "Failed to create RecordBatch for query vector: {e}" + )) + })?; + + let mut buf = Vec::new(); + { + let mut writer = arrow_ipc::writer::FileWriter::try_new(&mut buf, &batch.schema()) + .map_err(|e| Error::internal(format!("Failed to create IPC writer: {e}")))?; + writer + .write(&batch) + .map_err(|e| Error::internal(format!("Failed to write IPC batch: {e}")))?; + writer + .finish() + .map_err(|e| Error::internal(format!("Failed to finish IPC writer: {e}")))?; + } + Ok(buf) +} + +/// Deserialize a query vector array from IPC file-format bytes. +fn query_vector_from_ipc_bytes(bytes: &[u8]) -> Result { + let cursor = std::io::Cursor::new(bytes); + let reader = arrow_ipc::reader::FileReader::try_new(cursor, None) + .map_err(|e| Error::internal(format!("Failed to create IPC reader: {e}")))?; + + let batches: Vec = reader + .collect::, _>>() + .map_err(|e| Error::internal(format!("Failed to read IPC batches: {e}")))?; + + if batches.is_empty() || batches[0].num_columns() == 0 { + return Err(Error::internal( + "IPC bytes contain no data for query vector".to_string(), + )); + } + + Ok(batches[0].column(0).clone()) +} + +pub fn query_to_proto(query: &Query) -> Result { + let query_vector_arrow_ipc = query_vector_to_ipc_bytes(query.key.as_ref())?; + + let metric_type = query + .metric_type + .map(|dt| index_pb::VectorMetricType::from(dt) as i32); + + Ok(pb::VectorQueryProto { + query_vector_arrow_ipc, + column: query.column.clone(), + k: query.k as u32, + lower_bound: query.lower_bound, + upper_bound: query.upper_bound, + minimum_nprobes: Some(query.minimum_nprobes as u32), + maximum_nprobes: query.maximum_nprobes.map(|n| n as u32), + ef: query.ef.map(|n| n as u32), + refine_factor: query.refine_factor, + metric_type, + use_index: query.use_index, + dist_q_c: Some(query.dist_q_c), + }) +} + +pub fn query_from_proto(proto: pb::VectorQueryProto) -> Result { + let key = query_vector_from_ipc_bytes(&proto.query_vector_arrow_ipc)?; + + let metric_type = proto + .metric_type + .map(|v| { + index_pb::VectorMetricType::try_from(v) + .map(DistanceType::from) + .map_err(|_| Error::internal(format!("Invalid VectorMetricType value: {v}"))) + }) + .transpose()?; + + Ok(Query { + column: proto.column, + key, + k: proto.k as usize, + lower_bound: proto.lower_bound, + upper_bound: proto.upper_bound, + minimum_nprobes: proto.minimum_nprobes.unwrap_or(1) as usize, + maximum_nprobes: proto.maximum_nprobes.map(|n| n as usize), + ef: proto.ef.map(|n| n as usize), + refine_factor: proto.refine_factor, + metric_type, + use_index: proto.use_index, + dist_q_c: proto.dist_q_c.unwrap_or(0.0), + }) +} + +// ============================================================================= +// ANNIvfSubIndexExec <-> Proto +// ============================================================================= + +/// Convert an [`ANNIvfSubIndexExec`] to proto for serialization. +pub async fn ann_ivf_sub_index_exec_to_proto( + exec: &ANNIvfSubIndexExec, +) -> Result { + let table = table_identifier_from_dataset(exec.dataset()).await?; + let query = query_to_proto(exec.query())?; + + let indices: Vec = + exec.indices().iter().map(|idx| idx.into()).collect(); + + Ok(pb::AnnIvfSubIndexExecProto { + query: Some(query), + table: Some(table), + indices, + }) +} + +/// Reconstruct an [`ANNIvfSubIndexExec`] from proto. +/// +/// The caller (codec) is responsible for extracting child inputs: +/// - `input`: the child execution plan (e.g. partition exec) +/// - `prefilter_source`: optional prefilter input +pub async fn ann_ivf_sub_index_exec_from_proto( + proto: pb::AnnIvfSubIndexExecProto, + dataset: Option>, + input: Arc, + prefilter_source: PreFilterSource, +) -> Result { + let dataset = resolve_dataset(dataset, proto.table.as_ref()).await?; + + let query_proto = proto.query.ok_or_else(|| { + Error::invalid_input_source("Missing VectorQueryProto in ANNIvfSubIndexExecProto".into()) + })?; + let query = query_from_proto(query_proto)?; + + let indices: Vec = proto + .indices + .into_iter() + .map(IndexMetadata::try_from) + .collect::>>()?; + + if indices.is_empty() { + return Err(Error::invalid_input_source( + "ANNIvfSubIndexExecProto contains no indices".into(), + )); + } + + ANNIvfSubIndexExec::try_new(input, dataset, indices, query, prefilter_source) +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow_array::types::{Float32Type, UInt32Type}; + use arrow_array::{ArrayRef, Float32Array, Float64Array}; + use half::f16; + use lance_datagen::{array, gen_batch}; + + use crate::index::DatasetIndexExt; + use crate::index::vector::VectorIndexParams; + use datafusion_physical_plan::test::TestMemoryExec; + use lance_index::IndexType; + use lance_index::vector::ivf::IvfBuildParams; + use lance_index::vector::pq::PQBuildParams; + + #[test] + fn test_query_vector_ipc_roundtrip_f32() { + let arr: ArrayRef = Arc::new(Float32Array::from(vec![1.0, 2.0, 3.0])); + let bytes = query_vector_to_ipc_bytes(arr.as_ref()).unwrap(); + let back = query_vector_from_ipc_bytes(&bytes).unwrap(); + assert_eq!(arr.data_type(), back.data_type()); + assert_eq!(arr.len(), back.len()); + } + + #[test] + fn test_query_vector_ipc_roundtrip_f64() { + let arr: ArrayRef = Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0])); + let bytes = query_vector_to_ipc_bytes(arr.as_ref()).unwrap(); + let back = query_vector_from_ipc_bytes(&bytes).unwrap(); + assert_eq!(arr.data_type(), back.data_type()); + assert_eq!(&*arr, &*back); + } + + #[test] + fn test_query_vector_ipc_roundtrip_f16() { + let arr: ArrayRef = Arc::new(arrow_array::Float16Array::from(vec![ + f16::from_f32(1.0), + f16::from_f32(2.0), + ])); + let bytes = query_vector_to_ipc_bytes(arr.as_ref()).unwrap(); + let back = query_vector_from_ipc_bytes(&bytes).unwrap(); + assert_eq!(arr.data_type(), back.data_type()); + assert_eq!(arr.len(), back.len()); + } + + #[test] + fn test_query_roundtrip() { + let key: ArrayRef = Arc::new(Float32Array::from(vec![0.1, 0.2, 0.3])); + let query = Query { + column: "vector".to_string(), + key, + k: 10, + lower_bound: Some(0.5), + upper_bound: Some(1.5), + minimum_nprobes: 4, + maximum_nprobes: Some(16), + ef: Some(64), + refine_factor: Some(2), + metric_type: Some(DistanceType::Cosine), + use_index: true, + dist_q_c: 0.42, + }; + + let proto = query_to_proto(&query).unwrap(); + let back = query_from_proto(proto).unwrap(); + + assert_eq!(query.column, back.column); + assert_eq!(query.k, back.k); + assert_eq!(query.lower_bound, back.lower_bound); + assert_eq!(query.upper_bound, back.upper_bound); + assert_eq!(query.minimum_nprobes, back.minimum_nprobes); + assert_eq!(query.maximum_nprobes, back.maximum_nprobes); + assert_eq!(query.ef, back.ef); + assert_eq!(query.refine_factor, back.refine_factor); + assert_eq!(query.metric_type, back.metric_type); + assert_eq!(query.use_index, back.use_index); + assert_eq!(query.dist_q_c, back.dist_q_c); + assert_eq!(query.key.len(), back.key.len()); + assert_eq!(query.key.data_type(), back.key.data_type()); + } + + #[test] + fn test_query_roundtrip_none_metric() { + let key: ArrayRef = Arc::new(Float32Array::from(vec![1.0])); + let query = Query { + column: "v".to_string(), + key, + k: 5, + lower_bound: None, + upper_bound: None, + minimum_nprobes: 1, + maximum_nprobes: None, + ef: None, + refine_factor: None, + metric_type: None, + use_index: false, + dist_q_c: 0.0, + }; + + let proto = query_to_proto(&query).unwrap(); + let back = query_from_proto(proto).unwrap(); + assert!(back.metric_type.is_none()); + assert!(!back.use_index); + } + + async fn make_vector_dataset() -> (Arc, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let batch = gen_batch() + .col("id", array::step::()) + .col( + "vector", + array::rand_vec::(lance_datagen::Dimension::from(128)), + ) + .into_batch_rows(lance_datagen::RowCount::from(256)) + .unwrap(); + let path = dir.path().join("test_ann.lance"); + let ds = Dataset::write( + arrow_array::RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema()), + path.to_str().unwrap(), + None, + ) + .await + .unwrap(); + (Arc::new(ds), dir) + } + + async fn make_indexed_dataset() -> (Arc, tempfile::TempDir) { + let (_dataset, dir) = make_vector_dataset().await; + let mut ds = Dataset::open(dir.path().join("test_ann.lance").to_str().unwrap()) + .await + .unwrap(); + + let ivf_params = IvfBuildParams::new(2); + let pq_params = PQBuildParams::default(); + let index_params = + VectorIndexParams::with_ivf_pq_params(DistanceType::L2, ivf_params, pq_params); + + ds.create_index(&["vector"], IndexType::Vector, None, &index_params, false) + .await + .unwrap(); + let ds = Dataset::open(dir.path().join("test_ann.lance").to_str().unwrap()) + .await + .unwrap(); + (Arc::new(ds), dir) + } + + #[tokio::test] + async fn test_ann_ivf_sub_index_proto_roundtrip() { + let (dataset, _dir) = make_indexed_dataset().await; + + // Get real index metadata from the dataset + let indices = dataset.load_indices_by_name("vector_idx").await.unwrap(); + assert!(!indices.is_empty()); + + let key: ArrayRef = Arc::new(Float32Array::from(vec![0.1f32; 128])); + let query = Query { + column: "vector".to_string(), + key, + k: 10, + lower_bound: None, + upper_bound: None, + minimum_nprobes: 2, + maximum_nprobes: Some(4), + ef: None, + refine_factor: Some(2), + metric_type: Some(DistanceType::L2), + use_index: true, + dist_q_c: 0.0, + }; + + // Use a TestMemoryExec as a mock input child (provides the KNN_PARTITION_SCHEMA) + let input: Arc = + TestMemoryExec::try_new_exec( + &[], + super::super::knn::KNN_PARTITION_SCHEMA.clone(), + None, + ) + .unwrap(); + + let exec = ANNIvfSubIndexExec::try_new( + input.clone(), + dataset.clone(), + indices.clone(), + query, + PreFilterSource::None, + ) + .unwrap(); + + // Encode + let proto = ann_ivf_sub_index_exec_to_proto(&exec).await.unwrap(); + assert_eq!(proto.indices.len(), indices.len()); + + // Decode + let back = ann_ivf_sub_index_exec_from_proto( + proto, + Some(dataset.clone()), + input, + PreFilterSource::None, + ) + .await + .unwrap(); + + assert_eq!(back.query().column, "vector"); + assert_eq!(back.query().k, 10); + assert_eq!(back.query().minimum_nprobes, 2); + assert_eq!(back.query().refine_factor, Some(2)); + assert_eq!(back.indices().len(), indices.len()); + for (original, decoded) in indices.iter().zip(back.indices().iter()) { + assert_eq!(original.uuid, decoded.uuid); + assert_eq!(original.name, decoded.name); + assert_eq!(original.dataset_version, decoded.dataset_version); + assert_eq!(original.fields, decoded.fields); + } + } +} diff --git a/rust/lance/src/io/exec/filtered_read_proto.rs b/rust/lance/src/io/exec/filtered_read_proto.rs index 26225d748ea..c373a691f04 100644 --- a/rust/lance/src/io/exec/filtered_read_proto.rs +++ b/rust/lance/src/io/exec/filtered_read_proto.rs @@ -24,73 +24,14 @@ use lance_core::utils::mask::RowAddrTreeMap; use lance_core::{Error, Result}; use lance_datafusion::pb; use lance_datafusion::substrait::{encode_substrait, parse_substrait, prune_schema_for_substrait}; -use lance_io::object_store::StorageOptions; use lance_table::format::Fragment; -use prost::Message; use crate::Dataset; -use crate::dataset::builder::DatasetBuilder; use super::filtered_read::{ FilteredReadExec, FilteredReadOptions, FilteredReadPlan, FilteredReadThreadingMode, }; - -// ============================================================================= -// TableIdentifier helpers (reusable by other execs) -// ============================================================================= - -/// Build a [`TableIdentifier`] from a [`Dataset`]. -/// -/// Default: lightweight mode (uri + version + etag only, no serialized manifest). -/// Includes the dataset's latest storage options (if any) so the remote executor -/// can open or cache the dataset with the correct storage configuration. -pub async fn table_identifier_from_dataset(dataset: &Dataset) -> Result { - Ok(pb::TableIdentifier { - uri: dataset.uri().to_string(), - version: dataset.manifest.version, - manifest_etag: dataset.manifest_location.e_tag.clone(), - serialized_manifest: None, - storage_options: dataset - .latest_storage_options() - .await? - .map(|StorageOptions(m)| m) - .unwrap_or_default(), - }) -} - -/// Build a [`TableIdentifier`] with serialized manifest bytes included. -/// -/// Fast path: remote executor skips manifest read from storage. -pub async fn table_identifier_from_dataset_with_manifest( - dataset: &Dataset, -) -> Result { - let manifest_proto = lance_table::format::pb::Manifest::from(dataset.manifest.as_ref()); - Ok(pb::TableIdentifier { - uri: dataset.uri().to_string(), - version: dataset.manifest.version, - manifest_etag: dataset.manifest_location.e_tag.clone(), - serialized_manifest: Some(manifest_proto.encode_to_vec()), - storage_options: dataset - .latest_storage_options() - .await? - .map(|StorageOptions(m)| m) - .unwrap_or_default(), - }) -} - -/// Open a dataset from a table identifier proto -pub async fn open_dataset_from_table_identifier( - table_id: &pb::TableIdentifier, -) -> Result> { - let mut builder = DatasetBuilder::from_uri(&table_id.uri).with_version(table_id.version); - if let Some(manifest_bytes) = &table_id.serialized_manifest { - builder = builder.with_serialized_manifest(manifest_bytes)?; - } - if !table_id.storage_options.is_empty() { - builder = builder.with_storage_options(table_id.storage_options.clone()); - } - Ok(Arc::new(builder.load().await?)) -} +use super::table_identifier::{resolve_dataset, table_identifier_from_dataset}; // ============================================================================= // FilteredReadExec <-> Proto @@ -131,17 +72,7 @@ pub async fn filtered_read_exec_from_proto( index_input: Option>, state: &SessionState, ) -> Result { - let dataset = match dataset { - Some(ds) => ds, // dataset could be opened or cached by the caller - None => { - let table_id = proto.table.as_ref().ok_or_else(|| { - Error::invalid_input_source( - "Missing table identifier in FilteredReadExecProto".into(), - ) - })?; - open_dataset_from_table_identifier(table_id).await? - } - }; + let dataset = resolve_dataset(dataset, proto.table.as_ref()).await?; let options_proto = proto.options.ok_or_else(|| { Error::invalid_input_source("Missing options in FilteredReadExecProto".into()) @@ -559,6 +490,7 @@ mod tests { use lance_core::utils::mask::RowAddrTreeMap; use lance_datagen::{array, gen_batch}; use roaring::RoaringBitmap; + use std::collections::HashMap; use std::collections::HashSet; use crate::utils::test::{DatagenExt, FragmentCount, FragmentRowCount}; @@ -632,23 +564,6 @@ mod tests { assert_eq!(projection.blob_handling, back.blob_handling); } - #[test] - fn test_table_identifier_without_manifest() { - let id = pb::TableIdentifier { - uri: "s3://bucket/table.lance".to_string(), - version: 42, - manifest_etag: Some("etag123".to_string()), - serialized_manifest: None, - storage_options: HashMap::new(), - }; - let bytes = id.encode_to_vec(); - let back = pb::TableIdentifier::decode(bytes.as_slice()).unwrap(); - assert_eq!(id.uri, back.uri); - assert_eq!(id.version, back.version); - assert_eq!(id.manifest_etag, back.manifest_etag); - assert!(back.serialized_manifest.is_none()); - } - #[test] fn test_row_addr_tree_map_roundtrip_in_plan_proto() { let mut rows = RowAddrTreeMap::new(); @@ -812,23 +727,6 @@ mod tests { ); } - #[tokio::test] - async fn test_table_identifier_with_manifest() { - let dataset = make_test_dataset().await; - - let id = table_identifier_from_dataset_with_manifest(&dataset) - .await - .unwrap(); - assert_eq!(id.uri, dataset.uri()); - assert_eq!(id.version, dataset.manifest.version); - assert!(id.serialized_manifest.is_some()); - - // Verify the serialized manifest bytes decode - let manifest_bytes = id.serialized_manifest.unwrap(); - let _manifest_proto = - lance_table::format::pb::Manifest::decode(manifest_bytes.as_slice()).unwrap(); - } - #[tokio::test] async fn test_plan_proto_roundtrip() { let dataset = make_test_dataset().await; diff --git a/rust/lance/src/io/exec/knn.rs b/rust/lance/src/io/exec/knn.rs index 1e527a0c4f6..daec6140b0b 100644 --- a/rust/lance/src/io/exec/knn.rs +++ b/rust/lance/src/io/exec/knn.rs @@ -639,6 +639,26 @@ impl ANNIvfSubIndexExec { metrics: ExecutionPlanMetricsSet::new(), }) } + + /// Returns a reference to the vector query. + pub fn query(&self) -> &Query { + &self.query + } + + /// Returns a reference to the dataset. + pub fn dataset(&self) -> &Arc { + &self.dataset + } + + /// Returns a reference to the index metadata. + pub fn indices(&self) -> &[IndexMetadata] { + &self.indices + } + + /// Returns a reference to the prefilter source. + pub fn prefilter_source(&self) -> &PreFilterSource { + &self.prefilter_source + } } impl DisplayAs for ANNIvfSubIndexExec { diff --git a/rust/lance/src/io/exec/table_identifier.rs b/rust/lance/src/io/exec/table_identifier.rs new file mode 100644 index 00000000000..9ffd1561bc9 --- /dev/null +++ b/rust/lance/src/io/exec/table_identifier.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Helpers for converting between [`Dataset`] and [`TableIdentifier`](pb::TableIdentifier) proto. + +use std::sync::Arc; + +use lance_core::Result; +use lance_datafusion::pb; +use lance_io::object_store::StorageOptions; +use prost::Message; + +use crate::Dataset; +use crate::dataset::builder::DatasetBuilder; + +/// Build a [`TableIdentifier`] from a [`Dataset`]. +/// +/// Default: lightweight mode (uri + version + etag only, no serialized manifest). +/// Includes the dataset's latest storage options (if any) so the remote executor +/// can open or cache the dataset with the correct storage configuration. +pub async fn table_identifier_from_dataset(dataset: &Dataset) -> Result { + Ok(pb::TableIdentifier { + uri: dataset.uri().to_string(), + version: dataset.manifest.version, + manifest_etag: dataset.manifest_location.e_tag.clone(), + serialized_manifest: None, + storage_options: dataset + .latest_storage_options() + .await? + .map(|StorageOptions(m)| m) + .unwrap_or_default(), + }) +} + +/// Build a [`TableIdentifier`] with serialized manifest bytes included. +/// +/// Fast path: remote executor skips manifest read from storage. +pub async fn table_identifier_from_dataset_with_manifest( + dataset: &Dataset, +) -> Result { + let manifest_proto = lance_table::format::pb::Manifest::from(dataset.manifest.as_ref()); + Ok(pb::TableIdentifier { + uri: dataset.uri().to_string(), + version: dataset.manifest.version, + manifest_etag: dataset.manifest_location.e_tag.clone(), + serialized_manifest: Some(manifest_proto.encode_to_vec()), + storage_options: dataset + .latest_storage_options() + .await? + .map(|StorageOptions(m)| m) + .unwrap_or_default(), + }) +} + +/// Open a dataset from a table identifier proto. +pub async fn open_dataset_from_table_identifier( + table_id: &pb::TableIdentifier, +) -> Result> { + let mut builder = DatasetBuilder::from_uri(&table_id.uri).with_version(table_id.version); + if let Some(manifest_bytes) = &table_id.serialized_manifest { + builder = builder.with_serialized_manifest(manifest_bytes)?; + } + if !table_id.storage_options.is_empty() { + builder = builder.with_storage_options(table_id.storage_options.clone()); + } + Ok(Arc::new(builder.load().await?)) +} + +/// Resolve a dataset from an optional pre-loaded instance or from a table identifier. +/// +/// If `dataset` is `Some`, returns it directly. Otherwise, opens the dataset +/// from the table identifier proto. +pub async fn resolve_dataset( + dataset: Option>, + table_id: Option<&pb::TableIdentifier>, +) -> Result> { + use lance_core::Error; + match dataset { + Some(ds) => Ok(ds), + None => { + let table_id = table_id.ok_or_else(|| { + Error::invalid_input_source("Missing TableIdentifier in proto".into()) + })?; + open_dataset_from_table_identifier(table_id).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow_array::RecordBatchIterator; + use arrow_array::types::UInt32Type; + use lance_datagen::{array, gen_batch}; + use std::collections::HashMap; + + async fn make_test_dataset() -> (Arc, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let batch = gen_batch() + .col("x", array::step::()) + .col("y", array::step::()) + .into_batch_rows(lance_datagen::RowCount::from(100)) + .unwrap(); + let path = dir.path().join("test.lance"); + let ds = Dataset::write( + RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema()), + path.to_str().unwrap(), + None, + ) + .await + .unwrap(); + (Arc::new(ds), dir) + } + + #[test] + fn test_table_identifier_proto_roundtrip() { + let id = pb::TableIdentifier { + uri: "s3://bucket/table.lance".to_string(), + version: 42, + manifest_etag: Some("etag123".to_string()), + serialized_manifest: None, + storage_options: HashMap::new(), + }; + let bytes = id.encode_to_vec(); + let back = pb::TableIdentifier::decode(bytes.as_slice()).unwrap(); + assert_eq!(id.uri, back.uri); + assert_eq!(id.version, back.version); + assert_eq!(id.manifest_etag, back.manifest_etag); + assert!(back.serialized_manifest.is_none()); + } + + #[test] + fn test_table_identifier_proto_with_storage_options() { + let mut opts = HashMap::new(); + opts.insert("region".to_string(), "us-east-1".to_string()); + opts.insert("endpoint".to_string(), "https://s3.example.com".to_string()); + + let id = pb::TableIdentifier { + uri: "s3://bucket/table.lance".to_string(), + version: 7, + manifest_etag: None, + serialized_manifest: None, + storage_options: opts.clone(), + }; + let bytes = id.encode_to_vec(); + let back = pb::TableIdentifier::decode(bytes.as_slice()).unwrap(); + assert_eq!(back.storage_options, opts); + } + + #[tokio::test] + async fn test_table_identifier_from_dataset_roundtrip() { + let (dataset, _dir) = make_test_dataset().await; + + let id = table_identifier_from_dataset(&dataset).await.unwrap(); + assert_eq!(id.uri, dataset.uri()); + assert_eq!(id.version, dataset.manifest.version); + assert!(id.serialized_manifest.is_none()); + + // Roundtrip: open the dataset back from the identifier + let back = open_dataset_from_table_identifier(&id).await.unwrap(); + assert_eq!(back.uri(), dataset.uri()); + assert_eq!(back.manifest.version, dataset.manifest.version); + } + + #[tokio::test] + async fn test_table_identifier_with_manifest_roundtrip() { + let (dataset, _dir) = make_test_dataset().await; + + let id = table_identifier_from_dataset_with_manifest(&dataset) + .await + .unwrap(); + assert_eq!(id.uri, dataset.uri()); + assert_eq!(id.version, dataset.manifest.version); + assert!(id.serialized_manifest.is_some()); + + // Verify the serialized manifest bytes decode + let manifest_bytes = id.serialized_manifest.as_ref().unwrap(); + let _manifest_proto = + lance_table::format::pb::Manifest::decode(manifest_bytes.as_slice()).unwrap(); + + // Roundtrip: open the dataset back from the identifier (with manifest) + let back = open_dataset_from_table_identifier(&id).await.unwrap(); + assert_eq!(back.uri(), dataset.uri()); + assert_eq!(back.manifest.version, dataset.manifest.version); + } +} diff --git a/rust/lance/src/lib.rs b/rust/lance/src/lib.rs index b9c8c7a4f5e..284e10a9b6f 100644 --- a/rust/lance/src/lib.rs +++ b/rust/lance/src/lib.rs @@ -85,6 +85,11 @@ pub mod session; pub mod table; pub mod utils; +pub mod pb { + #![allow(clippy::use_self)] + include!(concat!(env!("OUT_DIR"), "/lance.pb.rs")); +} + pub use blob::{BlobArrayBuilder, blob_field}; pub use dataset::Dataset; use lance_index::vector::DIST_COL; From c7a7d3a0e944646e793d297d4a2e2cf7e4fb28a3 Mon Sep 17 00:00:00 2001 From: Lance Release Bot Date: Thu, 16 Apr 2026 02:15:05 +0000 Subject: [PATCH 196/206] chore: bump to 6.0.0-beta.1 based on breaking change detection --- .bumpversion.toml | 2 +- Cargo.lock | 60 ++-- Cargo.toml | 42 +-- java/lance-jni/Cargo.lock | 570 ++++++-------------------------------- java/lance-jni/Cargo.toml | 2 +- java/pom.xml | 2 +- python/Cargo.lock | 60 ++-- python/Cargo.toml | 2 +- 8 files changed, 175 insertions(+), 565 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index e1a90370669..cb662fe9fd9 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "5.1.0-beta.3" +current_version = "6.0.0-beta.1" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", diff --git a/Cargo.lock b/Cargo.lock index 7818ece8cb8..ce5370cae2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -621,9 +621,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -631,9 +631,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -1549,9 +1549,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -1571,9 +1571,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -3231,7 +3231,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "lance-datagen", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "all_asserts", "approx", @@ -4525,7 +4525,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4575,7 +4575,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrayref", "paste", @@ -4584,7 +4584,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4624,7 +4624,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4659,7 +4659,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4679,7 +4679,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4727,7 +4727,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "all_asserts", "arrow", @@ -4753,7 +4753,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -4796,7 +4796,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -4810,7 +4810,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "approx", "arrow", @@ -4889,7 +4889,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -4940,7 +4940,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "approx", "arrow-array", @@ -4961,7 +4961,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -4976,7 +4976,7 @@ dependencies = [ [[package]] name = "lance-namespace-datafusion" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4994,7 +4994,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -5053,7 +5053,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -5099,7 +5099,7 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "proc-macro2", "quote", @@ -5108,7 +5108,7 @@ dependencies = [ [[package]] name = "lance-testing" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "arrow-schema", @@ -5119,7 +5119,7 @@ dependencies = [ [[package]] name = "lance-tokenizer" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "jieba-rs", "lindera", @@ -5130,7 +5130,7 @@ dependencies = [ [[package]] name = "lance-tools" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "clap", "lance-core", diff --git a/Cargo.toml b/Cargo.toml index 6b5d2842a37..010f10e0846 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ resolver = "3" [workspace.package] -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" @@ -54,25 +54,25 @@ rust-version = "1.91.0" [workspace.dependencies] libc = "0.2.176" -lance = { version = "=5.1.0-beta.3", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=5.1.0-beta.3", path = "./rust/lance-arrow" } -lance-core = { version = "=5.1.0-beta.3", path = "./rust/lance-core" } -lance-datafusion = { version = "=5.1.0-beta.3", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=5.1.0-beta.3", path = "./rust/lance-datagen" } -lance-encoding = { version = "=5.1.0-beta.3", path = "./rust/lance-encoding" } -lance-file = { version = "=5.1.0-beta.3", path = "./rust/lance-file" } -lance-geo = { version = "=5.1.0-beta.3", path = "./rust/lance-geo" } -lance-index = { version = "=5.1.0-beta.3", path = "./rust/lance-index" } -lance-io = { version = "=5.1.0-beta.3", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=5.1.0-beta.3", path = "./rust/lance-linalg" } -lance-namespace = { version = "=5.1.0-beta.3", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=5.1.0-beta.3", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=5.1.0-beta.3", path = "./rust/lance-namespace-datafusion" } +lance = { version = "=6.0.0-beta.1", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=6.0.0-beta.1", path = "./rust/lance-arrow" } +lance-core = { version = "=6.0.0-beta.1", path = "./rust/lance-core" } +lance-datafusion = { version = "=6.0.0-beta.1", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=6.0.0-beta.1", path = "./rust/lance-datagen" } +lance-encoding = { version = "=6.0.0-beta.1", path = "./rust/lance-encoding" } +lance-file = { version = "=6.0.0-beta.1", path = "./rust/lance-file" } +lance-geo = { version = "=6.0.0-beta.1", path = "./rust/lance-geo" } +lance-index = { version = "=6.0.0-beta.1", path = "./rust/lance-index" } +lance-io = { version = "=6.0.0-beta.1", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=6.0.0-beta.1", path = "./rust/lance-linalg" } +lance-namespace = { version = "=6.0.0-beta.1", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=6.0.0-beta.1", path = "./rust/lance-namespace-impls" } +lance-namespace-datafusion = { version = "=6.0.0-beta.1", path = "./rust/lance-namespace-datafusion" } lance-namespace-reqwest-client = "0.6.1" -lance-tokenizer = { version = "=5.1.0-beta.3", path = "./rust/lance-tokenizer" } -lance-table = { version = "=5.1.0-beta.3", path = "./rust/lance-table" } -lance-test-macros = { version = "=5.1.0-beta.3", path = "./rust/lance-test-macros" } -lance-testing = { version = "=5.1.0-beta.3", path = "./rust/lance-testing" } +lance-tokenizer = { version = "=6.0.0-beta.1", path = "./rust/lance-tokenizer" } +lance-table = { version = "=6.0.0-beta.1", path = "./rust/lance-table" } +lance-test-macros = { version = "=6.0.0-beta.1", path = "./rust/lance-test-macros" } +lance-testing = { version = "=6.0.0-beta.1", path = "./rust/lance-testing" } approx = "0.5.1" # Note that this one does not include pyarrow arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } @@ -99,7 +99,7 @@ half = { "version" = "2.1", default-features = false, features = [ "num-traits", "std", ] } -lance-bitpacking = { version = "=5.1.0-beta.3", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=6.0.0-beta.1", path = "./rust/compression/bitpacking" } bitvec = "1" bytes = "1.11.1" byteorder = "1.5" @@ -140,7 +140,7 @@ deepsize = "0.2.0" dirs = "6.0.0" either = "1.0" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=5.1.0-beta.3", path = "./rust/compression/fsst" } +fsst = { version = "=6.0.0-beta.1", path = "./rust/compression/fsst" } futures = "0.3" geoarrow-array = "0.7" geoarrow-schema = "0.7" diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 325bb7d6ca0..22acba0d789 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -137,15 +137,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -286,7 +277,7 @@ dependencies = [ "arrow-schema", "arrow-select", "flatbuffers", - "lz4_flex 0.12.1", + "lz4_flex", "zstd", ] @@ -513,9 +504,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -523,9 +514,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -928,9 +919,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitpacking" @@ -994,31 +985,6 @@ dependencies = [ "generic-array", ] -[[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 = "brotli" version = "8.0.2" @@ -1095,12 +1061,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "census" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" - [[package]] name = "cesu8" version = "1.1.0" @@ -1388,40 +1348,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - [[package]] name = "dashmap" version = "6.1.0" @@ -1478,7 +1404,7 @@ dependencies = [ "object_store", "parking_lot", "parquet", - "rand 0.9.3", + "rand 0.9.4", "regex", "sqlparser", "tempfile", @@ -1593,7 +1519,7 @@ dependencies = [ "itertools 0.14.0", "log", "object_store", - "rand 0.9.3", + "rand 0.9.4", "tokio", "url", ] @@ -1719,7 +1645,7 @@ dependencies = [ "log", "object_store", "parking_lot", - "rand 0.9.3", + "rand 0.9.4", "tempfile", "url", ] @@ -1783,7 +1709,7 @@ dependencies = [ "log", "md-5", "num-traits", - "rand 0.9.3", + "rand 0.9.4", "regex", "sha2", "unicode-segmentation", @@ -2131,7 +2057,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -2187,12 +2112,6 @@ dependencies = [ "const-random", ] -[[package]] -name = "downcast-rs" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" - [[package]] name = "dunce" version = "1.0.5" @@ -2302,12 +2221,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" -[[package]] -name = "fastdivide" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" - [[package]] name = "fastrand" version = "2.4.1" @@ -2380,16 +2293,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs4" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" -dependencies = [ - "rustix 0.38.44", - "windows-sys 0.52.0", -] - [[package]] name = "fs_extra" version = "1.3.0" @@ -2398,10 +2301,10 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -2561,9 +2464,9 @@ dependencies = [ [[package]] name = "geo-types" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" dependencies = [ "approx", "num-traits", @@ -2764,8 +2667,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -2832,12 +2733,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "htmlescape" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" - [[package]] name = "http" version = "0.2.12" @@ -2935,16 +2830,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -3138,12 +3032,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -3377,10 +3265,10 @@ dependencies = [ "fast-float2", "itoa", "jiff", - "nom 8.0.0", + "nom", "num-traits", "ordered-float 5.3.0", - "rand 0.9.3", + "rand 0.9.4", "serde", "serde_json", "zmij", @@ -3403,7 +3291,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3446,20 +3334,21 @@ dependencies = [ "lance-linalg", "lance-namespace", "lance-table", + "lance-tokenizer", "log", "moka", "object_store", "permutation", "pin-project", "prost", + "prost-build", "prost-types", - "rand 0.9.3", + "rand 0.9.4", "roaring", "semver", "serde", "serde_json", "snafu", - "tantivy", "tokio", "tokio-stream", "tokio-util", @@ -3470,7 +3359,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3486,12 +3375,12 @@ dependencies = [ "half", "jsonb", "num-traits", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrayref", "paste", @@ -3500,7 +3389,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3523,7 +3412,7 @@ dependencies = [ "object_store", "pin-project", "prost", - "rand 0.9.3", + "rand 0.9.4", "roaring", "serde_json", "snafu", @@ -3537,7 +3426,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3571,7 +3460,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3581,15 +3470,15 @@ dependencies = [ "futures", "half", "hex", - "rand 0.9.3", - "rand_distr 0.5.1", + "rand 0.9.4", + "rand_distr", "rand_xoshiro", "random_word", ] [[package]] name = "lance-encoding" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -3615,7 +3504,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.3", + "rand 0.9.4", "snafu", "strum", "tokio", @@ -3626,7 +3515,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -3658,7 +3547,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -3672,7 +3561,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3713,6 +3602,7 @@ dependencies = [ "lance-io", "lance-linalg", "lance-table", + "lance-tokenizer", "libm", "log", "ndarray", @@ -3721,8 +3611,8 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.3", - "rand_distr 0.5.1", + "rand 0.9.4", + "rand_distr", "rangemap", "rayon", "roaring", @@ -3730,7 +3620,6 @@ dependencies = [ "serde_json", "smallvec", "snafu", - "tantivy", "tempfile", "tokio", "tracing", @@ -3740,7 +3629,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3773,7 +3662,7 @@ dependencies = [ "path_abs", "pin-project", "prost", - "rand 0.9.3", + "rand 0.9.4", "serde", "snafu", "tempfile", @@ -3784,7 +3673,7 @@ dependencies = [ [[package]] name = "lance-jni" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3820,7 +3709,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3831,12 +3720,12 @@ dependencies = [ "lance-arrow", "lance-core", "num-traits", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] name = "lance-namespace" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -3849,7 +3738,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -3868,7 +3757,7 @@ dependencies = [ "lance-table", "log", "object_store", - "rand 0.9.3", + "rand 0.9.4", "reqwest", "serde", "serde_json", @@ -3894,7 +3783,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3916,7 +3805,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.3", + "rand 0.9.4", "rangemap", "roaring", "semver", @@ -3929,6 +3818,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "lance-tokenizer" +version = "6.0.0-beta.1" +dependencies = [ + "rust-stemmers", + "serde", + "unicode-normalization", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3944,12 +3842,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "levenshtein_automata" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" - [[package]] name = "lexical-core" version = "1.0.6" @@ -4009,9 +3901,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libm" @@ -4028,12 +3920,6 @@ dependencies = [ "libc", ] -[[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" @@ -4074,15 +3960,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -4108,12 +3985,6 @@ dependencies = [ "libc", ] -[[package]] -name = "lz4_flex" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" - [[package]] name = "lz4_flex" version = "0.12.1" @@ -4161,30 +4032,12 @@ dependencies = [ "digest", ] -[[package]] -name = "measure_time" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" -dependencies = [ - "log", -] - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - [[package]] name = "mime" version = "0.3.17" @@ -4201,12 +4054,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4260,12 +4107,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "murmurhash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" - [[package]] name = "ndarray" version = "0.16.1" @@ -4281,16 +4122,6 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nom" version = "8.0.0" @@ -4434,7 +4265,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml 0.38.4", - "rand 0.9.3", + "rand 0.9.4", "reqwest", "ring", "rustls-pemfile", @@ -4478,12 +4309,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "oneshot" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" - [[package]] name = "opendal" version = "0.55.0" @@ -4560,15 +4385,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "ownedbytes" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "parking" version = "2.2.1" @@ -4620,7 +4436,7 @@ dependencies = [ "futures", "half", "hashbrown 0.16.1", - "lz4_flex 0.12.1", + "lz4_flex", "num-bigint", "num-integer", "num-traits", @@ -4833,9 +4649,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "portable-atomic" @@ -5004,7 +4820,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -5070,9 +4886,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -5116,16 +4932,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "rand_distr" version = "0.5.1" @@ -5133,7 +4939,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -5154,7 +4960,7 @@ dependencies = [ "ahash", "brotli", "paste", - "rand 0.9.3", + "rand 0.9.4", "unicase", ] @@ -5172,9 +4978,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -5430,19 +5236,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" @@ -5452,15 +5245,15 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "once_cell", @@ -5504,9 +5297,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.11" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", @@ -5839,15 +5632,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" -[[package]] -name = "sketches-ddsketch" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" -dependencies = [ - "serde", -] - [[package]] name = "slab" version = "0.4.12" @@ -5964,12 +5748,6 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51f1e89f093f99e7432c491c382b88a6860a5adbe6bf02574bf0a08efff1978" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "strum" version = "0.26.3" @@ -6071,152 +5849,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "tantivy" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a966cb0e76e311f09cf18507c9af192f15d34886ee43d7ba7c7e3803660c43" -dependencies = [ - "aho-corasick", - "arc-swap", - "base64", - "bitpacking", - "bon", - "byteorder", - "census", - "crc32fast", - "crossbeam-channel", - "downcast-rs", - "fastdivide", - "fnv", - "fs4", - "htmlescape", - "hyperloglogplus", - "itertools 0.14.0", - "levenshtein_automata", - "log", - "lru", - "lz4_flex 0.11.6", - "measure_time", - "memmap2", - "once_cell", - "oneshot", - "rayon", - "regex", - "rust-stemmers", - "rustc-hash", - "serde", - "serde_json", - "sketches-ddsketch", - "smallvec", - "tantivy-bitpacker", - "tantivy-columnar", - "tantivy-common", - "tantivy-fst", - "tantivy-query-grammar", - "tantivy-stacker", - "tantivy-tokenizer-api", - "tempfile", - "thiserror 2.0.18", - "time", - "uuid", - "winapi", -] - -[[package]] -name = "tantivy-bitpacker" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494" -dependencies = [ - "bitpacking", -] - -[[package]] -name = "tantivy-columnar" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344" -dependencies = [ - "downcast-rs", - "fastdivide", - "itertools 0.14.0", - "serde", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-sstable", - "tantivy-stacker", -] - -[[package]] -name = "tantivy-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f" -dependencies = [ - "async-trait", - "byteorder", - "ownedbytes", - "serde", - "time", -] - -[[package]] -name = "tantivy-fst" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" -dependencies = [ - "byteorder", - "regex-syntax", - "utf8-ranges", -] - -[[package]] -name = "tantivy-query-grammar" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" -dependencies = [ - "nom 7.1.3", - "serde", - "serde_json", -] - -[[package]] -name = "tantivy-sstable" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" -dependencies = [ - "futures-util", - "itertools 0.14.0", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-fst", - "zstd", -] - -[[package]] -name = "tantivy-stacker" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" -dependencies = [ - "murmurhash32", - "rand_distr 0.4.3", - "tantivy-common", -] - -[[package]] -name = "tantivy-tokenizer-api" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" -dependencies = [ - "serde", -] - [[package]] name = "tap" version = "1.0.1" @@ -6232,7 +5864,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -6372,9 +6004,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" dependencies = [ "bytes", "libc", @@ -6604,7 +6236,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" dependencies = [ - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -6672,6 +6304,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -6942,22 +6583,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -6967,12 +6592,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -7050,15 +6669,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 54213a2ca8c..6657c70e002 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lance-jni" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" edition = "2024" authors = ["Lance Devs "] rust-version = "1.91" diff --git a/java/pom.xml b/java/pom.xml index e18418a8d88..23b44326f96 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 5.1.0-beta.3 + 6.0.0-beta.1 jar Lance Format Java API diff --git a/python/Cargo.lock b/python/Cargo.lock index ec4d20f38b0..7a7bfc20e2d 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -595,9 +595,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -605,9 +605,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -2709,7 +2709,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "rand 0.9.4", @@ -2881,9 +2881,9 @@ dependencies = [ [[package]] name = "geo-types" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" dependencies = [ "approx", "num-traits", @@ -3247,9 +3247,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.8" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", "hyper", @@ -3710,7 +3710,7 @@ dependencies = [ [[package]] name = "lance" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -3779,7 +3779,7 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3800,7 +3800,7 @@ dependencies = [ [[package]] name = "lance-bitpacking" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrayref", "paste", @@ -3809,7 +3809,7 @@ dependencies = [ [[package]] name = "lance-core" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -3846,7 +3846,7 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3880,7 +3880,7 @@ dependencies = [ [[package]] name = "lance-datagen" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -3898,7 +3898,7 @@ dependencies = [ [[package]] name = "lance-encoding" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -3935,7 +3935,7 @@ dependencies = [ [[package]] name = "lance-file" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-arith", "arrow-array", @@ -3967,7 +3967,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "datafusion", "geo-traits", @@ -3981,7 +3981,7 @@ dependencies = [ [[package]] name = "lance-index" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -4050,7 +4050,7 @@ dependencies = [ [[package]] name = "lance-io" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-arith", @@ -4094,7 +4094,7 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow-array", "arrow-buffer", @@ -4110,7 +4110,7 @@ dependencies = [ [[package]] name = "lance-namespace" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -4123,7 +4123,7 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -4168,7 +4168,7 @@ dependencies = [ [[package]] name = "lance-table" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -4207,7 +4207,7 @@ dependencies = [ [[package]] name = "lance-tokenizer" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "jieba-rs", "lindera", @@ -5413,7 +5413,7 @@ dependencies = [ [[package]] name = "pylance" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" dependencies = [ "arrow", "arrow-array", @@ -6864,9 +6864,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" dependencies = [ "bytes", "libc", @@ -7139,9 +7139,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "typewit" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc19094686c694eb41b3b99dcc2f2975d4b078512fa22ae6c63f7ca318bdcff7" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" [[package]] name = "typify" diff --git a/python/Cargo.toml b/python/Cargo.toml index 95a8474d9b2..4a242f6bd9c 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylance" -version = "5.1.0-beta.3" +version = "6.0.0-beta.1" edition = "2024" authors = ["Lance Devs "] license = "Apache-2.0" From 0aedfa42d8b6b0895945abdcc164914e51fa6e5b Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 16 Apr 2026 16:31:26 +0800 Subject: [PATCH 197/206] test: isolate memory leak probe (#6539) This isolates the `test_memory_leaks` index statistics probe into a fresh subprocess instead of running it inside the long-lived pytest worker process. That keeps the test focused on repeated `index_statistics` calls and avoids false positives from RSS growth left behind by earlier tests such as the recent batch-chopping coverage added in `test_dataset.py`. --- python/python/tests/test_memory_leaks.py | 38 +++++++++++++++++++----- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/python/python/tests/test_memory_leaks.py b/python/python/tests/test_memory_leaks.py index 29907089ba0..20ba1686875 100644 --- a/python/python/tests/test_memory_leaks.py +++ b/python/python/tests/test_memory_leaks.py @@ -5,6 +5,8 @@ import gc import os +import subprocess +import sys from typing import Callable import lance @@ -78,18 +80,40 @@ def assert_noleaks( ) +def run_isolated_probe(probe_name: str, *args: str) -> None: + result = subprocess.run( + [sys.executable, __file__, probe_name, *args], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"Isolated probe '{probe_name}' failed.\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + + +def probe_index_statistics_no_leak(dataset_path: str) -> None: + def access_index_stats() -> None: + d = lance.dataset(dataset_path) + for idx in d.describe_indices(): + d.stats.index_stats(idx.name) + + assert_noleaks( + access_index_stats, iterations=1000, threshold_mb=2.0, check_interval=25 + ) + + class TestMemoryLeaks: def test_index_statistics_no_leak(self, tmp_path) -> None: dataset_path = str(tmp_path / "dataset") data = pa.table({"id": [1]}) ds = lance.write_dataset(data, dataset_path) ds.create_scalar_index("id", index_type="BTREE") + run_isolated_probe("index_statistics_no_leak", dataset_path) - def access_index_stats() -> None: - d = lance.dataset(dataset_path) - for idx in d.describe_indices(): - d.stats.index_stats(idx.name) - assert_noleaks( - access_index_stats, iterations=1000, threshold_mb=2.0, check_interval=25 - ) +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "index_statistics_no_leak": + probe_index_statistics_no_leak(sys.argv[2]) From accef6672180b142a607da6f2dab46cf7dbd2852 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Thu, 16 Apr 2026 17:24:06 +0800 Subject: [PATCH 198/206] feat: add planned blob reads with source-level coalescing (#6352) This PR improves blob I/O in two complementary ways: `BlobFile` instances that resolve to the same physical object now share a lazy `BlobSource` and can opportunistically coalesce concurrent reads before handing them to Lance's existing scheduler, and datasets now expose a planned `read_blobs` API for materializing blob payloads directly. It also adds explicit cursor-preserving range reads for `BlobFile` across Rust, Python, and Java, with end-to-end Python coverage for the new API and the edge cases it uncovered. This keeps the optimization aligned with Lance's existing scheduler model while giving callers a higher-level path for sequential and batched blob access. ## Python example ```python import lance dataset = lance.dataset("/path/to/dataset") blobs = dataset.read_blobs( "images", indices=[0, 4, 8], target_request_bytes=8 * 1024 * 1024, max_gap_bytes=64 * 1024, max_concurrency=4, preserve_order=True, ) for row_address, payload in blobs: print(row_address, len(payload)) ``` --- java/lance-jni/src/blocking_blob.rs | 35 + java/src/main/java/org/lance/BlobFile.java | 7 + python/python/lance/blob.py | 4 + python/python/lance/dataset.py | 95 +- python/python/lance/lance/__init__.pyi | 21 + python/python/tests/test_blob.py | 162 ++ python/src/dataset.rs | 108 ++ python/src/dataset/blob.rs | 17 + rust/lance/src/dataset.rs | 34 +- rust/lance/src/dataset/blob.rs | 1565 +++++++++++++++++--- rust/lance/src/dataset/take.rs | 8 +- 11 files changed, 1846 insertions(+), 210 deletions(-) diff --git a/java/lance-jni/src/blocking_blob.rs b/java/lance-jni/src/blocking_blob.rs index 4222e6b89d6..002fa817cf6 100755 --- a/java/lance-jni/src/blocking_blob.rs +++ b/java/lance-jni/src/blocking_blob.rs @@ -177,6 +177,41 @@ fn inner_blob_read_up_to<'local>( Ok(arr) } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_BlobFile_nativeReadRange<'local>( + mut env: JNIEnv<'local>, + jblob: JObject, + offset: jlong, + len: jint, +) -> jbyteArray { + ok_or_throw_with_return!( + env, + inner_blob_read_range(&mut env, jblob, offset, len).map(|arr| arr.into_raw()), + JByteArray::default().into_raw() + ) +} + +fn inner_blob_read_range<'local>( + env: &mut JNIEnv<'local>, + jblob: JObject, + offset: jlong, + len: jint, +) -> Result> { + let end = (offset as u64) + .checked_add(len as u64) + .ok_or_else(|| lance_core::Error::invalid_input("offset + len overflowed".to_string()))?; + let bytes = { + let blob = unsafe { env.get_rust_field::<_, _, BlockingBlobFile>(jblob, NATIVE_BLOB) }?; + RT.block_on(blob.inner.read_range(offset as u64..end))? + }; + let arr = env.new_byte_array(bytes.len() as jint)?; + let u8_slice: &[u8] = bytes.as_ref(); + let i8_slice: &[i8] = unsafe { transmute(u8_slice) }; + + env.set_byte_array_region(&arr, 0, i8_slice)?; + Ok(arr) +} + #[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_BlobFile_nativeSeek( mut env: JNIEnv, diff --git a/java/src/main/java/org/lance/BlobFile.java b/java/src/main/java/org/lance/BlobFile.java index 03c8408ab57..27af01fe73c 100755 --- a/java/src/main/java/org/lance/BlobFile.java +++ b/java/src/main/java/org/lance/BlobFile.java @@ -59,6 +59,13 @@ public byte[] readUpTo(int len) throws IOException { return nativeReadUpTo(len); } + /** Read a blob-local range without changing the current cursor. */ + public byte[] readRange(long offset, int len) throws IOException { + if (offset < 0) throw new IllegalArgumentException("offset must be non-negative"); + if (len < 0) throw new IllegalArgumentException("len must be non-negative"); + return nativeReadRange(offset, len); + } + /** Seek to a new cursor position. */ public void seek(long newCursor) throws IOException { if (newCursor < 0) throw new IllegalArgumentException("newCursor must be non-negative"); diff --git a/python/python/lance/blob.py b/python/python/lance/blob.py index 1a3f4e946fb..02adfdc95e3 100644 --- a/python/python/lance/blob.py +++ b/python/python/lance/blob.py @@ -282,6 +282,10 @@ def size(self) -> int: def readall(self) -> bytes: return self.inner.readall() + def read_range(self, offset: int, length: int) -> bytes: + """Read a blob-local byte range without changing the current cursor.""" + return self.inner.read_range(offset, length) + def readinto(self, b: bytearray) -> int: return self.inner.read_into(b) diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index 3c8668abb84..79641ab4438 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -227,6 +227,23 @@ def _is_null_blob_description(description: Any) -> bool: return False +def _resolve_blob_selection( + ids: Optional[Union[List[int], pa.Array]], + addresses: Optional[Union[List[int], pa.Array]], + indices: Optional[Union[List[int], pa.Array]], +) -> Tuple[str, Union[List[int], pa.Array]]: + if sum([bool(v is not None) for v in [ids, addresses, indices]]) != 1: + raise ValueError("Exactly one of ids, indices, or addresses must be specified") + + if ids is not None: + return "ids", ids + if addresses is not None: + return "addresses", addresses + if indices is not None: + return "indices", indices + raise ValueError("Either ids, addresses, or indices must be specified") + + class MergeInsertBuilder(_MergeInsertBuilder): def execute(self, data_obj: ReaderLike, *, schema: Optional[pa.Schema] = None): """Executes the merge insert operation @@ -1910,21 +1927,77 @@ def take_blobs( ------- blob_files : List[BlobFile] """ - if sum([bool(v is not None) for v in [ids, addresses, indices]]) != 1: - raise ValueError( - "Exactly one of ids, indices, or addresses must be specified" - ) + selection_kind, selection_values = _resolve_blob_selection( + ids, addresses, indices + ) - if ids is not None: - lance_blob_files = self._ds.take_blobs(ids, blob_column) - elif addresses is not None: - lance_blob_files = self._ds.take_blobs_by_addresses(addresses, blob_column) - elif indices is not None: - lance_blob_files = self._ds.take_blobs_by_indices(indices, blob_column) + if selection_kind == "ids": + lance_blob_files = self._ds.take_blobs(selection_values, blob_column) + elif selection_kind == "addresses": + lance_blob_files = self._ds.take_blobs_by_addresses( + selection_values, blob_column + ) else: - raise ValueError("Either ids, addresses, or indices must be specified") + lance_blob_files = self._ds.take_blobs_by_indices( + selection_values, blob_column + ) return [BlobFile(lance_blob_file) for lance_blob_file in lance_blob_files] + def read_blobs( + self, + blob_column: str, + ids: Optional[Union[List[int], pa.Array]] = None, + addresses: Optional[Union[List[int], pa.Array]] = None, + indices: Optional[Union[List[int], pa.Array]] = None, + *, + io_buffer_size: Optional[int] = None, + preserve_order: Optional[bool] = None, + ) -> List[Tuple[int, bytes]]: + """ + Read blobs directly into memory using Lance's planned blob reader. + + Unlike :py:meth:`take_blobs`, which returns file-like :py:class:`lance.BlobFile` + handles for random access, this API plans and executes batched reads and + returns materialized blob payloads. + + Exactly one of ids, addresses, or indices must be specified. + + Parameters + ---------- + blob_column : str + The name of the blob column to read. + ids : Integer Array or array-like + Row IDs to read in the dataset. + addresses : Integer Array or array-like + The (unstable) row addresses to read in the dataset. + indices : Integer Array or array-like + The offset / indices of the row in the dataset. + io_buffer_size : int, optional + Override the scheduler I/O buffer size used while materializing blobs. + preserve_order : bool, optional + If True, returned rows follow the requested selection order. + + Returns + ------- + blobs : List[Tuple[int, bytes]] + A list of ``(row_address, blob_bytes)`` pairs. + """ + selection_kind, selection_values = _resolve_blob_selection( + ids, addresses, indices + ) + + kwargs = { + "io_buffer_size": io_buffer_size, + "preserve_order": preserve_order, + } + if selection_kind == "ids": + return self._ds.read_blobs(selection_values, blob_column, **kwargs) + if selection_kind == "addresses": + return self._ds.read_blobs_by_addresses( + selection_values, blob_column, **kwargs + ) + return self._ds.read_blobs_by_indices(selection_values, blob_column, **kwargs) + def head(self, num_rows, **kwargs): """ Load the first N rows of the dataset. diff --git a/python/python/lance/lance/__init__.pyi b/python/python/lance/lance/__init__.pyi index 60398f9a3ae..82eaae3c97c 100644 --- a/python/python/lance/lance/__init__.pyi +++ b/python/python/lance/lance/__init__.pyi @@ -288,6 +288,27 @@ class _Dataset: row_indices: List[int], blob_column: str, ) -> List[LanceBlobFile]: ... + def read_blobs( + self, + row_ids: List[int], + blob_column: str, + io_buffer_size: Optional[int] = None, + preserve_order: Optional[bool] = None, + ) -> List[Tuple[int, bytes]]: ... + def read_blobs_by_addresses( + self, + row_addresses: List[int], + blob_column: str, + io_buffer_size: Optional[int] = None, + preserve_order: Optional[bool] = None, + ) -> List[Tuple[int, bytes]]: ... + def read_blobs_by_indices( + self, + row_indices: List[int], + blob_column: str, + io_buffer_size: Optional[int] = None, + preserve_order: Optional[bool] = None, + ) -> List[Tuple[int, bytes]]: ... def take_scan( self, row_slices: Iterable[Tuple[int, int]], diff --git a/python/python/tests/test_blob.py b/python/python/tests/test_blob.py index 01374a62b8e..c2baae79ce3 100644 --- a/python/python/tests/test_blob.py +++ b/python/python/tests/test_blob.py @@ -16,6 +16,32 @@ lance_dataset_module = importlib.import_module("lance.dataset") +def _blob_row_ids(dataset): + return dataset.to_table(columns=[], with_row_id=True).column("_rowid").to_pylist() + + +def _blob_row_addresses(dataset): + return ( + dataset.to_table(columns=["idx"], with_row_address=True) + .column("_rowaddr") + .to_pylist() + ) + + +def _out_of_order_blob_selection(dataset_with_blobs, selection_kind): + addresses = _blob_row_addresses(dataset_with_blobs) + expected = [(addresses[4], b"quux"), (addresses[0], b"foo")] + + if selection_kind == "ids": + return [ + _blob_row_ids(dataset_with_blobs)[4], + _blob_row_ids(dataset_with_blobs)[0], + ], expected + if selection_kind == "addresses": + return [addresses[4], addresses[0]], expected + return [4, 0], expected + + def test_blob_read_from_binary(): values = [b"foo", b"bar", b"baz"] data = pa.table( @@ -251,6 +277,134 @@ def test_blob_by_indices(tmp_path, dataset_with_blobs): assert f1.read() == f2.read() +@pytest.mark.parametrize( + ("selection_kind", "selection_values", "expected"), + [ + ("ids", [0, (1 << 32) + 1], [(0, b"foo"), ((1 << 32) + 1, b"quux")]), + ("addresses", [0, (1 << 32) + 1], [(0, b"foo"), ((1 << 32) + 1, b"quux")]), + ("indices", [0, 4], [(0, b"foo"), ((1 << 32) + 1, b"quux")]), + ], +) +def test_read_blobs(dataset_with_blobs, selection_kind, selection_values, expected): + kwargs = {selection_kind: selection_values} + + blobs = dataset_with_blobs.read_blobs( + "blobs", + **kwargs, + io_buffer_size=1024, + preserve_order=True, + ) + + assert blobs == expected + + +def test_read_blobs_requires_single_selector(dataset_with_blobs): + with pytest.raises( + ValueError, match="Exactly one of ids, indices, or addresses must be specified" + ): + dataset_with_blobs.read_blobs("blobs", ids=[0], indices=[0]) + + +def test_read_blobs_requires_selector(dataset_with_blobs): + with pytest.raises( + ValueError, match="Exactly one of ids, indices, or addresses must be specified" + ): + dataset_with_blobs.read_blobs("blobs") + + +def test_read_blobs_rejects_non_blob_column(dataset_with_blobs): + with pytest.raises(ValueError, match="not a blob column"): + dataset_with_blobs.read_blobs("idx", indices=[0]) + + +@pytest.mark.parametrize( + ("selection_kind", "selection_values", "expected"), + [ + ( + "ids", + pa.array([0, (1 << 32) + 1], type=pa.uint64()), + [(0, b"foo"), ((1 << 32) + 1, b"quux")], + ), + ( + "addresses", + pa.array([0, (1 << 32) + 1], type=pa.uint64()), + [(0, b"foo"), ((1 << 32) + 1, b"quux")], + ), + ( + "indices", + pa.array([0, 4], type=pa.uint64()), + [(0, b"foo"), ((1 << 32) + 1, b"quux")], + ), + ], +) +def test_read_blobs_accepts_arrow_array_selectors( + dataset_with_blobs, selection_kind, selection_values, expected +): + kwargs = {selection_kind: selection_values} + + blobs = dataset_with_blobs.read_blobs("blobs", **kwargs) + + assert blobs == expected + + +@pytest.mark.parametrize( + ("selection_kind", "selection_values"), + [ + ("ids", []), + ("addresses", []), + ("indices", []), + ("ids", pa.array([], type=pa.uint64())), + ("addresses", pa.array([], type=pa.uint64())), + ("indices", pa.array([], type=pa.uint64())), + ], +) +def test_read_blobs_accepts_empty_selection( + dataset_with_blobs, selection_kind, selection_values +): + kwargs = {selection_kind: selection_values} + + assert dataset_with_blobs.read_blobs("blobs", **kwargs) == [] + + +@pytest.mark.parametrize( + ("planner_kwargs", "error_message"), + [ + ({"io_buffer_size": 0}, "io_buffer_size must be greater than 0"), + ], +) +def test_read_blobs_rejects_invalid_planner_options( + dataset_with_blobs, planner_kwargs, error_message +): + with pytest.raises(ValueError, match=error_message): + dataset_with_blobs.read_blobs("blobs", indices=[0], **planner_kwargs) + + +@pytest.mark.parametrize("selection_kind", ["ids", "addresses", "indices"]) +def test_read_blobs_preserves_input_order(dataset_with_blobs, selection_kind): + selection_values, expected = _out_of_order_blob_selection( + dataset_with_blobs, selection_kind + ) + kwargs = {selection_kind: selection_values} + + blobs = dataset_with_blobs.read_blobs("blobs", **kwargs, preserve_order=True) + + assert blobs == expected + + +@pytest.mark.parametrize("selection_kind", ["ids", "addresses", "indices"]) +def test_read_blobs_without_preserve_order_returns_same_rows( + dataset_with_blobs, selection_kind +): + selection_values, expected = _out_of_order_blob_selection( + dataset_with_blobs, selection_kind + ) + kwargs = {selection_kind: selection_values} + + blobs = dataset_with_blobs.read_blobs("blobs", **kwargs, preserve_order=False) + + assert sorted(blobs) == sorted(expected) + + def test_blob_file_seek(tmp_path, dataset_with_blobs): row_ids = ( dataset_with_blobs.to_table(columns=[], with_row_id=True) @@ -466,6 +620,12 @@ def test_blob_extension_write_external_slice(tmp_path): with blob_file as f: assert f.read() == expected + assert ds.read_blobs("blob", indices=[0, 1, 2]) == [ + (0, b"alpha"), + (1, b"bravo"), + (2, b"charlie"), + ] + def test_blob_extension_write_external_slice_ingest(tmp_path): tar_path = tmp_path / "container.tar" @@ -548,6 +708,8 @@ def test_blob_extension_take_blobs_multi_base(payload, is_dataset_root, tmp_path with blobs[0] as f: assert f.read() == payload + assert ds.read_blobs("blob", indices=[0]) == [(0, payload)] + @pytest.fixture def dataset_for_pandas_blob_tests(tmp_path): diff --git a/python/src/dataset.rs b/python/src/dataset.rs index 7e139446819..1885b78bd64 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -119,6 +119,30 @@ const DEFAULT_NPROBES: usize = 1; const LANCE_COMMIT_MESSAGE_KEY: &str = "__lance_commit_message"; const INDEX_PROGRESS_QUEUE_SIZE: usize = 1024; +fn read_blobs_to_python( + py: Python<'_>, + blobs: Vec, +) -> Vec<(u64, Py)> { + blobs + .into_iter() + .map(|blob| (blob.row_address, PyBytes::new(py, &blob.data).unbind())) + .collect() +} + +fn configure_read_blobs_builder( + mut builder: lance::dataset::ReadBlobsBuilder, + io_buffer_size: Option, + preserve_order: Option, +) -> lance::dataset::ReadBlobsBuilder { + if let Some(bytes) = io_buffer_size { + builder = builder.with_io_buffer_size_bytes(bytes); + } + if let Some(preserve) = preserve_order { + builder = builder.preserve_order(preserve); + } + builder +} + fn convert_reader(reader: &Bound) -> PyResult> { let py = reader.py(); if reader.is_instance_of::() { @@ -1247,6 +1271,90 @@ impl Dataset { Ok(blobs.into_iter().map(LanceBlobFile::from).collect()) } + #[pyo3(signature=( + row_ids, + blob_column, + io_buffer_size=None, + preserve_order=None + ))] + fn read_blobs( + self_: PyRef<'_, Self>, + row_ids: Vec, + blob_column: &str, + io_buffer_size: Option, + preserve_order: Option, + ) -> PyResult)>> { + let builder = configure_read_blobs_builder( + self_ + .ds + .read_blobs(blob_column) + .infer_error()? + .with_row_ids(row_ids), + io_buffer_size, + preserve_order, + ); + let blobs = rt() + .block_on(Some(self_.py()), builder.execute())? + .infer_error()?; + Ok(read_blobs_to_python(self_.py(), blobs)) + } + + #[pyo3(signature=( + row_addresses, + blob_column, + io_buffer_size=None, + preserve_order=None + ))] + fn read_blobs_by_addresses( + self_: PyRef<'_, Self>, + row_addresses: Vec, + blob_column: &str, + io_buffer_size: Option, + preserve_order: Option, + ) -> PyResult)>> { + let builder = configure_read_blobs_builder( + self_ + .ds + .read_blobs(blob_column) + .infer_error()? + .with_row_addresses(row_addresses), + io_buffer_size, + preserve_order, + ); + let blobs = rt() + .block_on(Some(self_.py()), builder.execute())? + .infer_error()?; + Ok(read_blobs_to_python(self_.py(), blobs)) + } + + #[pyo3(signature=( + row_indices, + blob_column, + io_buffer_size=None, + preserve_order=None + ))] + fn read_blobs_by_indices( + self_: PyRef<'_, Self>, + row_indices: Vec, + blob_column: &str, + io_buffer_size: Option, + preserve_order: Option, + ) -> PyResult)>> { + let builder = configure_read_blobs_builder( + self_ + .ds + .read_blobs(blob_column) + .infer_error()? + .with_row_indices(row_indices), + io_buffer_size, + preserve_order, + ); + let blobs = rt() + .block_on(Some(self_.py()), builder.execute())? + .infer_error()?; + Ok(read_blobs_to_python(self_.py(), blobs)) + } + #[pyo3(signature = (row_slices, columns = None, batch_readahead = 10))] fn take_scan( &self, diff --git a/python/src/dataset/blob.rs b/python/src/dataset/blob.rs index 569a0e3ed8e..1f6272075f2 100644 --- a/python/src/dataset/blob.rs +++ b/python/src/dataset/blob.rs @@ -62,6 +62,23 @@ impl LanceBlobFile { Ok(PyBytes::new(py, &data)) } + /// Read a blob-local byte range without changing the current cursor. + pub fn read_range<'a>( + &'a self, + py: Python<'a>, + offset: u64, + length: usize, + ) -> PyResult> { + let end = offset + .checked_add(length as u64) + .ok_or_else(|| PyValueError::new_err("offset + length overflowed"))?; + let inner = self.inner.clone(); + let data = rt() + .block_on(Some(py), inner.read_range(offset..end))? + .infer_error()?; + Ok(PyBytes::new(py, &data)) + } + pub fn read_into(&self, dst: Bound<'_, PyByteArray>) -> PyResult { let inner = self.inner.clone(); diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 9763f8bb68a..8a7a9cf3636 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -113,7 +113,7 @@ use crate::io::commit::{ use crate::session::Session; use crate::utils::temporal::{SystemTime, timestamp_to_nanos, utc_now}; use crate::{Error, Result}; -pub use blob::BlobFile; +pub use blob::{BlobFile, ReadBlob, ReadBlobsBuilder, ReadBlobsStream}; use hash_joiner::HashJoiner; pub use lance_core::ROW_ID; use lance_core::box_error; @@ -1486,6 +1486,38 @@ impl Dataset { blob::take_blobs_by_addresses(self, &row_addrs, column.as_ref()).await } + /// Create a planned blob reader for a blob column. + /// + /// This API complements [`Self::take_blobs`]. `take_blobs` returns + /// [`BlobFile`] handles for caller-driven random access, while + /// `read_blobs` builds a streaming read plan for sequential or batched blob + /// retrieval. + /// + /// ```rust + /// # use std::sync::Arc; + /// # use futures::TryStreamExt; + /// # use lance::dataset::Dataset; + /// # use lance::Result; + /// # async fn example(dataset: Arc) -> Result<()> { + /// let blobs = dataset + /// .read_blobs("images")? + /// .with_row_indices(vec![0, 1, 2]) + /// .execute() + /// .await?; + /// # let _ = blobs; + /// # Ok(()) + /// # } + /// ``` + pub fn read_blobs(self: &Arc, column: impl AsRef) -> Result { + let column = column.as_ref(); + let blob_field_id = blob::validate_blob_column(self, column)?; + Ok(ReadBlobsBuilder::new( + self.clone(), + column.to_string(), + blob_field_id, + )) + } + /// Get a stream of batches based on iterator of ranges of row numbers. /// /// This is an experimental API. It may change at any time. diff --git a/rust/lance/src/dataset/blob.rs b/rust/lance/src/dataset/blob.rs index 29ed15533aa..a828f3fc336 100644 --- a/rust/lance/src/dataset/blob.rs +++ b/rust/lance/src/dataset/blob.rs @@ -2,10 +2,12 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap, VecDeque}, future::Future, ops::{DerefMut, Range}, + panic::AssertUnwindSafe, sync::Arc, + task::Poll, }; use arrow::array::AsArray; @@ -14,11 +16,15 @@ use arrow_array::Array; use arrow_array::RecordBatch; use arrow_array::builder::{LargeBinaryBuilder, PrimitiveBuilder, StringBuilder}; use arrow_schema::DataType as ArrowDataType; +use bytes::Bytes; +use futures::stream::BoxStream; +use futures::{FutureExt, StreamExt, TryStreamExt, stream}; use lance_arrow::{BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY, FieldExt}; use lance_io::object_store::{ObjectStore, ObjectStoreParams, ObjectStoreRegistry}; +use lance_io::scheduler::{FileScheduler, ScanScheduler, SchedulerConfig}; use object_store::path::Path; use tokio::io::AsyncWriteExt; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, OnceCell, oneshot}; use url::Url; use super::take::TakeBuilder; @@ -29,11 +35,11 @@ use lance_core::datatypes::{BlobKind, BlobVersion}; use lance_core::utils::blob::blob_path; use lance_core::{Error, Result, utils::address::RowAddress}; use lance_io::traits::{Reader, WriteExt, Writer}; +use lance_io::utils::CachedFileSize; const INLINE_MAX: usize = 64 * 1024; // 64KB inline cutoff const DEDICATED_THRESHOLD: usize = 4 * 1024 * 1024; // 4MB dedicated cutoff const PACK_FILE_MAX_SIZE: usize = 1024 * 1024 * 1024; // 1GiB per .pack sidecar - #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct ResolvedExternalBase { pub base_id: u32, @@ -715,29 +721,267 @@ pub async fn preprocess_blob_batches( Ok(out) } -/// Current state of the reader. Held in a mutex for easy sharing +/// Mutable state for a [`BlobFile`] cursor. /// -/// The u64 is the cursor in the file that the reader is currently at -/// (note that seeks are allowed before the file is opened) +/// The cursor is logical to the blob slice, not the backing object. Once closed, +/// subsequent cursor-based and range-based reads are rejected, but reads that +/// were already in flight may still complete. #[derive(Debug)] -enum ReaderState { - Uninitialized(u64), - Open((u64, Arc)), +enum BlobFileState { + Open(u64), Closed, } -/// A file-like object that represents a blob in a dataset +/// Shared physical read context for blob handles that resolve to the same object. +/// +/// Blob descriptors are logical slices over a backing object (data file, packed +/// sidecar, dedicated sidecar, or external object). Multiple [`BlobFile`] values +/// can point at different regions of that same object. This struct gives those +/// handles a single lazy-open scheduler plus a lightweight pending queue so +/// concurrent reads can be opportunistically grouped before reaching Lance's +/// existing I/O scheduler. #[derive(Debug)] -pub struct BlobFile { +struct BlobSource { object_store: Arc, path: Path, - reader: Arc>, + file_size: CachedFileSize, + scheduler: OnceCell, + pending_reads: Mutex, +} + +impl BlobSource { + /// Create a shared read context for one physical backing object. + fn new(object_store: Arc, path: Path) -> Self { + Self { + object_store, + path, + file_size: CachedFileSize::unknown(), + scheduler: OnceCell::new(), + pending_reads: Mutex::new(PendingBlobReads::default()), + } + } + + /// Read one or more physical ranges from this source. + /// + /// Concurrent callers enqueue their requests into `pending_reads`. The first + /// caller in a drain cycle becomes the leader and spawns the batch drain task. + /// The mutex critical section only updates in-memory queue bookkeeping; the + /// actual scheduler submission happens after the lock is released. + async fn read_ranges(self: &Arc, ranges: Vec>) -> Result> { + if ranges.is_empty() { + return Ok(Vec::new()); + } + + let scheduler = self + .scheduler + .get_or_try_init(|| async { + ScanScheduler::new( + self.object_store.clone(), + SchedulerConfig::max_bandwidth(self.object_store.as_ref()), + ) + .open_file(&self.path, &self.file_size) + .await + }) + .await?; + + let (response_tx, response_rx) = oneshot::channel(); + let should_spawn = { + let mut pending_reads = self.pending_reads.lock().await; + pending_reads.requests.push(PendingBlobRead { + ranges, + response: response_tx, + }); + if pending_reads.is_draining { + false + } else { + pending_reads.is_draining = true; + true + } + }; + + if should_spawn { + let source = self.clone(); + let scheduler = scheduler.clone(); + tokio::spawn(async move { + let result = AssertUnwindSafe(source.clone().drain_pending_reads(scheduler)) + .catch_unwind() + .await; + if let Err(panic) = result { + let mut pending_reads = source.pending_reads.lock().await; + pending_reads.is_draining = false; + std::panic::resume_unwind(panic); + } + }); + } + + response_rx.await.map_err(|_| { + Error::internal("Blob source read task dropped the response".to_string()) + })? + } + + /// Drain currently queued requests and submit them as scheduler batches. + /// + /// Each loop iteration grabs the queued requests with a short mutex hold and + /// immediately releases the lock before any I/O is awaited. + async fn drain_pending_reads(self: Arc, scheduler: FileScheduler) { + loop { + let batch = { + let mut pending_reads = self.pending_reads.lock().await; + if pending_reads.requests.is_empty() { + pending_reads.is_draining = false; + return; + } + std::mem::take(&mut pending_reads.requests) + }; + fulfill_pending_blob_reads(&scheduler, batch).await; + } + } +} + +/// Queue of pending logical blob reads for one [`BlobSource`]. +/// +/// `is_draining` marks whether a leader task is already draining the queue. +#[derive(Default, Debug)] +struct PendingBlobReads { + requests: Vec, + is_draining: bool, +} + +/// Pending logical blob reads waiting to be grouped into one scheduler batch. +/// +/// This queue exists only to combine overlapping concurrent calls. The actual +/// coalescing and physical I/O scheduling still happens in [`FileScheduler`]. +#[derive(Debug)] +struct PendingBlobRead { + ranges: Vec>, + response: oneshot::Sender>>, +} + +/// Submit one grouped batch of pending blob reads to Lance's [`FileScheduler`]. +/// +/// The function flattens all logical requests into one range list, preserves the +/// caller-visible order for each request, and fans the bytes back out after the +/// scheduler completes its own merge / split logic. +async fn fulfill_pending_blob_reads(scheduler: &FileScheduler, batch: Vec) { + let total_ranges = batch + .iter() + .map(|request| request.ranges.len()) + .sum::(); + let mut request_ranges = Vec::with_capacity(total_ranges); + let mut response = batch + .iter() + .map(|request| vec![Bytes::new(); request.ranges.len()]) + .collect::>(); + + for (request_idx, request) in batch.iter().enumerate() { + for (range_idx, range) in request.ranges.iter().enumerate() { + if range.is_empty() { + continue; + } + request_ranges.push((range.clone(), request_idx, range_idx)); + } + } + + let result = if request_ranges.is_empty() { + Ok(()) + } else { + request_ranges.sort_by_key(|(range, _, _)| (range.start, range.end)); + let priority = request_ranges[0].0.start; + match scheduler + .submit_request( + request_ranges + .iter() + .map(|(range, _, _)| range.clone()) + .collect::>(), + priority, + ) + .await + { + Ok(bytes_vec) => { + for ((_, request_idx, range_idx), bytes) in + request_ranges.into_iter().zip(bytes_vec) + { + response[request_idx][range_idx] = bytes; + } + Ok(()) + } + Err(err) => Err(err), + } + }; + + match result { + Ok(()) => { + for (request, bytes) in batch.into_iter().zip(response) { + let _ = request.response.send(Ok(bytes)); + } + } + Err(err) => { + let message = format!( + "Failed to read blob source {}: {}", + scheduler.reader().path(), + err + ); + for request in batch { + let _ = request.response.send(Err(Error::io(message.clone()))); + } + } + } +} + +/// Cache key for sharing one [`BlobSource`] across multiple blob descriptors. +/// +/// We include the store prefix as well as the path so the same path string in +/// different object stores is never conflated. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct BlobSourceKey { + store_prefix: String, + path: String, +} + +impl BlobSourceKey { + /// Build the cache key for one shared [`BlobSource`]. + fn new(source: &BlobSource) -> Self { + Self { + store_prefix: source.object_store.store_prefix.clone(), + path: source.path.to_string(), + } + } +} + +/// Return a shared [`BlobSource`] for the given physical object. +/// +/// This keeps all blob handles that resolve to the same `(store, path)` on a +/// single lazy-open scheduler and pending-read queue. +fn shared_blob_source( + source_cache: &mut HashMap>, + object_store: Arc, + path: &Path, +) -> Arc { + let key = BlobSourceKey { + store_prefix: object_store.store_prefix.clone(), + path: path.to_string(), + }; + source_cache + .entry(key) + .or_insert_with(|| Arc::new(BlobSource::new(object_store, path.clone()))) + .clone() +} + +/// A file-like object that represents a blob in a dataset +#[derive(Debug)] +pub struct BlobFile { + source: Arc, + state: Arc>, position: u64, size: u64, kind: BlobKind, uri: Option, } +/// Base-aware physical location metadata used while resolving blob reads. +/// +/// This is cached per fragment so repeated rows from the same fragment do not +/// recompute the object store, data directory, and data file key. #[derive(Clone)] struct BlobReadLocation { object_store: Arc, @@ -747,22 +991,20 @@ struct BlobReadLocation { } impl BlobFile { - fn with_location( - object_store: Arc, - path: Path, + fn with_source( + source: Arc, position: u64, size: u64, kind: BlobKind, uri: Option, ) -> Self { Self { - object_store, - path, + source, position, size, kind, uri, - reader: Arc::new(Mutex::new(ReaderState::Uninitialized(0))), + state: Arc::new(Mutex::new(BlobFileState::Open(0))), } } @@ -783,7 +1025,13 @@ impl BlobFile { position: u64, size: u64, ) -> Self { - Self::with_location(object_store, path, position, size, BlobKind::Inline, None) + Self::with_source( + Arc::new(BlobSource::new(object_store, path)), + position, + size, + BlobKind::Inline, + None, + ) } /// Create a dedicated blob reader backed by a sidecar `.blob` file. @@ -797,7 +1045,13 @@ impl BlobFile { /// * `path` - Full path to the dedicated sidecar blob file. /// * `size` - Total byte length to expose from the sidecar file. pub fn new_dedicated(object_store: Arc, path: Path, size: u64) -> Self { - Self::with_location(object_store, path, 0, size, BlobKind::Dedicated, None) + Self::with_source( + Arc::new(BlobSource::new(object_store, path)), + 0, + size, + BlobKind::Dedicated, + None, + ) } /// Create a packed blob reader for a slice inside a shared sidecar `.blob` file. @@ -817,7 +1071,13 @@ impl BlobFile { position: u64, size: u64, ) -> Self { - Self::with_location(object_store, path, position, size, BlobKind::Packed, None) + Self::with_source( + Arc::new(BlobSource::new(object_store, path)), + position, + size, + BlobKind::Packed, + None, + ) } /// Create an external blob reader backed by a caller-resolved object location. @@ -840,9 +1100,8 @@ impl BlobFile { position: u64, size: u64, ) -> Self { - Self::with_location( - object_store, - path, + Self::with_source( + Arc::new(BlobSource::new(object_store, path)), position, size, BlobKind::External, @@ -852,58 +1111,118 @@ impl BlobFile { /// Close the blob file, releasing any associated resources pub async fn close(&self) -> Result<()> { - let mut reader = self.reader.lock().await; - *reader = ReaderState::Closed; + let mut state = self.state.lock().await; + *state = BlobFileState::Closed; Ok(()) } /// Returns true if the blob file is closed pub async fn is_closed(&self) -> bool { - matches!(*self.reader.lock().await, ReaderState::Closed) + matches!(*self.state.lock().await, BlobFileState::Closed) } - async fn do_with_reader< - T, - Fut: Future>, - Func: FnOnce(u64, Arc) -> Fut, - >( + async fn do_with_cursor>, Func: FnOnce(u64) -> Fut>( &self, func: Func, ) -> Result { - let mut reader = self.reader.lock().await; - if let ReaderState::Uninitialized(cursor) = *reader { - let opened = self.object_store.open(&self.path).await?; - let opened = Arc::::from(opened); - *reader = ReaderState::Open((cursor, opened.clone())); - } - match reader.deref_mut() { - ReaderState::Open((cursor, reader)) => { - let (new_cursor, data) = func(*cursor, reader.clone()).await?; + let mut state = self.state.lock().await; + match state.deref_mut() { + BlobFileState::Open(cursor) => { + let (new_cursor, data) = func(*cursor).await?; *cursor = new_cursor; Ok(data) } - ReaderState::Closed => Err(Error::invalid_input( + BlobFileState::Closed => Err(Error::invalid_input( "Blob file is already closed".to_string(), )), - _ => unreachable!(), } } + async fn ensure_open(&self) -> Result<()> { + let state = self.state.lock().await; + match *state { + BlobFileState::Open(_) => Ok(()), + BlobFileState::Closed => Err(Error::invalid_input( + "Blob file is already closed".to_string(), + )), + } + } + + fn read_phys_range(&self, range: Range) -> Result> { + if range.start > range.end { + return Err(Error::invalid_input(format!( + "Blob range start {} must be <= end {}", + range.start, range.end + ))); + } + if range.end > self.size { + return Err(Error::invalid_input(format!( + "Blob range end {} exceeds blob size {}", + range.end, self.size + ))); + } + let start = self.position.checked_add(range.start).ok_or_else(|| { + Error::invalid_input(format!( + "Blob range start overflowed physical position: base={} offset={}", + self.position, range.start + )) + })?; + let end = self.position.checked_add(range.end).ok_or_else(|| { + Error::invalid_input(format!( + "Blob range end overflowed physical position: base={} offset={}", + self.position, range.end + )) + })?; + Ok(start..end) + } + + /// Read a byte range relative to the beginning of this blob without changing the cursor. + /// + /// The provided range is interpreted in blob-local coordinates, not object + /// coordinates. Empty ranges are allowed. This method is intended for random + /// access callers that want deterministic range semantics instead of the + /// stateful file-like cursor used by [`Self::read`] and [`Self::read_up_to`]. + pub async fn read_range(&self, range: Range) -> Result { + let mut data = self.read_ranges(&[range]).await?; + Ok(data.pop().unwrap_or_default()) + } + + /// Read multiple ranges relative to the beginning of this blob without changing the cursor. + /// + /// Empty ranges are allowed and yield empty buffers. The result order always + /// matches the input order, even though the underlying physical requests may + /// be reordered, coalesced, or split for efficiency. + pub async fn read_ranges(&self, ranges: &[Range]) -> Result> { + self.ensure_open().await?; + let physical_ranges = ranges + .iter() + .cloned() + .map(|range| self.read_phys_range(range)) + .collect::>>()?; + self.source.read_ranges(physical_ranges).await + } + /// Read the entire blob file from the current cursor position /// to the end of the file /// /// After this call the cursor will be pointing to the end of /// the file. pub async fn read(&self) -> Result { - let position = self.position; let size = self.size; - self.do_with_reader(|cursor, reader| async move { - if cursor >= size { - return Ok((size, bytes::Bytes::new())); + let source = self.source.clone(); + let position = self.position; + self.do_with_cursor(move |cursor| { + let source = source.clone(); + async move { + if cursor >= size { + return Ok((size, Bytes::new())); + } + let physical = (position + cursor)..(position + size); + Ok(( + size, + source.read_ranges(vec![physical]).await?.pop().unwrap(), + )) } - let start = position as usize + cursor as usize; - let end = (position + size) as usize; - Ok((size, reader.get_range(start..end).await?)) }) .await } @@ -913,48 +1232,47 @@ impl BlobFile { /// After this call the cursor will be pointing to the end of /// the read data. pub async fn read_up_to(&self, len: usize) -> Result { - let position = self.position; let size = self.size; - self.do_with_reader(|cursor, reader| async move { - if cursor >= size || len == 0 { - return Ok((size.min(cursor), bytes::Bytes::new())); + let source = self.source.clone(); + let position = self.position; + self.do_with_cursor(move |cursor| { + let source = source.clone(); + async move { + if cursor >= size || len == 0 { + return Ok((size.min(cursor), Bytes::new())); + } + let read_size = len.min((size - cursor) as usize) as u64; + let start = position + cursor; + let end = start + read_size; + let data = source.read_ranges(vec![start..end]).await?.pop().unwrap(); + Ok((cursor + read_size, data)) } - let start = position as usize + cursor as usize; - let read_size = len.min((size - cursor) as usize); - let end = start + read_size; - let data = reader.get_range(start..end).await?; - Ok((end as u64 - position, data)) }) .await } /// Seek to a new cursor position in the file pub async fn seek(&self, new_cursor: u64) -> Result<()> { - let mut reader = self.reader.lock().await; - match reader.deref_mut() { - ReaderState::Open((cursor, _)) => { + let mut state = self.state.lock().await; + match state.deref_mut() { + BlobFileState::Open(cursor) => { *cursor = new_cursor; Ok(()) } - ReaderState::Closed => Err(Error::invalid_input( + BlobFileState::Closed => Err(Error::invalid_input( "Blob file is already closed".to_string(), )), - ReaderState::Uninitialized(cursor) => { - *cursor = new_cursor; - Ok(()) - } } } /// Return the current cursor position in the file pub async fn tell(&self) -> Result { - let reader = self.reader.lock().await; - match *reader { - ReaderState::Open((cursor, _)) => Ok(cursor), - ReaderState::Closed => Err(Error::invalid_input( + let state = self.state.lock().await; + match *state { + BlobFileState::Open(cursor) => Ok(cursor), + BlobFileState::Closed => Err(Error::invalid_input( "Blob file is already closed".to_string(), )), - ReaderState::Uninitialized(cursor) => Ok(cursor), } } @@ -968,7 +1286,7 @@ impl BlobFile { } pub fn data_path(&self) -> &Path { - &self.path + &self.source.path } pub fn kind(&self) -> BlobKind { @@ -980,140 +1298,555 @@ impl BlobFile { } } -pub(super) async fn take_blobs( - dataset: &Arc, - row_ids: &[u64], - column: &str, -) -> Result> { - let projection = dataset.schema().project(&[column])?; - let blob_field = &projection.fields[0]; - let blob_field_id = blob_field.id; - if !projection.fields[0].is_blob() { - return Err(Error::invalid_input_source( - format!("the column '{}' is not a blob column", column).into(), - )); - } - let description_and_addr = dataset - .take_builder(row_ids, projection)? - .with_row_address(true) - .execute() - .await?; - let descriptions = description_and_addr.column(0).as_struct(); - let row_addrs = description_and_addr.column(1).as_primitive::(); - let blob_field_id = blob_field_id as u32; +/// Blob bytes materialized by [`ReadBlobsBuilder`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadBlob { + /// Row address of the blob that was read. + pub row_address: u64, + /// Blob payload bytes. + pub data: Bytes, +} - match blob_version_from_descriptions(descriptions)? { - BlobVersion::V1 => collect_blob_files_v1(dataset, blob_field_id, descriptions, row_addrs), - BlobVersion::V2 => { - collect_blob_files_v2(dataset, blob_field_id, descriptions, row_addrs).await +/// Stream returned by [`ReadBlobsBuilder::try_into_stream`]. +pub type ReadBlobsStream = BoxStream<'static, Result>; + +/// Row selector configured on [`ReadBlobsBuilder`]. +#[derive(Debug, Clone)] +enum ReadBlobsSelection { + None, + RowIds(Vec), + RowIndices(Vec), + RowAddresses(Vec), +} + +/// Planner knobs for [`ReadBlobsBuilder`]. +/// +/// Options that shape how `read_blobs` uses Lance's existing schedulers. +#[derive(Debug, Clone)] +struct ReadBlobsOptions { + io_buffer_size_bytes: Option, + preserve_order: bool, +} + +impl Default for ReadBlobsOptions { + fn default() -> Self { + Self { + io_buffer_size_bytes: None, + preserve_order: true, } } } -/// Take [BlobFile] by row addresses. +/// Builder for sequential / planned blob reads. /// -/// Row addresses are `u64` values encoding `(fragment_id << 32) | row_offset`. -/// Use this method when you already have row addresses, for example from -/// a scan with `with_row_address()`. For row IDs (stable identifiers), use -/// [`Dataset::take_blobs`]. For row indices (offsets), use -/// [`Dataset::take_blobs_by_indices`]. -pub async fn take_blobs_by_addresses( - dataset: &Arc, - row_addrs: &[u64], - column: &str, -) -> Result> { - let projection = dataset.schema().project(&[column])?; - let blob_field = &projection.fields[0]; - let blob_field_id = blob_field.id; - if !projection.fields[0].is_blob() { - return Err(Error::invalid_input_source( - format!("the column '{}' is not a blob column", column).into(), - )); +/// Unlike [`Dataset::take_blobs`], which returns [`BlobFile`] handles for +/// caller-driven random access, this builder plans object-store reads across a +/// selected row set and yields fully materialized blob payloads. +#[derive(Debug, Clone)] +pub struct ReadBlobsBuilder { + dataset: Arc, + column: String, + blob_field_id: u32, + selection: ReadBlobsSelection, + options: ReadBlobsOptions, +} + +impl ReadBlobsBuilder { + pub(crate) fn new(dataset: Arc, column: String, blob_field_id: u32) -> Self { + Self { + dataset, + column, + blob_field_id, + selection: ReadBlobsSelection::None, + options: ReadBlobsOptions::default(), + } } - // Convert Schema to ProjectionPlan - let projection_request = ProjectionRequest::from(projection); - let projection_plan = Arc::new(projection_request.into_projection_plan(dataset.clone())?); + /// Read blobs for the provided stable row ids. + pub fn with_row_ids(mut self, row_ids: impl Into>) -> Self { + self.selection = ReadBlobsSelection::RowIds(row_ids.into()); + self + } - // Use try_new_from_addresses to bypass row ID index lookup. - // This is critical when enable_stable_row_ids=true because row addresses - // (fragment_id << 32 | row_offset) are different from row IDs (sequential integers). - let description_and_addr = - TakeBuilder::try_new_from_addresses(dataset.clone(), row_addrs.to_vec(), projection_plan)? - .with_row_address(true) - .execute() - .await?; + /// Read blobs for the provided row offsets in dataset order. + pub fn with_row_indices(mut self, row_indices: impl Into>) -> Self { + self.selection = ReadBlobsSelection::RowIndices(row_indices.into()); + self + } - let descriptions = description_and_addr.column(0).as_struct(); - let row_addrs_result = description_and_addr.column(1).as_primitive::(); - let blob_field_id = blob_field_id as u32; + /// Read blobs for the provided physical row addresses. + pub fn with_row_addresses(mut self, row_addrs: impl Into>) -> Self { + self.selection = ReadBlobsSelection::RowAddresses(row_addrs.into()); + self + } - match blob_version_from_descriptions(descriptions)? { - BlobVersion::V1 => { - collect_blob_files_v1(dataset, blob_field_id, descriptions, row_addrs_result) + /// Set the scheduler I/O buffer size used while materializing blobs. + pub fn with_io_buffer_size_bytes(mut self, bytes: u64) -> Self { + self.options.io_buffer_size_bytes = Some(bytes); + self + } + + /// Whether results must follow the caller's requested row order. + pub fn preserve_order(mut self, preserve: bool) -> Self { + self.options.preserve_order = preserve; + self + } + + /// Execute the planned blob read and return a stream of blob payloads. + /// + /// The stream yields one [`ReadBlob`] per selected non-null blob row. + pub async fn try_into_stream(self) -> Result { + self.validate()?; + let entries = collect_blob_entries_for_selection( + &self.dataset, + self.blob_field_id, + &self.column, + &self.selection, + ) + .await?; + let expected_selection_indices = entries + .iter() + .map(|entry| entry.selection_index) + .collect::>(); + let plans = plan_blob_read_plans(entries); + let execution = Arc::new(ReadBlobsExecution::new(self.options.io_buffer_size_bytes)); + if plans.is_empty() { + return Ok(stream::empty().boxed()); } - BlobVersion::V2 => { - collect_blob_files_v2(dataset, blob_field_id, descriptions, row_addrs_result).await + + let plan_stream = stream::iter(plans.into_iter().map(move |plan| { + let execution = execution.clone(); + execute_blob_read_plan(plan, execution) + })) + .buffer_unordered(self.dataset.object_store.io_parallelism().max(1)); + + if !self.options.preserve_order { + return Ok(plan_stream + .map_ok(|blobs| { + stream::iter(blobs.into_iter().map(|blob| Ok(into_read_blob(blob)))) + }) + .try_flatten() + .boxed()); } + + let mut plan_stream = plan_stream.boxed(); + let mut expected_selection_indices = expected_selection_indices; + let mut ready = BTreeMap::::new(); + + Ok(stream::poll_fn(move |cx| { + loop { + let Some(next_selection_index) = expected_selection_indices.front().copied() else { + return Poll::Ready(None); + }; + + if let Some(blob) = ready.remove(&next_selection_index) { + expected_selection_indices.pop_front(); + return Poll::Ready(Some(Ok(blob))); + } + + match plan_stream.poll_next_unpin(cx) { + Poll::Ready(Some(Ok(blobs))) => { + for blob in blobs { + ready.insert(blob.selection_index, into_read_blob(blob)); + } + } + Poll::Ready(Some(Err(err))) => { + return Poll::Ready(Some(Err(err))); + } + Poll::Ready(None) => { + let err = Error::internal(format!( + "planned blob read stream completed before selection index {} was produced", + next_selection_index + )); + return Poll::Ready(Some(Err(err))); + } + Poll::Pending => return Poll::Pending, + } + } + }) + .boxed()) } -} -fn blob_version_from_descriptions(descriptions: &StructArray) -> Result { - let fields = descriptions.fields(); - if fields.len() == 2 && fields[0].name() == "position" && fields[1].name() == "size" { - return Ok(BlobVersion::V1); + /// Execute the planned blob read and collect the full result in memory. + pub async fn execute(self) -> Result> { + self.try_into_stream().await?.try_collect().await } - if fields.len() == 5 - && fields[0].name() == "kind" - && fields[1].name() == "position" - && fields[2].name() == "size" - && fields[3].name() == "blob_id" - && fields[4].name() == "blob_uri" - { - return Ok(BlobVersion::V2); + + fn validate(&self) -> Result<()> { + match self.selection { + ReadBlobsSelection::None => Err(Error::invalid_input( + "ReadBlobsBuilder requires a row selection; call one of with_row_ids, with_row_indices, or with_row_addresses".to_string(), + )), + _ if self.options.io_buffer_size_bytes == Some(0) => Err(Error::invalid_input( + "ReadBlobsBuilder io_buffer_size must be greater than 0".to_string(), + )), + _ => Ok(()), + } } - Err(Error::invalid_input_source(format!( - "Unrecognized blob descriptions schema: expected v1 (position,size) or v2 (kind,position,size,blob_id,blob_uri) but got {:?}", - fields.iter().map(|f| f.name().as_str()).collect::>(), - ) - .into())) } -fn collect_blob_files_v1( - dataset: &Arc, - blob_field_id: u32, - descriptions: &StructArray, - row_addrs: &arrow::array::PrimitiveArray, -) -> Result> { - let positions = descriptions.column(0).as_primitive::(); - let sizes = descriptions.column(1).as_primitive::(); +/// One logical blob selected for planned reading. +#[derive(Debug)] +struct BlobEntry { + selection_index: usize, + row_address: u64, + file: BlobFile, +} - Ok(row_addrs - .values() +/// Physical read input derived from one [`BlobEntry`]. +#[derive(Debug)] +struct PlannedBlobRead { + selection_index: usize, + row_address: u64, + physical_range: Range, +} + +/// One per-source read plan emitted by `read_blobs`. +#[derive(Debug)] +struct BlobReadPlan { + source_key: BlobSourceKey, + source: Arc, + reads: Vec, +} + +/// Operation-scoped scheduler cache for one [`ReadBlobsBuilder`] execution. +/// +/// We reuse one [`ScanScheduler`] per object store during a single `read_blobs` +/// operation and still submit exactly one request per physical file. +#[derive(Debug)] +struct ReadBlobsExecution { + io_buffer_size_bytes: Option, + schedulers: std::sync::Mutex>>, +} + +impl ReadBlobsExecution { + fn new(io_buffer_size_bytes: Option) -> Self { + Self { + io_buffer_size_bytes, + schedulers: std::sync::Mutex::new(HashMap::new()), + } + } + + fn scheduler_for(&self, source: &BlobSource) -> Arc { + let mut schedulers = self.schedulers.lock().unwrap(); + schedulers + .entry(source.object_store.store_prefix.clone()) + .or_insert_with(|| { + let config = self + .io_buffer_size_bytes + .map(SchedulerConfig::new) + .unwrap_or_else(|| { + SchedulerConfig::max_bandwidth(source.object_store.as_ref()) + }); + ScanScheduler::new(source.object_store.clone(), config) + }) + .clone() + } +} + +/// Materialized blob bytes plus the original selection index used to restore +/// caller ordering after per-source reads complete. +#[derive(Debug)] +struct IndexedReadBlob { + selection_index: usize, + row_address: u64, + data: Bytes, +} + +fn into_read_blob(blob: IndexedReadBlob) -> ReadBlob { + ReadBlob { + row_address: blob.row_address, + data: blob.data, + } +} + +/// Group selected blobs by physical source and sort each group's ranges by +/// physical offset before handing them to the file scheduler. +fn plan_blob_read_plans(entries: Vec) -> Vec { + let mut plan_indices = HashMap::::new(); + let mut plans = Vec::::new(); + + for entry in entries { + let source_key = BlobSourceKey::new(&entry.file.source); + let plan_index = if let Some(plan_index) = plan_indices.get(&source_key) { + *plan_index + } else { + let plan_index = plans.len(); + plans.push(BlobReadPlan { + source_key: source_key.clone(), + source: entry.file.source.clone(), + reads: Vec::new(), + }); + plan_indices.insert(source_key.clone(), plan_index); + plan_index + }; + + plans[plan_index].reads.push(PlannedBlobRead { + selection_index: entry.selection_index, + row_address: entry.row_address, + physical_range: entry.file.position..(entry.file.position + entry.file.size), + }); + } + + plans.sort_by(|left, right| { + left.source_key + .store_prefix + .cmp(&right.source_key.store_prefix) + .then_with(|| left.source_key.path.cmp(&right.source_key.path)) + }); + + for plan in &mut plans { + plan.reads.sort_by(|left, right| { + left.physical_range + .start + .cmp(&right.physical_range.start) + .then_with(|| left.physical_range.end.cmp(&right.physical_range.end)) + .then_with(|| left.selection_index.cmp(&right.selection_index)) + }); + } + + plans +} + +/// Execute one per-source blob read plan with a single scheduler submission. +async fn execute_blob_read_plan( + task: BlobReadPlan, + execution: Arc, +) -> Result> { + let ranges = task + .reads + .iter() + .map(|read| read.physical_range.clone()) + .collect::>(); + let scheduler = execution.scheduler_for(&task.source); + let file_scheduler = scheduler + .open_file(&task.source.path, &task.source.file_size) + .await?; + let priority = ranges[0].start; + let bytes = file_scheduler.submit_request(ranges, priority).await?; + + Ok(task + .reads + .into_iter() + .zip(bytes) + .map(|(read, data)| IndexedReadBlob { + selection_index: read.selection_index, + row_address: read.row_address, + data, + }) + .collect()) +} + +pub(super) async fn take_blobs( + dataset: &Arc, + row_ids: &[u64], + column: &str, +) -> Result> { + let blob_field_id = validate_blob_column(dataset, column)?; + Ok(collect_blob_entries_for_selection( + dataset, + blob_field_id, + column, + &ReadBlobsSelection::RowIds(row_ids.to_vec()), + ) + .await? + .into_iter() + .map(|entry| entry.file) + .collect()) +} + +/// Take [BlobFile] by row addresses. +/// +/// Row addresses are `u64` values encoding `(fragment_id << 32) | row_offset`. +/// Use this method when you already have row addresses, for example from +/// a scan with `with_row_address()`. For row IDs (stable identifiers), use +/// [`Dataset::take_blobs`]. For row indices (offsets), use +/// [`Dataset::take_blobs_by_indices`]. +pub async fn take_blobs_by_addresses( + dataset: &Arc, + row_addrs: &[u64], + column: &str, +) -> Result> { + let blob_field_id = validate_blob_column(dataset, column)?; + Ok(collect_blob_entries_for_selection( + dataset, + blob_field_id, + column, + &ReadBlobsSelection::RowAddresses(row_addrs.to_vec()), + ) + .await? + .into_iter() + .map(|entry| entry.file) + .collect()) +} + +/// Validate that `column` exists and is a blob column, returning its field id. +pub(super) fn validate_blob_column(dataset: &Arc, column: &str) -> Result { + let projection = dataset.schema().project(&[column])?; + let blob_field = &projection.fields[0]; + if !blob_field.is_blob() { + return Err(Error::invalid_input_source( + format!("the column '{}' is not a blob column", column).into(), + )); + } + Ok(blob_field.id as u32) +} + +/// Load blob descriptor rows for a stable-row-id selection. +async fn take_blob_descriptions_by_row_ids( + dataset: &Arc, + row_ids: &[u64], + column: &str, +) -> Result { + let projection = dataset.schema().project(&[column])?; + dataset + .take_builder(row_ids, projection)? + .with_row_address(true) + .execute() + .await +} + +/// Load blob descriptor rows for a physical-row-address selection. +async fn take_blob_descriptions_by_row_addresses( + dataset: &Arc, + row_addrs: &[u64], + column: &str, +) -> Result { + let projection = dataset.schema().project(&[column])?; + let projection_request = ProjectionRequest::from(projection); + let projection_plan = Arc::new(projection_request.into_projection_plan(dataset.clone())?); + TakeBuilder::try_new_from_addresses(dataset.clone(), row_addrs.to_vec(), projection_plan)? + .with_row_address(true) + .execute() + .await +} + +/// Resolve a caller selection into [`BlobEntry`] values that share `BlobSource` +/// instances by physical backing object. +async fn collect_blob_entries_for_selection( + dataset: &Arc, + blob_field_id: u32, + column: &str, + selection: &ReadBlobsSelection, +) -> Result> { + let description_and_addr = match selection { + ReadBlobsSelection::None => { + return Err(Error::invalid_input( + "Blob row selection is required".to_string(), + )); + } + ReadBlobsSelection::RowIds(row_ids) => { + take_blob_descriptions_by_row_ids(dataset, row_ids, column).await? + } + ReadBlobsSelection::RowIndices(row_indices) => { + let row_addrs = + super::take::row_offsets_to_row_addresses(&dataset.get_fragments(), row_indices) + .await?; + take_blob_descriptions_by_row_addresses(dataset, &row_addrs, column).await? + } + ReadBlobsSelection::RowAddresses(row_addrs) => { + take_blob_descriptions_by_row_addresses(dataset, row_addrs, column).await? + } + }; + + if description_and_addr.num_rows() == 0 { + return Ok(Vec::new()); + } + + let descriptions = description_and_addr.column(0).as_struct(); + let row_addrs = description_and_addr.column(1).as_primitive::(); + + match blob_version_from_descriptions(descriptions)? { + BlobVersion::V1 => collect_blob_entries_v1(dataset, blob_field_id, descriptions, row_addrs), + BlobVersion::V2 => { + collect_blob_entries_v2(dataset, blob_field_id, descriptions, row_addrs).await + } + } +} + +fn blob_version_from_descriptions(descriptions: &StructArray) -> Result { + let fields = descriptions.fields(); + if fields.len() == 2 && fields[0].name() == "position" && fields[1].name() == "size" { + return Ok(BlobVersion::V1); + } + if fields.len() == 5 + && fields[0].name() == "kind" + && fields[1].name() == "position" + && fields[2].name() == "size" + && fields[3].name() == "blob_id" + && fields[4].name() == "blob_uri" + { + return Ok(BlobVersion::V2); + } + Err(Error::invalid_input_source(format!( + "Unrecognized blob descriptions schema: expected v1 (position,size) or v2 (kind,position,size,blob_id,blob_uri) but got {:?}", + fields.iter().map(|f| f.name().as_str()).collect::>(), + ) + .into())) +} + +/// Convert blob v1 descriptors into logical blob entries. +fn collect_blob_entries_v1( + dataset: &Arc, + blob_field_id: u32, + descriptions: &StructArray, + row_addrs: &arrow::array::PrimitiveArray, +) -> Result> { + let positions = descriptions.column(0).as_primitive::(); + let sizes = descriptions.column(1).as_primitive::(); + let mut source_cache = HashMap::>::new(); + row_addrs + .values() .iter() .zip(positions.iter()) .zip(sizes.iter()) - .filter_map(|((row_addr, position), size)| { + .enumerate() + .filter_map(|(selection_index, ((row_addr, position), size))| { let position = position?; let size = size?; - Some((*row_addr, position, size)) + Some((selection_index, *row_addr, position, size)) }) - .map(|(row_addr, position, size)| { + .map(|(selection_index, row_addr, position, size)| { let frag_id = RowAddress::from(row_addr).fragment_id(); - let frag = dataset.get_fragment(frag_id as usize).unwrap(); - let data_file = frag.data_file_for_field(blob_field_id).unwrap(); + let frag = dataset.get_fragment(frag_id as usize).ok_or_else(|| { + Error::invalid_input(format!( + "Blob row address {} references missing fragment {}", + row_addr, frag_id + )) + })?; + let data_file = frag.data_file_for_field(blob_field_id).ok_or_else(|| { + Error::invalid_input(format!( + "Blob field {} has no data file in fragment {} for row address {}", + blob_field_id, frag_id, row_addr + )) + })?; let data_file_path = dataset.data_dir().child(data_file.path.as_str()); - BlobFile::new_inline(dataset.object_store.clone(), data_file_path, position, size) + Ok(BlobEntry { + selection_index, + row_address: row_addr, + file: BlobFile::with_source( + shared_blob_source( + &mut source_cache, + dataset.object_store.clone(), + &data_file_path, + ), + position, + size, + BlobKind::Inline, + None, + ), + }) }) - .collect()) + .collect() } -async fn collect_blob_files_v2( +/// Convert blob v2 descriptors into logical blob entries. +async fn collect_blob_entries_v2( dataset: &Arc, blob_field_id: u32, descriptions: &StructArray, row_addrs: &arrow::array::PrimitiveArray, -) -> Result> { +) -> Result> { let kinds = descriptions.column(0).as_primitive::(); let positions = descriptions.column(1).as_primitive::(); let sizes = descriptions.column(2).as_primitive::(); @@ -1124,7 +1857,9 @@ async fn collect_blob_files_v2( let mut fragment_cache = HashMap::::new(); let mut store_cache = HashMap::>::new(); let mut external_base_path_cache = HashMap::::new(); - for (idx, row_addr) in row_addrs.values().iter().enumerate() { + let mut source_cache = HashMap::>::new(); + for (selection_index, row_addr) in row_addrs.values().iter().enumerate() { + let idx = selection_index; let kind = BlobKind::try_from(kinds.value(idx))?; // Struct is non-nullable; null rows are encoded as inline with zero position/size and empty uri @@ -1144,12 +1879,16 @@ async fn collect_blob_files_v2( &mut store_cache, ) .await?; - files.push(BlobFile::new_inline( + let source = shared_blob_source( + &mut source_cache, location.object_store, - location.data_file_path, - position, - size, - )); + &location.data_file_path, + ); + files.push(BlobEntry { + selection_index, + row_address: *row_addr, + file: BlobFile::with_source(source, position, size, BlobKind::Inline, None), + }); } BlobKind::Dedicated => { let blob_id = blob_ids.value(idx); @@ -1163,7 +1902,12 @@ async fn collect_blob_files_v2( ) .await?; let path = blob_path(&location.data_file_dir, &location.data_file_key, blob_id); - files.push(BlobFile::new_dedicated(location.object_store, path, size)); + let source = shared_blob_source(&mut source_cache, location.object_store, &path); + files.push(BlobEntry { + selection_index, + row_address: *row_addr, + file: BlobFile::with_source(source, 0, size, BlobKind::Dedicated, None), + }); } BlobKind::Packed => { let blob_id = blob_ids.value(idx); @@ -1178,12 +1922,12 @@ async fn collect_blob_files_v2( ) .await?; let path = blob_path(&location.data_file_dir, &location.data_file_key, blob_id); - files.push(BlobFile::new_packed( - location.object_store, - path, - position, - size, - )); + let source = shared_blob_source(&mut source_cache, location.object_store, &path); + files.push(BlobEntry { + selection_index, + row_address: *row_addr, + file: BlobFile::with_source(source, position, size, BlobKind::Packed, None), + }); } BlobKind::External => { let uri_or_path = blob_uris.value(idx).to_string(); @@ -1227,13 +1971,18 @@ async fn collect_blob_files_v2( } else { object_store.size(&path).await? }; - files.push(BlobFile::new_external( - object_store, - path, - uri_or_path, - position, - size, - )); + let source = shared_blob_source(&mut source_cache, object_store, &path); + files.push(BlobEntry { + selection_index, + row_address: *row_addr, + file: BlobFile::with_source( + source, + position, + size, + BlobKind::External, + Some(uri_or_path), + ), + }); } } } @@ -1324,7 +2073,9 @@ fn data_file_key_from_path(path: &str) -> &str { #[cfg(test)] mod tests { use std::collections::HashMap; + use std::ops::Range; use std::sync::Arc; + use std::time::Duration; use arrow::{ array::AsArray, @@ -1336,7 +2087,9 @@ mod tests { }; use arrow_schema::{DataType, Field, Schema}; use async_trait::async_trait; - use futures::TryStreamExt; + use bytes::Bytes; + use chrono::Utc; + use futures::{StreamExt, TryStreamExt, future::try_join_all}; use lance_arrow::{ ARROW_EXT_NAME_KEY, BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY, BLOB_V2_EXT_NAME, DataTypeExt, }; @@ -1347,9 +2100,10 @@ mod tests { use lance_io::stream::RecordBatchStream; use lance_table::format::BasePath; use object_store::{ - GetOptions, GetRange, GetResult, ListResult, MultipartUpload, ObjectMeta, - PutMultipartOptions, PutOptions, PutPayload, PutResult, path::Path, + Attributes, GetOptions, GetRange, GetResult, GetResultPayload, ListResult, MultipartUpload, + ObjectMeta, PutMultipartOptions, PutOptions, PutPayload, PutResult, path::Path, }; + use tokio::sync::Notify; use url::Url; use lance_core::{ @@ -1359,7 +2113,11 @@ mod tests { use lance_datagen::{BatchCount, RowCount, array}; use lance_file::version::LanceFileVersion; - use super::{BlobFile, ExternalBaseCandidate, ExternalBaseResolver, data_file_key_from_path}; + use super::{ + BlobEntry, BlobFile, BlobSource, ExternalBaseCandidate, ExternalBaseResolver, + ReadBlobsExecution, collect_blob_entries_v1, data_file_key_from_path, + execute_blob_read_plan, plan_blob_read_plans, + }; use crate::{ Dataset, blob::{BlobArrayBuilder, blob_field}, @@ -1569,6 +2327,205 @@ mod tests { )) } + #[derive(Debug)] + struct RecordingRangeObjectStore { + data: Bytes, + gate: Option>, + requested_ranges: std::sync::Mutex>>, + } + + impl RecordingRangeObjectStore { + fn new(data: Bytes) -> Self { + Self { + data, + gate: None, + requested_ranges: std::sync::Mutex::new(Vec::new()), + } + } + + fn with_gate(data: Bytes, gate: Arc) -> Self { + Self { + data, + gate: Some(gate), + requested_ranges: std::sync::Mutex::new(Vec::new()), + } + } + + fn requested_ranges(&self) -> Vec> { + self.requested_ranges.lock().unwrap().clone() + } + + fn object_meta(&self, location: &Path) -> ObjectMeta { + ObjectMeta { + location: location.clone(), + last_modified: Utc::now(), + size: self.data.len() as u64, + e_tag: None, + version: None, + } + } + } + + impl std::fmt::Display for RecordingRangeObjectStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RecordingRangeObjectStore") + } + } + + #[async_trait] + impl object_store::ObjectStore for RecordingRangeObjectStore { + async fn put( + &self, + _location: &Path, + _bytes: PutPayload, + ) -> object_store::Result { + unimplemented!("put is not used by these tests") + } + + async fn put_opts( + &self, + _location: &Path, + _bytes: PutPayload, + _opts: PutOptions, + ) -> object_store::Result { + unimplemented!("put_opts is not used by these tests") + } + + async fn put_multipart( + &self, + _location: &Path, + ) -> object_store::Result> { + unimplemented!("put_multipart is not used by these tests") + } + + async fn put_multipart_opts( + &self, + _location: &Path, + _opts: PutMultipartOptions, + ) -> object_store::Result> { + unimplemented!("put_multipart_opts is not used by these tests") + } + + async fn get(&self, location: &Path) -> object_store::Result { + self.get_opts(location, GetOptions::default()).await + } + + async fn get_opts( + &self, + location: &Path, + options: GetOptions, + ) -> object_store::Result { + let range = match options.range { + Some(GetRange::Bounded(range)) => range, + None => 0..self.data.len() as u64, + Some(other) => { + return Err(object_store::Error::NotSupported { + source: format!("unsupported range request {other:?}").into(), + }); + } + }; + if let Some(gate) = &self.gate { + gate.notified().await; + } + self.requested_ranges.lock().unwrap().push(range.clone()); + let bytes = self.data.slice(range.start as usize..range.end as usize); + Ok(GetResult { + payload: GetResultPayload::Stream( + futures::stream::once(async move { Ok(bytes) }).boxed(), + ), + meta: self.object_meta(location), + range, + attributes: Attributes::default(), + }) + } + + async fn head(&self, location: &Path) -> object_store::Result { + Ok(self.object_meta(location)) + } + + async fn delete(&self, _location: &Path) -> object_store::Result<()> { + unimplemented!("delete is not used by these tests") + } + + fn list( + &self, + _prefix: Option<&Path>, + ) -> futures::stream::BoxStream<'static, object_store::Result> { + unimplemented!("list is not used by these tests") + } + + async fn list_with_delimiter( + &self, + _prefix: Option<&Path>, + ) -> object_store::Result { + unimplemented!("list_with_delimiter is not used by these tests") + } + + async fn copy(&self, _from: &Path, _to: &Path) -> object_store::Result<()> { + unimplemented!("copy is not used by these tests") + } + + async fn copy_if_not_exists(&self, _from: &Path, _to: &Path) -> object_store::Result<()> { + unimplemented!("copy_if_not_exists is not used by these tests") + } + } + + fn recording_range_store_with_url( + data: Bytes, + url: &str, + ) -> (Arc, Arc) { + const TEST_RANGE_STORE_SIZE: usize = 128 * 1024; + let mut padded = vec![0; TEST_RANGE_STORE_SIZE.max(data.len())]; + padded[..data.len()].copy_from_slice(data.as_ref()); + let inner = Arc::new(RecordingRangeObjectStore::new(Bytes::from(padded))); + let store = Arc::new(ObjectStore::new( + inner.clone() as Arc, + Url::parse(url).unwrap(), + None, + None, + false, + true, + lance_io::object_store::DEFAULT_LOCAL_IO_PARALLELISM, + lance_io::object_store::DEFAULT_DOWNLOAD_RETRY_COUNT, + None, + )); + (store, inner) + } + + fn recording_range_store(data: Bytes) -> (Arc, Arc) { + recording_range_store_with_url(data, "mock://recording/blob-range-tests") + } + + fn gated_range_store( + data: Bytes, + url: &str, + ) -> ( + Arc, + Arc, + Arc, + ) { + const TEST_RANGE_STORE_SIZE: usize = 128 * 1024; + let mut padded = vec![0; TEST_RANGE_STORE_SIZE.max(data.len())]; + padded[..data.len()].copy_from_slice(data.as_ref()); + let gate = Arc::new(Notify::new()); + let inner = Arc::new(RecordingRangeObjectStore::with_gate( + Bytes::from(padded), + gate.clone(), + )); + let store = Arc::new(ObjectStore::new( + inner.clone() as Arc, + Url::parse(url).unwrap(), + None, + None, + false, + true, + lance_io::object_store::DEFAULT_LOCAL_IO_PARALLELISM, + lance_io::object_store::DEFAULT_DOWNLOAD_RETRY_COUNT, + None, + )); + (store, inner, gate) + } + impl BlobTestFixture { async fn new() -> Self { let test_dir = TempStrDir::default(); @@ -1698,6 +2655,46 @@ mod tests { } } + #[tokio::test] + async fn test_read_blobs_requires_selection() { + let fixture = BlobTestFixture::new().await; + + let err = fixture.dataset.read_blobs("blobs").unwrap().execute().await; + + assert!(matches!(err, Err(Error::InvalidInput { .. }))); + assert!( + err.unwrap_err() + .to_string() + .contains("requires a row selection") + ); + } + + #[tokio::test] + async fn test_read_blobs_by_indices_execute() { + let fixture = BlobTestFixture::new().await; + let indices = vec![2, 12, 22]; + + let blobs = fixture + .dataset + .read_blobs("blobs") + .unwrap() + .with_row_indices(indices) + .execute() + .await + .unwrap(); + + assert_eq!(blobs.len(), 3); + for (actual_idx, (expected_batch_idx, expected_row_idx)) in + [(0, 2), (1, 2), (2, 2)].iter().enumerate() + { + let expected = fixture.data[*expected_batch_idx] + .column(1) + .as_binary::() + .value(*expected_row_idx); + assert_eq!(blobs[actual_idx].data.as_ref(), expected); + } + } + #[tokio::test] pub async fn test_take_blobs_by_indices() { let fixture = BlobTestFixture::new().await; @@ -1744,6 +2741,30 @@ mod tests { assert!(matches!(err, Err(Error::InvalidInput { .. }))); } + #[tokio::test] + async fn test_collect_blob_entries_v1_rejects_missing_fragment() { + let fixture = BlobTestFixture::new().await; + let blob_field_id = + fixture.dataset.schema().project(&["blobs"]).unwrap().fields[0].id as u32; + let descriptions = StructArray::from(vec![ + ( + Arc::new(Field::new("position", DataType::UInt64, false)), + Arc::new(UInt64Array::from(vec![1])) as ArrayRef, + ), + ( + Arc::new(Field::new("size", DataType::UInt64, false)), + Arc::new(UInt64Array::from(vec![3])) as ArrayRef, + ), + ]); + let row_addrs = UInt64Array::from(vec![(999_u64 << 32) | 7]); + + let err = + collect_blob_entries_v1(&fixture.dataset, blob_field_id, &descriptions, &row_addrs) + .unwrap_err(); + + assert!(err.to_string().contains("references missing fragment")); + } + #[tokio::test] pub async fn test_take_blob_not_blob_col() { let fixture = BlobTestFixture::new().await; @@ -1959,6 +2980,158 @@ mod tests { assert_eq!(blob.tell().await.unwrap(), 2); } + #[tokio::test] + async fn test_blob_file_read_range_does_not_change_cursor() { + let (store, _) = recording_range_store(Bytes::from_static(b"abcdefgh")); + let path = Path::from("blobs/test.bin"); + let blob = BlobFile::new_packed(store, path, 1, 6); + + let bytes = blob.read_range(2..5).await.unwrap(); + assert_eq!(bytes.as_ref(), b"def"); + assert_eq!(blob.tell().await.unwrap(), 0); + } + + #[tokio::test] + async fn test_blob_file_read_ranges_preserves_input_order() { + let (store, inner) = recording_range_store(Bytes::from_static(b"abcdefghij")); + let path = Path::from("blobs/test.bin"); + let blob = BlobFile::new_packed(store, path, 1, 6); + + let chunks = blob.read_ranges(&[4..6, 0..2, 2..4, 2..2]).await.unwrap(); + assert_eq!(chunks[0].as_ref(), b"fg"); + assert_eq!(chunks[1].as_ref(), b"bc"); + assert_eq!(chunks[2].as_ref(), b"de"); + assert!(chunks[3].is_empty()); + assert_eq!(inner.requested_ranges(), vec![1..7]); + } + + #[tokio::test] + async fn test_blob_file_read_range_rejects_out_of_bounds() { + let (store, _) = recording_range_store(Bytes::from_static(b"abcdef")); + let path = Path::from("blobs/test.bin"); + let blob = BlobFile::new_packed(store, path, 0, 4); + + let err = blob.read_range(1..5).await.unwrap_err(); + assert!(err.to_string().contains("exceeds blob size")); + } + + #[tokio::test] + async fn test_blob_files_share_source_and_coalesce() { + let (store, inner) = recording_range_store(Bytes::from_static(b"abcdefghij")); + let source = Arc::new(BlobSource::new(store, Path::from("blobs/test.bin"))); + let blob1 = BlobFile::with_source(source.clone(), 1, 3, BlobKind::Packed, None); + let blob2 = BlobFile::with_source(source, 4, 3, BlobKind::Packed, None); + + let (data1, data2) = tokio::join!(blob1.read(), blob2.read()); + assert_eq!(data1.unwrap().as_ref(), b"bcd"); + assert_eq!(data2.unwrap().as_ref(), b"efg"); + assert_eq!(inner.requested_ranges(), vec![1..7]); + } + + #[tokio::test] + async fn test_read_blobs_plan_preserves_order_and_coalesces() { + let (store, inner) = recording_range_store(Bytes::from_static(b"abcdefghij")); + let source = Arc::new(BlobSource::new(store, Path::from("blobs/test.bin"))); + let entries = vec![ + BlobEntry { + selection_index: 0, + row_address: 10, + file: BlobFile::with_source(source.clone(), 4, 3, BlobKind::Packed, None), + }, + BlobEntry { + selection_index: 1, + row_address: 11, + file: BlobFile::with_source(source, 1, 3, BlobKind::Packed, None), + }, + ]; + let execution = Arc::new(ReadBlobsExecution::new(None)); + let blobs = try_join_all( + plan_blob_read_plans(entries) + .into_iter() + .map(|plan| execute_blob_read_plan(plan, execution.clone())), + ) + .await + .unwrap(); + let mut blobs = blobs.into_iter().flatten().collect::>(); + blobs.sort_by_key(|blob| blob.selection_index); + + assert_eq!(blobs.len(), 2); + assert_eq!(blobs[0].row_address, 10); + assert_eq!(blobs[0].data.as_ref(), b"efg"); + assert_eq!(blobs[1].row_address, 11); + assert_eq!(blobs[1].data.as_ref(), b"bcd"); + assert_eq!(inner.requested_ranges(), vec![1..7]); + } + + #[tokio::test] + async fn test_read_blobs_stream_emits_ready_plan_without_waiting_for_slower_ones() { + let (slow_store, _, slow_gate) = gated_range_store( + Bytes::from_static(b"abcdef"), + "mock://slow/blob-range-tests", + ); + let (fast_store, _) = recording_range_store_with_url( + Bytes::from_static(b"uvwxyz"), + "mock://fast/blob-range-tests", + ); + let entries = vec![ + BlobEntry { + selection_index: 0, + row_address: 10, + file: BlobFile::with_source( + Arc::new(BlobSource::new(slow_store, Path::from("blobs/slow.bin"))), + 0, + 3, + BlobKind::Packed, + None, + ), + }, + BlobEntry { + selection_index: 1, + row_address: 11, + file: BlobFile::with_source( + Arc::new(BlobSource::new(fast_store, Path::from("blobs/fast.bin"))), + 0, + 3, + BlobKind::Packed, + None, + ), + }, + ]; + let execution = Arc::new(ReadBlobsExecution::new(None)); + let mut stream: super::ReadBlobsStream = futures::stream::iter( + plan_blob_read_plans(entries) + .into_iter() + .map(move |plan| execute_blob_read_plan(plan, execution.clone())), + ) + .buffer_unordered(2) + .map_ok(|blobs: Vec| { + futures::stream::iter( + blobs + .into_iter() + .map(|blob| Ok::(super::into_read_blob(blob))), + ) + }) + .try_flatten() + .boxed(); + + let first = tokio::time::timeout(Duration::from_secs(1), stream.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(first.row_address, 11); + assert_eq!(first.data.as_ref(), b"uvw"); + + slow_gate.notify_one(); + let second = tokio::time::timeout(Duration::from_secs(1), stream.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(second.row_address, 10); + assert_eq!(second.data.as_ref(), b"abc"); + } + #[tokio::test] async fn test_take_blob_v2_from_non_default_base_inline() { let fixture = create_multi_base_blob_v2_fixture(b"inline".to_vec(), None, true).await; diff --git a/rust/lance/src/dataset/take.rs b/rust/lance/src/dataset/take.rs index 83ee7f7216a..f304f732978 100644 --- a/rust/lance/src/dataset/take.rs +++ b/rust/lance/src/dataset/take.rs @@ -373,14 +373,18 @@ async fn do_take_rows( AddRowOffsetExec::compute_row_offset_array(&row_addr_col, builder.dataset).await?; let row_offset_field = ArrowField::new(ROW_OFFSET, arrow::datatypes::DataType::UInt64, false); - batch = batch.try_with_column(row_offset_field, row_offset_col)?; + if batch.schema().column_with_name(ROW_OFFSET).is_none() { + batch = batch.try_with_column(row_offset_field, row_offset_col)?; + } } if builder.with_row_address { // inject `ROW_ADDR` column let row_addr_field = ArrowField::new(ROW_ADDR, arrow::datatypes::DataType::UInt64, false); - batch = batch.try_with_column(row_addr_field, row_addr_col)?; + if batch.schema().column_with_name(ROW_ADDR).is_none() { + batch = batch.try_with_column(row_addr_field, row_addr_col)?; + } } } From c913ff8f312b0d560f8ce8387fb55f21d09f2dfc Mon Sep 17 00:00:00 2001 From: Justin Miller Date: Thu, 16 Apr 2026 03:57:01 -0700 Subject: [PATCH 199/206] perf: add explicit SIMD types and distance kernels for f64 (#6540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `f64x4` and `f64x8` SIMD types to `lance-linalg` with support for x86_64 (AVX2/AVX-512), aarch64 (NEON), and loongarch64 (LASX) - Replaces auto-vectorization-dependent f64 distance functions with explicit SIMD using two-level unrolling (f64x8 + f64x4 + scalar tail) - Updates norm_l2, dot, L2, and cosine distance for f64 ## Benchmark Results (Apple M-series, aarch64 NEON) 1M vectors × 1024 dimensions: | Benchmark | Before | After | Change | |-----------|--------|-------|--------| | NormL2(f64, auto-vec) | 117.76 ms | 116.04 ms | ~same | | NormL2(f64, SIMD) | N/A (TODO) | 119.16 ms | new | | Dot(f64, auto-vec) | 129.36 ms | 130.23 ms | ~same | | L2(f64, auto-vec) | 132.53 ms | 135.15 ms | ~same | | **Cosine(f64, auto-vec)** | **202.52 ms** | **139.23 ms** | **-31.4%** | The biggest win is **cosine distance**, which previously had an empty `impl Cosine for f64 {}` falling back to the scalar path. The explicit SIMD implementation is **31% faster**. For norm_l2, dot, and L2, LLVM's auto-vectorization with the LANES=8 hint was already producing good code on this platform. The explicit SIMD ensures consistent performance across compilers and platforms rather than relying on fragile auto-vectorization hints. ## Test plan - [x] All 59 lance-linalg tests pass - [x] Clippy clean (`-D warnings`) - [x] `cargo fmt` clean - [ ] CI passes on all platforms 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-linalg/benches/norm_l2.rs | 4 +- rust/lance-linalg/src/distance/cosine.rs | 42 +- rust/lance-linalg/src/distance/dot.rs | 39 +- rust/lance-linalg/src/distance/l2.rs | 44 +- rust/lance-linalg/src/distance/norm_l2.rs | 34 +- rust/lance-linalg/src/simd.rs | 1 + rust/lance-linalg/src/simd/f64.rs | 886 ++++++++++++++++++++++ 7 files changed, 1044 insertions(+), 6 deletions(-) create mode 100644 rust/lance-linalg/src/simd/f64.rs diff --git a/rust/lance-linalg/benches/norm_l2.rs b/rust/lance-linalg/benches/norm_l2.rs index e8efd68a909..c43cb32b3d8 100644 --- a/rust/lance-linalg/benches/norm_l2.rs +++ b/rust/lance-linalg/benches/norm_l2.rs @@ -13,7 +13,7 @@ use num_traits::Float; use rand::Rng; use lance_arrow::{ArrowFloatType, FloatArray, bfloat16::BFloat16Type}; -use lance_linalg::distance::{norm_l2, norm_l2_impl}; +use lance_linalg::distance::{norm_l2, norm_l2_f64_simd, norm_l2_impl}; use lance_testing::datagen::generate_random_array_with_seed; #[cfg(target_os = "linux")] @@ -106,7 +106,7 @@ fn bench_distance(c: &mut Criterion) { c, target.as_slice(), norm_l2_impl::, - None, // TODO: implement SIMD for f64 + Some(norm_l2_f64_simd), ); } diff --git a/rust/lance-linalg/src/distance/cosine.rs b/rust/lance-linalg/src/distance/cosine.rs index 79494199d3d..78e5ee526b5 100644 --- a/rust/lance-linalg/src/distance/cosine.rs +++ b/rust/lance-linalg/src/distance/cosine.rs @@ -230,7 +230,47 @@ impl Cosine for f32 { } } -impl Cosine for f64 {} +impl Cosine for f64 { + #[inline] + fn cosine_fast(x: &[Self], x_norm: f32, y: &[Self]) -> f32 { + use crate::simd::f64::{f64x4, f64x8}; + use crate::simd::{FloatSimd, SIMD}; + + let dim = x.len(); + let unrolled_len = dim / 8 * 8; + let mut y_norm8 = f64x8::zeros(); + let mut xy8 = f64x8::zeros(); + for i in (0..unrolled_len).step_by(8) { + unsafe { + let xv = f64x8::load_unaligned(x.as_ptr().add(i)); + let yv = f64x8::load_unaligned(y.as_ptr().add(i)); + xy8.multiply_add(xv, yv); + y_norm8.multiply_add(yv, yv); + } + } + let aligned_len = dim / 4 * 4; + let mut y_norm4 = f64x4::zeros(); + let mut xy4 = f64x4::zeros(); + for i in (unrolled_len..aligned_len).step_by(4) { + unsafe { + let xv = f64x4::load_unaligned(x.as_ptr().add(i)); + let yv = f64x4::load_unaligned(y.as_ptr().add(i)); + xy4.multiply_add(xv, yv); + y_norm4.multiply_add(yv, yv); + } + } + let tail_y_norm: Self = y[aligned_len..].iter().map(|&v| v * v).sum(); + let tail_xy: Self = x[aligned_len..] + .iter() + .zip(y[aligned_len..].iter()) + .map(|(&a, &b)| a * b) + .sum(); + + let y_norm_sq = (y_norm8.reduce_sum() + y_norm4.reduce_sum() + tail_y_norm) as f32; + let xy = (xy8.reduce_sum() + xy4.reduce_sum() + tail_xy) as f32; + 1.0 - xy / x_norm / y_norm_sq.sqrt() + } +} /// Fallback non-SIMD implementation #[inline] diff --git a/rust/lance-linalg/src/distance/dot.rs b/rust/lance-linalg/src/distance/dot.rs index 2951bc4643a..193f52275b5 100644 --- a/rust/lance-linalg/src/distance/dot.rs +++ b/rust/lance-linalg/src/distance/dot.rs @@ -145,10 +145,47 @@ impl Dot for f32 { impl Dot for f64 { #[inline] fn dot(x: &[Self], y: &[Self]) -> f32 { - dot_scalar::(x, y) as f32 + dot_f64_simd(x, y) } } +/// Explicit SIMD dot product for f64. +#[inline] +fn dot_f64_simd(x: &[f64], y: &[f64]) -> f32 { + use crate::simd::f64::{f64x4, f64x8}; + use crate::simd::{FloatSimd, SIMD}; + + let dim = x.len(); + let unrolled_len = dim / 8 * 8; + + let mut acc8 = f64x8::zeros(); + for i in (0..unrolled_len).step_by(8) { + unsafe { + let a = f64x8::load_unaligned(x.as_ptr().add(i)); + let b = f64x8::load_unaligned(y.as_ptr().add(i)); + acc8.multiply_add(a, b); + } + } + + let aligned_len = dim / 4 * 4; + let mut acc4 = f64x4::zeros(); + for i in (unrolled_len..aligned_len).step_by(4) { + unsafe { + let a = f64x4::load_unaligned(x.as_ptr().add(i)); + let b = f64x4::load_unaligned(y.as_ptr().add(i)); + acc4.multiply_add(a, b); + } + } + + let tail: f64 = x[aligned_len..] + .iter() + .zip(y[aligned_len..].iter()) + .map(|(&a, &b)| a * b) + .sum(); + + (acc8.reduce_sum() + acc4.reduce_sum() + tail) as f32 +} + impl Dot for u8 { #[inline] fn dot(x: &[Self], y: &[Self]) -> f32 { diff --git a/rust/lance-linalg/src/distance/l2.rs b/rust/lance-linalg/src/distance/l2.rs index e90ba9a3952..8848cc5ad6d 100644 --- a/rust/lance-linalg/src/distance/l2.rs +++ b/rust/lance-linalg/src/distance/l2.rs @@ -170,10 +170,52 @@ impl L2 for f32 { impl L2 for f64 { #[inline] fn l2(x: &[Self], y: &[Self]) -> f32 { - l2_scalar::(x, y) as f32 + l2_f64_simd(x, y) } } +/// Explicit SIMD L2 distance for f64. +#[inline] +fn l2_f64_simd(x: &[f64], y: &[f64]) -> f32 { + use crate::simd::f64::{f64x4, f64x8}; + use crate::simd::{FloatSimd, SIMD}; + + let dim = x.len(); + let unrolled_len = dim / 8 * 8; + + let mut acc8 = f64x8::zeros(); + for i in (0..unrolled_len).step_by(8) { + unsafe { + let a = f64x8::load_unaligned(x.as_ptr().add(i)); + let b = f64x8::load_unaligned(y.as_ptr().add(i)); + let diff = a - b; + acc8.multiply_add(diff, diff); + } + } + + let aligned_len = dim / 4 * 4; + let mut acc4 = f64x4::zeros(); + for i in (unrolled_len..aligned_len).step_by(4) { + unsafe { + let a = f64x4::load_unaligned(x.as_ptr().add(i)); + let b = f64x4::load_unaligned(y.as_ptr().add(i)); + let diff = a - b; + acc4.multiply_add(diff, diff); + } + } + + let tail: f64 = x[aligned_len..] + .iter() + .zip(y[aligned_len..].iter()) + .map(|(&a, &b)| { + let diff = a - b; + diff * diff + }) + .sum(); + + (acc8.reduce_sum() + acc4.reduce_sum() + tail) as f32 +} + /// Accumulate squared differences for one dimension into per-target results. /// /// Separated into its own function so that LLVM sees `row` and `result` diff --git a/rust/lance-linalg/src/distance/norm_l2.rs b/rust/lance-linalg/src/distance/norm_l2.rs index 97ee4cb1ce9..46bd4d49086 100644 --- a/rust/lance-linalg/src/distance/norm_l2.rs +++ b/rust/lance-linalg/src/distance/norm_l2.rs @@ -97,10 +97,42 @@ impl Normalize for f32 { impl Normalize for f64 { #[inline] fn norm_l2(vector: &[Self]) -> f32 { - norm_l2_impl::(vector) as f32 + norm_l2_f64_simd(vector) } } +/// Explicit SIMD implementation of L2 norm for f64. +/// +/// Two-level unrolling: f64x8 main loop, f64x4 remainder, scalar tail. +#[inline] +pub fn norm_l2_f64_simd(vector: &[f64]) -> f32 { + use crate::simd::f64::{f64x4, f64x8}; + use crate::simd::{FloatSimd, SIMD}; + + let dim = vector.len(); + let unrolled_len = dim / 8 * 8; + + let mut acc8 = f64x8::zeros(); + for i in (0..unrolled_len).step_by(8) { + unsafe { + let v = f64x8::load_unaligned(vector.as_ptr().add(i)); + acc8.multiply_add(v, v); + } + } + + let aligned_len = dim / 4 * 4; + let mut acc4 = f64x4::zeros(); + for i in (unrolled_len..aligned_len).step_by(4) { + unsafe { + let v = f64x4::load_unaligned(vector.as_ptr().add(i)); + acc4.multiply_add(v, v); + } + } + + let tail: f64 = vector[aligned_len..].iter().map(|&v| v * v).sum(); + (acc8.reduce_sum() + acc4.reduce_sum() + tail).sqrt() as f32 +} + /// NOTE: this is only pub for benchmarking purposes #[inline] pub fn norm_l2_impl< diff --git a/rust/lance-linalg/src/simd.rs b/rust/lance-linalg/src/simd.rs index 0526d531b8f..91dc1c6959d 100644 --- a/rust/lance-linalg/src/simd.rs +++ b/rust/lance-linalg/src/simd.rs @@ -16,6 +16,7 @@ use std::ops::{Add, AddAssign, Mul, Sub, SubAssign}; pub mod dist_table; pub mod f32; +pub mod f64; pub mod i32; pub mod u8; diff --git a/rust/lance-linalg/src/simd/f64.rs b/rust/lance-linalg/src/simd/f64.rs new file mode 100644 index 00000000000..32c0d389e5b --- /dev/null +++ b/rust/lance-linalg/src/simd/f64.rs @@ -0,0 +1,886 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! `f64x4` and `f64x8` SIMD types for f64 distance computations. + +use std::fmt::Formatter; + +#[cfg(target_arch = "aarch64")] +use std::arch::aarch64::*; +#[cfg(target_arch = "loongarch64")] +use std::arch::loongarch64::*; +#[cfg(target_arch = "x86_64")] +use std::arch::x86_64::*; +#[cfg(target_arch = "loongarch64")] +use std::mem::transmute; +use std::ops::{Add, AddAssign, Mul, Sub, SubAssign}; + +use super::{FloatSimd, SIMD}; + +/// 4 of 64-bit `f64` values. Uses 256-bit SIMD if possible. +#[allow(non_camel_case_types)] +#[cfg(target_arch = "x86_64")] +#[derive(Clone, Copy)] +pub struct f64x4(std::arch::x86_64::__m256d); + +#[allow(non_camel_case_types)] +#[cfg(target_arch = "aarch64")] +#[derive(Clone, Copy)] +pub struct f64x4(float64x2x2_t); + +#[allow(non_camel_case_types)] +#[cfg(target_arch = "loongarch64")] +#[derive(Clone, Copy)] +pub struct f64x4(v4f64); + +impl std::fmt::Debug for f64x4 { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let mut arr = [0.0_f64; 4]; + unsafe { + self.store_unaligned(arr.as_mut_ptr()); + } + write!(f, "f64x4({:?})", arr) + } +} + +impl From<&[f64]> for f64x4 { + fn from(value: &[f64]) -> Self { + unsafe { Self::load_unaligned(value.as_ptr()) } + } +} + +impl<'a> From<&'a [f64; 4]> for f64x4 { + fn from(value: &'a [f64; 4]) -> Self { + unsafe { Self::load_unaligned(value.as_ptr()) } + } +} + +impl SIMD for f64x4 { + fn splat(val: f64) -> Self { + #[cfg(target_arch = "x86_64")] + unsafe { + Self(_mm256_set1_pd(val)) + } + #[cfg(target_arch = "aarch64")] + unsafe { + Self(float64x2x2_t(vdupq_n_f64(val), vdupq_n_f64(val))) + } + #[cfg(target_arch = "loongarch64")] + unsafe { + Self(transmute(lasx_xvreplgr2vr_d(transmute(val)))) + } + } + + fn zeros() -> Self { + #[cfg(target_arch = "x86_64")] + unsafe { + Self(_mm256_setzero_pd()) + } + #[cfg(target_arch = "aarch64")] + { + Self::splat(0.0) + } + #[cfg(target_arch = "loongarch64")] + { + Self::splat(0.0) + } + } + + #[inline] + unsafe fn load(ptr: *const f64) -> Self { + #[cfg(target_arch = "x86_64")] + unsafe { + Self(_mm256_load_pd(ptr)) + } + #[cfg(target_arch = "aarch64")] + { + Self::load_unaligned(ptr) + } + #[cfg(target_arch = "loongarch64")] + { + Self(transmute(lasx_xvld::<0>(transmute(ptr)))) + } + } + + #[inline] + unsafe fn load_unaligned(ptr: *const f64) -> Self { + #[cfg(target_arch = "x86_64")] + unsafe { + Self(_mm256_loadu_pd(ptr)) + } + #[cfg(target_arch = "aarch64")] + { + Self(vld1q_f64_x2(ptr)) + } + #[cfg(target_arch = "loongarch64")] + { + Self(transmute(lasx_xvld::<0>(transmute(ptr)))) + } + } + + unsafe fn store(&self, ptr: *mut f64) { + #[cfg(target_arch = "x86_64")] + unsafe { + _mm256_store_pd(ptr, self.0); + } + #[cfg(target_arch = "aarch64")] + unsafe { + vst1q_f64_x2(ptr, self.0); + } + #[cfg(target_arch = "loongarch64")] + unsafe { + lasx_xvst::<0>(transmute(self.0), transmute(ptr)); + } + } + + unsafe fn store_unaligned(&self, ptr: *mut f64) { + #[cfg(target_arch = "x86_64")] + unsafe { + _mm256_storeu_pd(ptr, self.0); + } + #[cfg(target_arch = "aarch64")] + unsafe { + vst1q_f64_x2(ptr, self.0); + } + #[cfg(target_arch = "loongarch64")] + unsafe { + lasx_xvst::<0>(transmute(self.0), transmute(ptr)); + } + } + + #[inline] + fn reduce_sum(&self) -> f64 { + #[cfg(target_arch = "x86_64")] + unsafe { + // [a, b, c, d] -> hadd -> [a+b, a+b, c+d, c+d] + let sum = _mm256_hadd_pd(self.0, self.0); + // Extract low 128 and high 128, add them + let lo = _mm256_castpd256_pd128(sum); + let hi = _mm256_extractf128_pd(sum, 1); + let r = _mm_add_pd(lo, hi); + _mm_cvtsd_f64(r) + } + #[cfg(target_arch = "aarch64")] + unsafe { + let sum = vaddq_f64(self.0.0, self.0.1); + vaddvq_f64(sum) + } + #[cfg(target_arch = "loongarch64")] + { + self.as_array().iter().sum() + } + } + + fn reduce_min(&self) -> f64 { + #[cfg(target_arch = "x86_64")] + unsafe { + // Swap high/low 128-bit lanes and min + let hi = _mm256_permute2f128_pd(self.0, self.0, 1); + let m = _mm256_min_pd(self.0, hi); + // Swap within 128-bit lane and min + let shuf = _mm256_permute_pd(m, 0b0101); + let m = _mm256_min_pd(m, shuf); + _mm256_cvtsd_f64(m) + } + #[cfg(target_arch = "aarch64")] + unsafe { + let m = vminq_f64(self.0.0, self.0.1); + vminvq_f64(m) + } + #[cfg(target_arch = "loongarch64")] + { + self.as_array() + .iter() + .copied() + .fold(f64::INFINITY, f64::min) + } + } + + fn min(&self, rhs: &Self) -> Self { + #[cfg(target_arch = "x86_64")] + unsafe { + Self(_mm256_min_pd(self.0, rhs.0)) + } + #[cfg(target_arch = "aarch64")] + unsafe { + Self(float64x2x2_t( + vminq_f64(self.0.0, rhs.0.0), + vminq_f64(self.0.1, rhs.0.1), + )) + } + #[cfg(target_arch = "loongarch64")] + unsafe { + Self(lasx_xvfmin_d(self.0, rhs.0)) + } + } + + fn find(&self, val: f64) -> Option { + unsafe { + for i in 0..4 { + if self.as_array().get_unchecked(i) == &val { + return Some(i as i32); + } + } + } + None + } +} + +impl FloatSimd for f64x4 { + fn multiply_add(&mut self, a: Self, b: Self) { + #[cfg(target_arch = "x86_64")] + unsafe { + self.0 = _mm256_fmadd_pd(a.0, b.0, self.0); + } + #[cfg(target_arch = "aarch64")] + unsafe { + self.0.0 = vfmaq_f64(self.0.0, a.0.0, b.0.0); + self.0.1 = vfmaq_f64(self.0.1, a.0.1, b.0.1); + } + #[cfg(target_arch = "loongarch64")] + unsafe { + self.0 = lasx_xvfmadd_d(a.0, b.0, self.0); + } + } +} + +impl Add for f64x4 { + type Output = Self; + + #[inline] + fn add(self, rhs: Self) -> Self::Output { + #[cfg(target_arch = "x86_64")] + unsafe { + Self(_mm256_add_pd(self.0, rhs.0)) + } + #[cfg(target_arch = "aarch64")] + unsafe { + Self(float64x2x2_t( + vaddq_f64(self.0.0, rhs.0.0), + vaddq_f64(self.0.1, rhs.0.1), + )) + } + #[cfg(target_arch = "loongarch64")] + unsafe { + Self(lasx_xvfadd_d(self.0, rhs.0)) + } + } +} + +impl AddAssign for f64x4 { + #[inline] + fn add_assign(&mut self, rhs: Self) { + #[cfg(target_arch = "x86_64")] + unsafe { + self.0 = _mm256_add_pd(self.0, rhs.0) + } + #[cfg(target_arch = "aarch64")] + unsafe { + self.0.0 = vaddq_f64(self.0.0, rhs.0.0); + self.0.1 = vaddq_f64(self.0.1, rhs.0.1); + } + #[cfg(target_arch = "loongarch64")] + unsafe { + self.0 = lasx_xvfadd_d(self.0, rhs.0); + } + } +} + +impl Sub for f64x4 { + type Output = Self; + + #[inline] + fn sub(self, rhs: Self) -> Self::Output { + #[cfg(target_arch = "x86_64")] + unsafe { + Self(_mm256_sub_pd(self.0, rhs.0)) + } + #[cfg(target_arch = "aarch64")] + unsafe { + Self(float64x2x2_t( + vsubq_f64(self.0.0, rhs.0.0), + vsubq_f64(self.0.1, rhs.0.1), + )) + } + #[cfg(target_arch = "loongarch64")] + unsafe { + Self(lasx_xvfsub_d(self.0, rhs.0)) + } + } +} + +impl SubAssign for f64x4 { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + #[cfg(target_arch = "x86_64")] + unsafe { + self.0 = _mm256_sub_pd(self.0, rhs.0) + } + #[cfg(target_arch = "aarch64")] + unsafe { + self.0.0 = vsubq_f64(self.0.0, rhs.0.0); + self.0.1 = vsubq_f64(self.0.1, rhs.0.1); + } + #[cfg(target_arch = "loongarch64")] + unsafe { + self.0 = lasx_xvfsub_d(self.0, rhs.0); + } + } +} + +impl Mul for f64x4 { + type Output = Self; + + #[inline] + fn mul(self, rhs: Self) -> Self::Output { + #[cfg(target_arch = "x86_64")] + unsafe { + Self(_mm256_mul_pd(self.0, rhs.0)) + } + #[cfg(target_arch = "aarch64")] + unsafe { + Self(float64x2x2_t( + vmulq_f64(self.0.0, rhs.0.0), + vmulq_f64(self.0.1, rhs.0.1), + )) + } + #[cfg(target_arch = "loongarch64")] + unsafe { + Self(lasx_xvfmul_d(self.0, rhs.0)) + } + } +} + +// --------------------------------------------------------------------------- +// f64x8: 8 × f64 values (512-bit SIMD or 2 × 256-bit) +// --------------------------------------------------------------------------- + +/// 8 of 64-bit `f64` values. Uses 512-bit SIMD if possible. +#[allow(non_camel_case_types)] +#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] +#[derive(Clone, Copy)] +pub struct f64x8(__m512d); + +#[allow(non_camel_case_types)] +#[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] +#[derive(Clone, Copy)] +pub struct f64x8(__m256d, __m256d); + +#[allow(non_camel_case_types)] +#[cfg(target_arch = "aarch64")] +#[derive(Clone, Copy)] +pub struct f64x8(float64x2x2_t, float64x2x2_t); + +#[allow(non_camel_case_types)] +#[cfg(target_arch = "loongarch64")] +#[derive(Clone, Copy)] +pub struct f64x8(v4f64, v4f64); + +impl std::fmt::Debug for f64x8 { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let mut arr = [0.0_f64; 8]; + unsafe { + self.store_unaligned(arr.as_mut_ptr()); + } + write!(f, "f64x8({:?})", arr) + } +} + +impl From<&[f64]> for f64x8 { + fn from(value: &[f64]) -> Self { + unsafe { Self::load_unaligned(value.as_ptr()) } + } +} + +impl<'a> From<&'a [f64; 8]> for f64x8 { + fn from(value: &'a [f64; 8]) -> Self { + unsafe { Self::load_unaligned(value.as_ptr()) } + } +} + +impl SIMD for f64x8 { + #[inline] + fn splat(val: f64) -> Self { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + Self(_mm512_set1_pd(val)) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + Self(_mm256_set1_pd(val), _mm256_set1_pd(val)) + } + #[cfg(target_arch = "aarch64")] + unsafe { + let v = vdupq_n_f64(val); + Self(float64x2x2_t(v, v), float64x2x2_t(v, v)) + } + #[cfg(target_arch = "loongarch64")] + unsafe { + let v = transmute(lasx_xvreplgr2vr_d(transmute(val))); + Self(v, v) + } + } + + #[inline] + fn zeros() -> Self { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + Self(_mm512_setzero_pd()) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + Self(_mm256_setzero_pd(), _mm256_setzero_pd()) + } + #[cfg(target_arch = "aarch64")] + { + Self::splat(0.0) + } + #[cfg(target_arch = "loongarch64")] + { + Self::splat(0.0) + } + } + + #[inline] + unsafe fn load(ptr: *const f64) -> Self { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + Self(_mm512_load_pd(ptr)) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + Self(_mm256_load_pd(ptr), _mm256_load_pd(ptr.add(4))) + } + #[cfg(target_arch = "aarch64")] + { + Self::load_unaligned(ptr) + } + #[cfg(target_arch = "loongarch64")] + { + Self( + transmute(lasx_xvld::<0>(transmute(ptr))), + transmute(lasx_xvld::<32>(transmute(ptr))), + ) + } + } + + #[inline] + unsafe fn load_unaligned(ptr: *const f64) -> Self { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + Self(_mm512_loadu_pd(ptr)) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + Self(_mm256_loadu_pd(ptr), _mm256_loadu_pd(ptr.add(4))) + } + #[cfg(target_arch = "aarch64")] + unsafe { + Self(vld1q_f64_x2(ptr), vld1q_f64_x2(ptr.add(4))) + } + #[cfg(target_arch = "loongarch64")] + { + Self( + transmute(lasx_xvld::<0>(transmute(ptr))), + transmute(lasx_xvld::<32>(transmute(ptr))), + ) + } + } + + #[inline] + unsafe fn store(&self, ptr: *mut f64) { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + _mm512_store_pd(ptr, self.0) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + _mm256_store_pd(ptr, self.0); + _mm256_store_pd(ptr.add(4), self.1); + } + #[cfg(target_arch = "aarch64")] + unsafe { + vst1q_f64_x2(ptr, self.0); + vst1q_f64_x2(ptr.add(4), self.1); + } + #[cfg(target_arch = "loongarch64")] + { + lasx_xvst::<0>(transmute(self.0), transmute(ptr)); + lasx_xvst::<32>(transmute(self.1), transmute(ptr)); + } + } + + #[inline] + unsafe fn store_unaligned(&self, ptr: *mut f64) { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + _mm512_storeu_pd(ptr, self.0) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + _mm256_storeu_pd(ptr, self.0); + _mm256_storeu_pd(ptr.add(4), self.1); + } + #[cfg(target_arch = "aarch64")] + unsafe { + vst1q_f64_x2(ptr, self.0); + vst1q_f64_x2(ptr.add(4), self.1); + } + #[cfg(target_arch = "loongarch64")] + { + lasx_xvst::<0>(transmute(self.0), transmute(ptr)); + lasx_xvst::<32>(transmute(self.1), transmute(ptr)); + } + } + + fn reduce_sum(&self) -> f64 { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + _mm512_mask_reduce_add_pd(0xFF, self.0) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + let sum = _mm256_add_pd(self.0, self.1); + let hi = _mm256_permute2f128_pd(sum, sum, 1); + let sum = _mm256_add_pd(sum, hi); + let sum = _mm256_hadd_pd(sum, sum); + _mm256_cvtsd_f64(sum) + } + #[cfg(target_arch = "aarch64")] + unsafe { + let sum0 = vaddq_f64(self.0.0, self.0.1); + let sum1 = vaddq_f64(self.1.0, self.1.1); + let sum = vaddq_f64(sum0, sum1); + vaddvq_f64(sum) + } + #[cfg(target_arch = "loongarch64")] + { + self.as_array().iter().sum() + } + } + + #[inline] + fn reduce_min(&self) -> f64 { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + _mm512_mask_reduce_min_pd(0xFF, self.0) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + let m = _mm256_min_pd(self.0, self.1); + let hi = _mm256_permute2f128_pd(m, m, 1); + let m = _mm256_min_pd(m, hi); + let shuf = _mm256_permute_pd(m, 0b0101); + let m = _mm256_min_pd(m, shuf); + _mm256_cvtsd_f64(m) + } + #[cfg(target_arch = "aarch64")] + unsafe { + let m0 = vminq_f64(self.0.0, self.0.1); + let m1 = vminq_f64(self.1.0, self.1.1); + let m = vminq_f64(m0, m1); + vminvq_f64(m) + } + #[cfg(target_arch = "loongarch64")] + { + self.as_array() + .iter() + .copied() + .fold(f64::INFINITY, f64::min) + } + } + + #[inline] + fn min(&self, rhs: &Self) -> Self { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + Self(_mm512_min_pd(self.0, rhs.0)) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + Self(_mm256_min_pd(self.0, rhs.0), _mm256_min_pd(self.1, rhs.1)) + } + #[cfg(target_arch = "aarch64")] + unsafe { + Self( + float64x2x2_t(vminq_f64(self.0.0, rhs.0.0), vminq_f64(self.0.1, rhs.0.1)), + float64x2x2_t(vminq_f64(self.1.0, rhs.1.0), vminq_f64(self.1.1, rhs.1.1)), + ) + } + #[cfg(target_arch = "loongarch64")] + unsafe { + Self(lasx_xvfmin_d(self.0, rhs.0), lasx_xvfmin_d(self.1, rhs.1)) + } + } + + fn find(&self, val: f64) -> Option { + unsafe { + for i in 0..8 { + if self.as_array().get_unchecked(i) == &val { + return Some(i as i32); + } + } + } + None + } +} + +impl FloatSimd for f64x8 { + #[inline] + fn multiply_add(&mut self, a: Self, b: Self) { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + self.0 = _mm512_fmadd_pd(a.0, b.0, self.0) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + self.0 = _mm256_fmadd_pd(a.0, b.0, self.0); + self.1 = _mm256_fmadd_pd(a.1, b.1, self.1); + } + #[cfg(target_arch = "aarch64")] + unsafe { + self.0.0 = vfmaq_f64(self.0.0, a.0.0, b.0.0); + self.0.1 = vfmaq_f64(self.0.1, a.0.1, b.0.1); + self.1.0 = vfmaq_f64(self.1.0, a.1.0, b.1.0); + self.1.1 = vfmaq_f64(self.1.1, a.1.1, b.1.1); + } + #[cfg(target_arch = "loongarch64")] + unsafe { + self.0 = lasx_xvfmadd_d(a.0, b.0, self.0); + self.1 = lasx_xvfmadd_d(a.1, b.1, self.1); + } + } +} + +impl Add for f64x8 { + type Output = Self; + + #[inline] + fn add(self, rhs: Self) -> Self::Output { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + Self(_mm512_add_pd(self.0, rhs.0)) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + Self(_mm256_add_pd(self.0, rhs.0), _mm256_add_pd(self.1, rhs.1)) + } + #[cfg(target_arch = "aarch64")] + unsafe { + Self( + float64x2x2_t(vaddq_f64(self.0.0, rhs.0.0), vaddq_f64(self.0.1, rhs.0.1)), + float64x2x2_t(vaddq_f64(self.1.0, rhs.1.0), vaddq_f64(self.1.1, rhs.1.1)), + ) + } + #[cfg(target_arch = "loongarch64")] + unsafe { + Self(lasx_xvfadd_d(self.0, rhs.0), lasx_xvfadd_d(self.1, rhs.1)) + } + } +} + +impl AddAssign for f64x8 { + #[inline] + fn add_assign(&mut self, rhs: Self) { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + self.0 = _mm512_add_pd(self.0, rhs.0) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + self.0 = _mm256_add_pd(self.0, rhs.0); + self.1 = _mm256_add_pd(self.1, rhs.1); + } + #[cfg(target_arch = "aarch64")] + unsafe { + self.0.0 = vaddq_f64(self.0.0, rhs.0.0); + self.0.1 = vaddq_f64(self.0.1, rhs.0.1); + self.1.0 = vaddq_f64(self.1.0, rhs.1.0); + self.1.1 = vaddq_f64(self.1.1, rhs.1.1); + } + #[cfg(target_arch = "loongarch64")] + unsafe { + self.0 = lasx_xvfadd_d(self.0, rhs.0); + self.1 = lasx_xvfadd_d(self.1, rhs.1); + } + } +} + +impl Mul for f64x8 { + type Output = Self; + + #[inline] + fn mul(self, rhs: Self) -> Self::Output { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + Self(_mm512_mul_pd(self.0, rhs.0)) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + Self(_mm256_mul_pd(self.0, rhs.0), _mm256_mul_pd(self.1, rhs.1)) + } + #[cfg(target_arch = "aarch64")] + unsafe { + Self( + float64x2x2_t(vmulq_f64(self.0.0, rhs.0.0), vmulq_f64(self.0.1, rhs.0.1)), + float64x2x2_t(vmulq_f64(self.1.0, rhs.1.0), vmulq_f64(self.1.1, rhs.1.1)), + ) + } + #[cfg(target_arch = "loongarch64")] + unsafe { + Self(lasx_xvfmul_d(self.0, rhs.0), lasx_xvfmul_d(self.1, rhs.1)) + } + } +} + +impl Sub for f64x8 { + type Output = Self; + + #[inline] + fn sub(self, rhs: Self) -> Self::Output { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + Self(_mm512_sub_pd(self.0, rhs.0)) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + Self(_mm256_sub_pd(self.0, rhs.0), _mm256_sub_pd(self.1, rhs.1)) + } + #[cfg(target_arch = "aarch64")] + unsafe { + Self( + float64x2x2_t(vsubq_f64(self.0.0, rhs.0.0), vsubq_f64(self.0.1, rhs.0.1)), + float64x2x2_t(vsubq_f64(self.1.0, rhs.1.0), vsubq_f64(self.1.1, rhs.1.1)), + ) + } + #[cfg(target_arch = "loongarch64")] + unsafe { + Self(lasx_xvfsub_d(self.0, rhs.0), lasx_xvfsub_d(self.1, rhs.1)) + } + } +} + +impl SubAssign for f64x8 { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + #[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] + unsafe { + self.0 = _mm512_sub_pd(self.0, rhs.0) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx512f")))] + unsafe { + self.0 = _mm256_sub_pd(self.0, rhs.0); + self.1 = _mm256_sub_pd(self.1, rhs.1); + } + #[cfg(target_arch = "aarch64")] + unsafe { + self.0.0 = vsubq_f64(self.0.0, rhs.0.0); + self.0.1 = vsubq_f64(self.0.1, rhs.0.1); + self.1.0 = vsubq_f64(self.1.0, rhs.1.0); + self.1.1 = vsubq_f64(self.1.1, rhs.1.1); + } + #[cfg(target_arch = "loongarch64")] + unsafe { + self.0 = lasx_xvfsub_d(self.0, rhs.0); + self.1 = lasx_xvfsub_d(self.1, rhs.1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_f64x4_basic_ops() { + let a = [1.0_f64, 2.0, 3.0, 4.0]; + let b = [5.0_f64, 6.0, 7.0, 8.0]; + + let simd_a: f64x4 = (&a).into(); + let simd_b: f64x4 = (&b).into(); + + let sum = simd_a + simd_b; + assert_eq!(sum.as_array(), [6.0, 8.0, 10.0, 12.0]); + + let product = simd_a * simd_b; + assert_eq!(product.as_array(), [5.0, 12.0, 21.0, 32.0]); + + let diff = simd_b - simd_a; + assert_eq!(diff.as_array(), [4.0, 4.0, 4.0, 4.0]); + + assert_eq!(simd_a.reduce_sum(), 10.0); + assert_eq!(simd_a.reduce_min(), 1.0); + } + + #[test] + fn test_f64x4_fma() { + let a = [1.0_f64, 2.0, 3.0, 4.0]; + let b = [2.0_f64, 3.0, 4.0, 5.0]; + + let simd_a: f64x4 = (&a).into(); + let simd_b: f64x4 = (&b).into(); + let mut acc = f64x4::zeros(); + acc.multiply_add(simd_a, simd_b); + assert_eq!(acc.as_array(), [2.0, 6.0, 12.0, 20.0]); + } + + #[test] + fn test_f64x4_min() { + let a = [1.0_f64, 5.0, 2.0, 8.0]; + let b = [3.0_f64, 2.0, 4.0, 1.0]; + let simd_a: f64x4 = (&a).into(); + let simd_b: f64x4 = (&b).into(); + + let m = simd_a.min(&simd_b); + assert_eq!(m.as_array(), [1.0, 2.0, 2.0, 1.0]); + assert_eq!(m.reduce_min(), 1.0); + } + + #[test] + fn test_f64x8_basic_ops() { + let a: [f64; 8] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let b: [f64; 8] = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]; + + let simd_a: f64x8 = (&a).into(); + let simd_b: f64x8 = (&b).into(); + + let sum = simd_a + simd_b; + assert_eq!( + sum.as_array(), + [11.0, 22.0, 33.0, 44.0, 55.0, 66.0, 77.0, 88.0] + ); + + assert_eq!(simd_a.reduce_sum(), 36.0); + assert_eq!(simd_a.reduce_min(), 1.0); + } + + #[test] + fn test_f64x8_fma() { + let a: [f64; 8] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + let b: [f64; 8] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + + let simd_a: f64x8 = (&a).into(); + let simd_b: f64x8 = (&b).into(); + let mut acc = f64x8::zeros(); + acc.multiply_add(simd_a, simd_b); + assert_eq!(acc.as_array(), a); + assert_eq!(acc.reduce_sum(), 36.0); + } + + #[test] + fn test_f64x8_min() { + let a: [f64; 8] = [5.0, 1.0, 8.0, 3.0, 9.0, 2.0, 7.0, 4.0]; + let b: [f64; 8] = [2.0, 6.0, 3.0, 7.0, 1.0, 8.0, 4.0, 9.0]; + let simd_a: f64x8 = (&a).into(); + let simd_b: f64x8 = (&b).into(); + + let m = simd_a.min(&simd_b); + assert_eq!(m.as_array(), [2.0, 1.0, 3.0, 3.0, 1.0, 2.0, 4.0, 4.0]); + assert_eq!(m.reduce_min(), 1.0); + + // Test with negative values + let c: [f64; 8] = [-1.0, 5.0, 3.0, 7.0, 2.0, 4.0, 6.0, -3.0]; + let simd_c: f64x8 = (&c).into(); + assert_eq!(simd_c.reduce_min(), -3.0); + } +} From 8f479dbff1d9b24cb47078522418d44dfd76a663 Mon Sep 17 00:00:00 2001 From: Justin Miller Date: Thu, 16 Apr 2026 05:27:35 -0700 Subject: [PATCH 200/206] perf: add SIMD-accelerated u8 dot product for SQ distance (#6506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add AVX-512 VNNI and AVX2 backends for unsigned int8 dot product with runtime CPU feature detection and automatic fallback to scalar - Replace the unoptimized `Dot` impl (which had an explicit TODO) with dispatched SIMD kernel - All existing callers including SQ distance computation benefit automatically with zero changes to lance-index ## Details ### New file: `rust/lance-linalg/src/distance/dot_u8.rs` Three backends selected at runtime via `OnceLock` + `is_x86_feature_detected!`: | Backend | Instruction | Elements/iter | CPU | |---|---|---|---| | AVX-512 VNNI | `VPDPBUSD` + XOR-0x80 bias trick | 64 | Ice Lake+ / Zen 4+ | | AVX2 | `VPMADDWD` on zero-extended u16 | 32 | Haswell+ / Zen 1+ | | Scalar | portable reference | - | any (including ARM) | ### The VNNI bias trick `VPDPBUSD` expects one unsigned and one signed operand, but SQ vectors are u8×u8. We XOR one operand with 0x80 to map it to the signed domain, then correct by adding `128·Σa` at the end. The correction uses `VPSADBW` which runs on execution port 5 while `VPDPBUSD` runs on port 0 — they execute in parallel every cycle, making the correction effectively free. ### SQ integration (automatic) `SQDistCalculator::distance()` already calls `dot_distance()` → `u8::dot()` for Dot distance type. Replacing the `Dot` body is the only change needed. ## Benchmarks ### Ryzen 4500 (AVX2, no VNNI) 1M total u8 elements, varying vector dimension. Scalar baseline vs AVX2-dispatched path: | Dimension | Scalar | Dispatch (AVX2) | Speedup | |-----------|--------|-----------------|---------| | 128 | 51.02 µs | 58.25 µs | 0.88x (dispatch overhead dominates) | | 256 | 44.96 µs | 38.62 µs | **1.16x** | | 512 | 42.82 µs | 28.27 µs | **1.51x** | | 1024 | 41.00 µs | 25.17 µs | **1.63x** | AVX2 delivers up to 1.63x throughput at dim=1024. At dim=128 the `OnceLock` dispatch and AVX2 loop setup overhead exceeds the SIMD gains on short vectors. AVX-512 VNNI (Ice Lake+ / Zen 4+) is expected to show larger gains with 64 elements/iter. ### Apple M4 (ARM64, scalar fallback) On ARM64 the dispatch falls back to scalar, so both paths perform identically (~13 µs at dim=1024). A follow-up ARM NEON `UDOT` path would bring SIMD gains to Apple Silicon. ### Out of scope (follow-up) - L2/Cosine u8 SIMD optimization (different kernel: `Σ(a-b)²`) - Native `VPDPBUUD` (unsigned×unsigned, Sierra Forest+) — too new for stable Rust - ARM NEON `UDOT` path - Precomputed norms for SQ L2/Cosine (requires storage format change) ## Test plan - [x] Unit tests: random inputs across 18 vector sizes (0-4097), boundary values (all 0s, all 255s, alternating), one-sided zeros, all-ones patterns - [x] Each backend tested independently against scalar reference (with `#[cfg]` guards for missing CPU features) - [x] Existing `dot` tests continue to pass (9/9) - [x] `cargo clippy -p lance-linalg --tests --benches -- -D warnings` clean - [x] Benchmark on x86_64 with AVX2: `cargo bench --bench dot -p lance-linalg -- "Dot\(u8"` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-linalg/benches/dot.rs | 34 +++ rust/lance-linalg/src/distance.rs | 1 + rust/lance-linalg/src/distance/dot.rs | 6 +- rust/lance-linalg/src/distance/dot_u8.rs | 255 +++++++++++++++++++++++ 4 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 rust/lance-linalg/src/distance/dot_u8.rs diff --git a/rust/lance-linalg/benches/dot.rs b/rust/lance-linalg/benches/dot.rs index 17fd1b891eb..3b1beaba7bf 100644 --- a/rust/lance-linalg/benches/dot.rs +++ b/rust/lance-linalg/benches/dot.rs @@ -107,6 +107,40 @@ fn bench_distance(c: &mut Criterion) { }); }); + // u8 dot product benchmarks: scalar baseline vs SIMD dispatch + { + use lance_linalg::distance::dot_u8::{dot_u8, dot_u8_scalar}; + + for &dim in &[128, 256, 512, 1024] { + let num_vectors = 1024 * 1024 / dim; // ~1M elements total + let mut rng = rand::rng(); + let key_u8: Vec = (0..dim).map(|_| rng.random()).collect(); + let target_u8: Vec = (0..num_vectors * dim).map(|_| rng.random()).collect(); + + c.bench_function(&format!("Dot(u8, scalar, dim={dim})"), |b| { + b.iter(|| { + black_box( + target_u8 + .chunks(dim) + .map(|y| dot_u8_scalar(key_u8.as_slice(), y)) + .collect::>(), + ) + }); + }); + + c.bench_function(&format!("Dot(u8, dispatch, dim={dim})"), |b| { + b.iter(|| { + black_box( + target_u8 + .chunks(dim) + .map(|y| dot_u8(key_u8.as_slice(), y)) + .collect::>(), + ) + }); + }); + } + } + run_bench::(c); c.bench_function("Dot(f32, SIMD)", |b| { let key = generate_random_array_with_seed::(DIMENSION, [0; 32]); diff --git a/rust/lance-linalg/src/distance.rs b/rust/lance-linalg/src/distance.rs index 255abd971ed..9dd57edb3f9 100644 --- a/rust/lance-linalg/src/distance.rs +++ b/rust/lance-linalg/src/distance.rs @@ -19,6 +19,7 @@ use arrow_schema::{ArrowError, DataType}; pub mod cosine; pub mod cosine_u8; pub mod dot; +pub mod dot_u8; pub mod hamming; pub mod l2; pub mod l2_u8; diff --git a/rust/lance-linalg/src/distance/dot.rs b/rust/lance-linalg/src/distance/dot.rs index 193f52275b5..1188637a333 100644 --- a/rust/lance-linalg/src/distance/dot.rs +++ b/rust/lance-linalg/src/distance/dot.rs @@ -189,11 +189,7 @@ fn dot_f64_simd(x: &[f64], y: &[f64]) -> f32 { impl Dot for u8 { #[inline] fn dot(x: &[Self], y: &[Self]) -> f32 { - // TODO: this is not optimized for auto vectorization yet. - x.iter() - .zip(y.iter()) - .map(|(&x_i, &y_i)| x_i as u32 * y_i as u32) - .sum::() as f32 + super::dot_u8::dot_u8(x, y) as f32 } } diff --git a/rust/lance-linalg/src/distance/dot_u8.rs b/rust/lance-linalg/src/distance/dot_u8.rs new file mode 100644 index 00000000000..de5522cddfe --- /dev/null +++ b/rust/lance-linalg/src/distance/dot_u8.rs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Unsigned int8 dot product with runtime-dispatched SIMD backends. +//! +//! Used by Scalar Quantization (SQ) distance computation. SQ stores each +//! vector dimension as a u8 after linearly mapping [min, max] → [0, 255]. +//! Distance computation between SQ-encoded vectors reduces to a u8 × u8 +//! dot product plus precomputed per-vector scalar terms. +//! +//! Backends (selected at runtime, best available wins): +//! 1. scalar — portable reference, also used for tails +//! 2. avx2 — VPMADDWD on u16-widened halves, 32 elements/iter +//! 3. avx512vnni — VPDPBUSD with XOR-0x80 bias trick, 64 elements/iter +//! +//! ## The VNNI bias trick +//! +//! VPDPBUSD expects one unsigned and one signed operand, but SQ vectors +//! are u8 × u8. We bias `b` into the signed domain via XOR 0x80 (equivalent +//! to subtracting 128 when reinterpreted as i8), feed `a` directly as +//! unsigned, and correct by adding 128·Σa at the end: +//! +//! DPBUSD(a, b ⊕ 0x80) = Σ a·(b − 128) = Σ a·b − 128·Σa +//! +//! The Σa term uses VPSADBW, which dispatches to port 5 while VPDPBUSD +//! runs on port 0 on Intel. The two instructions execute in parallel, +//! making the correction effectively free. + +use std::sync::OnceLock; + +/// Portable scalar u8 dot product, also used for SIMD tail elements. +#[inline] +pub fn dot_u8_scalar(a: &[u8], b: &[u8]) -> u32 { + debug_assert_eq!(a.len(), b.len()); + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| x as u32 * y as u32) + .sum() +} + +#[cfg(target_arch = "x86_64")] +mod x86 { + use std::arch::x86_64::*; + + /// AVX2 path: zero-extend u8→u16, then VPMADDWD. 32 elements/iter. + #[target_feature(enable = "avx2")] + pub unsafe fn dot_u8_avx2(a: &[u8], b: &[u8]) -> u32 { + debug_assert_eq!(a.len(), b.len()); + let n = a.len(); + let mut acc = _mm256_setzero_si256(); + let mut i = 0usize; + + while i + 32 <= n { + let av = _mm256_loadu_si256(a.as_ptr().add(i) as *const __m256i); + let bv = _mm256_loadu_si256(b.as_ptr().add(i) as *const __m256i); + + // Zero-extend each 128-bit half to 16 × u16. Values ≤ 255 fit + // in i16 as positive, so VPMADDWD gives correct results. + let a_lo = _mm256_cvtepu8_epi16(_mm256_castsi256_si128(av)); + let a_hi = _mm256_cvtepu8_epi16(_mm256_extracti128_si256(av, 1)); + let b_lo = _mm256_cvtepu8_epi16(_mm256_castsi256_si128(bv)); + let b_hi = _mm256_cvtepu8_epi16(_mm256_extracti128_si256(bv, 1)); + + acc = _mm256_add_epi32(acc, _mm256_madd_epi16(a_lo, b_lo)); + acc = _mm256_add_epi32(acc, _mm256_madd_epi16(a_hi, b_hi)); + i += 32; + } + + let lo128 = _mm256_castsi256_si128(acc); + let hi128 = _mm256_extracti128_si256(acc, 1); + let mut sum128 = _mm_add_epi32(lo128, hi128); + sum128 = _mm_hadd_epi32(sum128, sum128); + sum128 = _mm_hadd_epi32(sum128, sum128); + let mut result = _mm_cvtsi128_si32(sum128) as u32; + + while i < n { + result += a[i] as u32 * b[i] as u32; + i += 1; + } + result + } + + /// AVX-512 VNNI path (Ice Lake+, Zen 4+). 64 elements/iter. + /// + /// VPDPBUSD expects (unsigned, signed) operands but SQ stores u8×u8. + /// We XOR b with 0x80 to map it to i8, then correct: result + 128·Σa. + /// The Σa term (VPSADBW, port 5) runs in parallel with VPDPBUSD (port 0). + #[target_feature(enable = "avx512f,avx512bw,avx512vnni")] + pub unsafe fn dot_u8_avx512_vnni(a: &[u8], b: &[u8]) -> u32 { + debug_assert_eq!(a.len(), b.len()); + let n = a.len(); + + let mut acc_dot = _mm512_setzero_si512(); + let mut acc_suma = _mm512_setzero_si512(); + let sign_flip = _mm512_set1_epi8(0x80u8 as i8); + let zeros = _mm512_setzero_si512(); + let mut i = 0usize; + + while i + 64 <= n { + let av = _mm512_loadu_si512(a.as_ptr().add(i) as *const __m512i); + let bv = _mm512_loadu_si512(b.as_ptr().add(i) as *const __m512i); + let b_biased = _mm512_xor_si512(bv, sign_flip); + acc_dot = _mm512_dpbusd_epi32(acc_dot, av, b_biased); + acc_suma = _mm512_add_epi64(acc_suma, _mm512_sad_epu8(av, zeros)); + i += 64; + } + + let biased_dot = _mm512_reduce_add_epi32(acc_dot); + let sum_a = _mm512_reduce_add_epi64(acc_suma); + let mut result = (biased_dot as i64 + 128 * sum_a) as u32; + + while i < n { + result += a[i] as u32 * b[i] as u32; + i += 1; + } + result + } +} + +type DotU8Fn = fn(&[u8], &[u8]) -> u32; + +static DISPATCH: OnceLock = OnceLock::new(); + +fn select_backend() -> DotU8Fn { + #[cfg(target_arch = "x86_64")] + { + if is_x86_feature_detected!("avx512f") + && is_x86_feature_detected!("avx512bw") + && is_x86_feature_detected!("avx512vnni") + { + return |a, b| unsafe { x86::dot_u8_avx512_vnni(a, b) }; + } + + if is_x86_feature_detected!("avx2") { + return |a, b| unsafe { x86::dot_u8_avx2(a, b) }; + } + } + + dot_u8_scalar +} + +/// Dispatched u8 dot product, selecting the best available SIMD backend. +#[inline] +pub fn dot_u8(a: &[u8], b: &[u8]) -> u32 { + (DISPATCH.get_or_init(select_backend))(a, b) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fill_random(buf: &mut [u8], seed: &mut u32) { + for slot in buf.iter_mut() { + *seed = seed.wrapping_mul(1103515245).wrapping_add(12345); + *slot = (*seed >> 16) as u8; + } + } + + const SIZES: &[usize] = &[ + 0, 1, 7, 15, 16, 31, 32, 33, 63, 64, 65, 127, 128, 255, 256, 1024, 4096, 4097, + ]; + + fn check_all_backends(a: &[u8], b: &[u8], case: &str) { + let reference = dot_u8_scalar(a, b); + + #[cfg(target_arch = "x86_64")] + { + if is_x86_feature_detected!("avx2") { + let got = unsafe { x86::dot_u8_avx2(a, b) }; + assert_eq!(got, reference, "avx2 [{case}] n={}", a.len()); + } + + if is_x86_feature_detected!("avx512f") + && is_x86_feature_detected!("avx512bw") + && is_x86_feature_detected!("avx512vnni") + { + let got = unsafe { x86::dot_u8_avx512_vnni(a, b) }; + assert_eq!(got, reference, "avx512_vnni [{case}] n={}", a.len()); + } + } + + assert_eq!(dot_u8(a, b), reference, "dispatch [{case}] n={}", a.len()); + } + + #[test] + fn random_inputs_across_sizes_and_seeds() { + let mut a = vec![0u8; 4097]; + let mut b = vec![0u8; 4097]; + + for seed_idx in 0..4u32 { + let mut seed = 0xC0FFEE_u32.wrapping_add(seed_idx.wrapping_mul(7919)); + for &n in SIZES { + fill_random(&mut a[..n], &mut seed); + fill_random(&mut b[..n], &mut seed); + check_all_backends(&a[..n], &b[..n], "random"); + } + } + } + + #[test] + fn boundary_values() { + let mut a = vec![0u8; 4097]; + let mut b = vec![0u8; 4097]; + + for &n in SIZES { + a[..n].fill(u8::MAX); + b[..n].fill(u8::MAX); + check_all_backends(&a[..n], &b[..n], "max*max"); + + a[..n].fill(u8::MAX); + b[..n].fill(0); + check_all_backends(&a[..n], &b[..n], "max*0"); + + a[..n].fill(0); + b[..n].fill(u8::MAX); + check_all_backends(&a[..n], &b[..n], "0*max"); + + for i in 0..n { + a[i] = if i & 1 == 0 { 0 } else { u8::MAX }; + b[i] = if i & 1 == 0 { u8::MAX } else { 0 }; + } + check_all_backends(&a[..n], &b[..n], "alt 0/max"); + } + } + + #[test] + fn one_sided_zeros() { + let mut a = vec![0u8; 4097]; + let mut b = vec![0u8; 4097]; + + for &n in SIZES { + let mut seed = 0xDEAD_BEEF_u32; + fill_random(&mut a[..n], &mut seed); + b[..n].fill(0); + check_all_backends(&a[..n], &b[..n], "b=0"); + + a[..n].fill(0); + fill_random(&mut b[..n], &mut seed); + check_all_backends(&a[..n], &b[..n], "a=0"); + } + } + + #[test] + fn all_ones_pattern() { + let mut a = vec![0u8; 4097]; + let mut b = vec![0u8; 4097]; + + for &n in SIZES { + a[..n].fill(1); + b[..n].fill(1); + check_all_backends(&a[..n], &b[..n], "1*1"); + assert_eq!(dot_u8_scalar(&a[..n], &b[..n]), n as u32); + } + } +} From d0124edf62574bdb71dc9db019afdaaecf03fdbe Mon Sep 17 00:00:00 2001 From: Justin Miller Date: Thu, 16 Apr 2026 05:27:54 -0700 Subject: [PATCH 201/206] perf: add SIMD kernels for bf16 distance functions (#6510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replaces the external `numkong` dependency with in-tree C kernels for **bf16 distance computation** (dot product, L2, cosine, norm_l2) - Follows the existing f16 kernel pattern: C source compiled via `build.rs` with per-architecture flags, runtime CPU dispatch via `SIMD_SUPPORT` - Kernels are only enabled when the CPU supports the required instructions (NEON on aarch64, AVX2/AVX-512 on x86_64, LSX/LASX on loongarch64), with scalar fallback otherwise - Gated behind the existing `fp16kernels` feature flag ## Benchmark Results Tested on two platforms with 1M x 1024-dim vectors: ### Apple Silicon (M-series, NEON) | Benchmark | Before (scalar) | After (C kernel) | Change | |-----------|-----------------|-------------------|--------| | **Dot(bf16)** | 144 ms | 55 ms | **2.6x faster** | | **NormL2(bf16)** | 90 ms | 36 ms | **2.5x faster** | ### AMD Ryzen 5 4500 (Zen 2, AVX2) | Benchmark | Before (scalar) | After (C kernel) | Change | |-----------|-----------------|-------------------|--------| | **Dot(bf16)** | 578 ms | 363 ms | **1.6x faster** (−37%) | | **NormL2(bf16)** | 365 ms | 207 ms | **1.8x faster** (−43%) | ### Why the approach works BF16-to-f32 conversion is a simple left-shift by 16 bits. The C kernels compiled with architecture-specific flags (`-march=haswell`, `-mtune=apple-m1`, etc.) plus `-ffast-math` and vectorization pragmas give the compiler more freedom to emit tight SIMD code than LLVM gets from the Rust scalar loops. ARM benefits more because the baseline Rust auto-vectorization was weaker there. ## Files Changed - **New**: `rust/lance-linalg/src/simd/bf16.c` — C kernels for dot, L2, cosine, norm_l2 - `rust/lance-linalg/build.rs` — compile bf16.c for each architecture - `rust/lance-linalg/src/distance/{dot,l2,cosine,norm_l2}.rs` — runtime SIMD dispatch for bf16 - `rust/lance-linalg/Cargo.toml` — removed `numkong` dependency and feature - `rust/lance-linalg/benches/{dot,l2,cosine}.rs` — removed numkong benchmark sections - **Deleted**: `scripts/bench_numkong.sh` ## Test plan - [x] `cargo test -p lance-linalg --features fp16kernels` — all bf16 tests pass (kernel path) - [x] `cargo test -p lance-linalg` — all bf16 tests pass (scalar fallback) - [x] `cargo clippy -p lance-linalg --features fp16kernels --tests --benches -- -D warnings` — clean - [x] Benchmarked on Apple Silicon (ARM NEON) - [x] Benchmarked on AMD Ryzen 5 4500 (x86_64 AVX2) - To reproduce: ```bash git checkout HEAD~1 TARGET_TIME=3 cargo bench -p lance-linalg --features fp16kernels --bench dot -- --save-baseline before "bf16" TARGET_TIME=3 cargo bench -p lance-linalg --features fp16kernels --bench norm_l2 -- --save-baseline before "bf16" git checkout - TARGET_TIME=3 cargo bench -p lance-linalg --features fp16kernels --bench dot -- --baseline before "bf16" TARGET_TIME=3 cargo bench -p lance-linalg --features fp16kernels --bench norm_l2 -- --baseline before "bf16" ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-linalg/build.rs | 50 +++++++++++++++ rust/lance-linalg/src/distance/cosine.rs | 60 +++++++++++++++++- rust/lance-linalg/src/distance/dot.rs | 48 ++++++++++++++- rust/lance-linalg/src/distance/l2.rs | 49 ++++++++++++++- rust/lance-linalg/src/distance/norm_l2.rs | 46 +++++++++++++- rust/lance-linalg/src/simd/bf16.c | 74 +++++++++++++++++++++++ rust/lance-linalg/src/simd/f16.c | 1 - 7 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 rust/lance-linalg/src/simd/bf16.c diff --git a/rust/lance-linalg/build.rs b/rust/lance-linalg/build.rs index 152163ed611..06e1439c77a 100644 --- a/rust/lance-linalg/build.rs +++ b/rust/lance-linalg/build.rs @@ -19,6 +19,7 @@ fn main() -> Result<(), String> { println!("cargo::rustc-check-cfg=cfg(kernel_support, values(\"avx512\"))"); println!("cargo:rerun-if-changed=src/simd/f16.c"); + println!("cargo:rerun-if-changed=src/simd/bf16.c"); println!("cargo:rerun-if-changed=src/simd/dist_table.c"); // Important: we don't use `cfg!(target_arch)` here because that is the target_arch @@ -37,13 +38,16 @@ fn main() -> Result<(), String> { if target_arch == "aarch64" && target_os == "macos" { // Build a version with NEON build_f16_with_flags("neon", &["-mtune=apple-m1"]).unwrap(); + build_bf16_with_flags("neon", &["-mtune=apple-m1"]).unwrap(); } else if target_arch == "aarch64" && target_os == "ios" { // Build version with NEON // A13 bionic is the earliest supported iOS SOC build_f16_with_flags("neon", &["-mtune=apple-a13"]).unwrap(); + build_bf16_with_flags("neon", &["-mtune=apple-a13"]).unwrap(); } else if target_arch == "aarch64" && (target_os == "linux" || target_os == "android") { // Build a version with NEON build_f16_with_flags("neon", &["-march=armv8.2-a+fp16"]).unwrap(); + build_bf16_with_flags("neon", &["-march=armv8.2-a+fp16"]).unwrap(); } else if target_arch == "x86_64" { // Build a version with AVX512 if let Err(err) = build_f16_with_flags("avx512", &["-march=sapphirerapids", "-mavx512fp16"]) @@ -59,6 +63,17 @@ fn main() -> Result<(), String> { // generated the AVX512 version of the f16 kernels. println!("cargo:rustc-cfg=kernel_support=\"avx512\""); }; + // Build AVX-512 bf16 kernels (sapphirerapids has native vdpbf16ps) + if let Err(err) = + build_bf16_with_flags("avx512", &["-march=sapphirerapids", "-mavx512fp16"]) + { + println!( + "cargo:warning=Skipping build of AVX-512 bf16 kernels. Error: {}", + err + ); + } else { + println!("cargo:rustc-cfg=kernel_support=\"avx512\""); + }; if let Err(err) = build_dist_table_with_flags("avx512", &["-march=native"]) { println!( "cargo:warning=Skipping build of AVX-512 dist_table. Error: {}", @@ -77,11 +92,20 @@ fn main() -> Result<(), String> { err )); }; + // Build AVX2 bf16 kernels (bf16-to-f32 is just a shift, auto-vectorizes well) + if let Err(err) = build_bf16_with_flags("avx2", &["-march=haswell"]) { + return Err(format!( + "Unable to build AVX2 bf16 kernels. Received error: {}", + err + )); + }; // There is no SSE instruction set for f16 -> f32 float conversion } else if target_arch == "loongarch64" { // Build a version with LSX and LASX build_f16_with_flags("lsx", &["-mlsx"]).unwrap(); build_f16_with_flags("lasx", &["-mlasx"]).unwrap(); + build_bf16_with_flags("lsx", &["-mlsx"]).unwrap(); + build_bf16_with_flags("lasx", &["-mlasx"]).unwrap(); } else { // Only error if fp16kernels was explicitly requested on unsupported platform. // This allows builds on iOS, Android, etc. when the feature is disabled. @@ -128,6 +152,32 @@ fn build_f16_with_flags(suffix: &str, flags: &[&str]) -> Result<(), cc::Error> { builder.try_compile(&format!("f16_{}", suffix)) } +fn build_bf16_with_flags(suffix: &str, flags: &[&str]) -> Result<(), cc::Error> { + if cfg!(not(feature = "fp16kernels")) { + println!( + "cargo:warning=fp16kernels feature is not enabled, skipping build of bf16 kernels" + ); + return Ok(()); + } + + let mut builder = cc::Build::new(); + builder + .std("c17") + .file("src/simd/bf16.c") + .flag("-ffast-math") + .flag("-funroll-loops") + .flag("-O3") + .flag("-Wall") + .flag("-Wextra") + .flag(format!("-DSUFFIX=_{}", suffix).as_str()); + + for flag in flags { + builder.flag(flag); + } + + builder.try_compile(&format!("bf16_{}", suffix)) +} + fn build_dist_table_with_flags(suffix: &str, flags: &[&str]) -> Result<(), cc::Error> { let mut builder = cc::Build::new(); builder diff --git a/rust/lance-linalg/src/distance/cosine.rs b/rust/lance-linalg/src/distance/cosine.rs index 78e5ee526b5..be5bf436344 100644 --- a/rust/lance-linalg/src/distance/cosine.rs +++ b/rust/lance-linalg/src/distance/cosine.rs @@ -72,7 +72,65 @@ impl Cosine for u8 { } } -impl Cosine for bf16 {} +#[cfg(feature = "fp16kernels")] +mod bf16_kernel { + use half::bf16; + + // These are the `cosine_bf16` function in bf16.c. Our build.rs script compiles + // a version of this file for each SIMD level with different suffixes. + unsafe extern "C" { + #[cfg(target_arch = "aarch64")] + pub fn cosine_bf16_neon(x: *const bf16, x_norm: f32, y: *const bf16, dimension: u32) + -> f32; + #[cfg(all(kernel_support = "avx512", target_arch = "x86_64"))] + pub fn cosine_bf16_avx512( + x: *const bf16, + x_norm: f32, + y: *const bf16, + dimension: u32, + ) -> f32; + #[cfg(target_arch = "x86_64")] + pub fn cosine_bf16_avx2(x: *const bf16, x_norm: f32, y: *const bf16, dimension: u32) + -> f32; + #[cfg(target_arch = "loongarch64")] + pub fn cosine_bf16_lsx(x: *const bf16, x_norm: f32, y: *const bf16, dimension: u32) -> f32; + #[cfg(target_arch = "loongarch64")] + pub fn cosine_bf16_lasx(x: *const bf16, x_norm: f32, y: *const bf16, dimension: u32) + -> f32; + } +} + +impl Cosine for bf16 { + fn cosine_fast(x: &[Self], x_norm: f32, y: &[Self]) -> f32 { + match *SIMD_SUPPORT { + #[cfg(all(feature = "fp16kernels", target_arch = "aarch64"))] + SimdSupport::Neon => unsafe { + bf16_kernel::cosine_bf16_neon(x.as_ptr(), x_norm, y.as_ptr(), y.len() as u32) + }, + #[cfg(all( + feature = "fp16kernels", + kernel_support = "avx512", + target_arch = "x86_64" + ))] + SimdSupport::Avx512FP16 => unsafe { + bf16_kernel::cosine_bf16_avx512(x.as_ptr(), x_norm, y.as_ptr(), y.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "x86_64"))] + SimdSupport::Avx2 | SimdSupport::Avx512 => unsafe { + bf16_kernel::cosine_bf16_avx2(x.as_ptr(), x_norm, y.as_ptr(), y.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))] + SimdSupport::Lasx => unsafe { + bf16_kernel::cosine_bf16_lasx(x.as_ptr(), x_norm, y.as_ptr(), y.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))] + SimdSupport::Lsx => unsafe { + bf16_kernel::cosine_bf16_lsx(x.as_ptr(), x_norm, y.as_ptr(), y.len() as u32) + }, + _ => cosine_scalar(x, x_norm, y), + } + } +} #[cfg(feature = "fp16kernels")] mod kernel { diff --git a/rust/lance-linalg/src/distance/dot.rs b/rust/lance-linalg/src/distance/dot.rs index 1188637a333..0dfa2178b9d 100644 --- a/rust/lance-linalg/src/distance/dot.rs +++ b/rust/lance-linalg/src/distance/dot.rs @@ -75,10 +75,56 @@ pub trait Dot: Num { fn dot(x: &[Self], y: &[Self]) -> f32; } +#[cfg(feature = "fp16kernels")] +mod bf16_kernel { + use half::bf16; + + // These are the `dot_bf16` function in bf16.c. Our build.rs script compiles + // a version of this file for each SIMD level with different suffixes. + unsafe extern "C" { + #[cfg(target_arch = "aarch64")] + pub fn dot_bf16_neon(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32; + #[cfg(all(kernel_support = "avx512", target_arch = "x86_64"))] + pub fn dot_bf16_avx512(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32; + #[cfg(target_arch = "x86_64")] + pub fn dot_bf16_avx2(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32; + #[cfg(target_arch = "loongarch64")] + pub fn dot_bf16_lsx(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32; + #[cfg(target_arch = "loongarch64")] + pub fn dot_bf16_lasx(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32; + } +} + impl Dot for bf16 { #[inline] fn dot(x: &[Self], y: &[Self]) -> f32 { - dot_scalar::(x, y) + match *SIMD_SUPPORT { + #[cfg(all(feature = "fp16kernels", target_arch = "aarch64"))] + SimdSupport::Neon => unsafe { + bf16_kernel::dot_bf16_neon(x.as_ptr(), y.as_ptr(), x.len() as u32) + }, + #[cfg(all( + feature = "fp16kernels", + kernel_support = "avx512", + target_arch = "x86_64" + ))] + SimdSupport::Avx512FP16 => unsafe { + bf16_kernel::dot_bf16_avx512(x.as_ptr(), y.as_ptr(), x.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "x86_64"))] + SimdSupport::Avx2 | SimdSupport::Avx512 => unsafe { + bf16_kernel::dot_bf16_avx2(x.as_ptr(), y.as_ptr(), x.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))] + SimdSupport::Lasx => unsafe { + bf16_kernel::dot_bf16_lasx(x.as_ptr(), y.as_ptr(), x.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))] + SimdSupport::Lsx => unsafe { + bf16_kernel::dot_bf16_lsx(x.as_ptr(), y.as_ptr(), x.len() as u32) + }, + _ => dot_scalar::(x, y), + } } } diff --git a/rust/lance-linalg/src/distance/l2.rs b/rust/lance-linalg/src/distance/l2.rs index 8848cc5ad6d..36855bfee18 100644 --- a/rust/lance-linalg/src/distance/l2.rs +++ b/rust/lance-linalg/src/distance/l2.rs @@ -97,11 +97,56 @@ impl L2 for u8 { } } +#[cfg(feature = "fp16kernels")] +mod bf16_kernel { + use half::bf16; + + // These are the `l2_bf16` function in bf16.c. Our build.rs script compiles + // a version of this file for each SIMD level with different suffixes. + unsafe extern "C" { + #[cfg(target_arch = "aarch64")] + pub fn l2_bf16_neon(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32; + #[cfg(all(kernel_support = "avx512", target_arch = "x86_64"))] + pub fn l2_bf16_avx512(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32; + #[cfg(target_arch = "x86_64")] + pub fn l2_bf16_avx2(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32; + #[cfg(target_arch = "loongarch64")] + pub fn l2_bf16_lsx(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32; + #[cfg(target_arch = "loongarch64")] + pub fn l2_bf16_lasx(ptr1: *const bf16, ptr2: *const bf16, len: u32) -> f32; + } +} + impl L2 for bf16 { #[inline] fn l2(x: &[Self], y: &[Self]) -> f32 { - // TODO: add SIMD support - l2_scalar::(x, y) + match *SIMD_SUPPORT { + #[cfg(all(feature = "fp16kernels", target_arch = "aarch64"))] + SimdSupport::Neon => unsafe { + bf16_kernel::l2_bf16_neon(x.as_ptr(), y.as_ptr(), x.len() as u32) + }, + #[cfg(all( + feature = "fp16kernels", + kernel_support = "avx512", + target_arch = "x86_64" + ))] + SimdSupport::Avx512FP16 => unsafe { + bf16_kernel::l2_bf16_avx512(x.as_ptr(), y.as_ptr(), x.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "x86_64"))] + SimdSupport::Avx2 | SimdSupport::Avx512 => unsafe { + bf16_kernel::l2_bf16_avx2(x.as_ptr(), y.as_ptr(), x.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))] + SimdSupport::Lasx => unsafe { + bf16_kernel::l2_bf16_lasx(x.as_ptr(), y.as_ptr(), x.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))] + SimdSupport::Lsx => unsafe { + bf16_kernel::l2_bf16_lsx(x.as_ptr(), y.as_ptr(), x.len() as u32) + }, + _ => l2_scalar::(x, y), + } } } diff --git a/rust/lance-linalg/src/distance/norm_l2.rs b/rust/lance-linalg/src/distance/norm_l2.rs index 46bd4d49086..cd604ac8c05 100644 --- a/rust/lance-linalg/src/distance/norm_l2.rs +++ b/rust/lance-linalg/src/distance/norm_l2.rs @@ -80,10 +80,54 @@ impl Normalize for f16 { } } +#[cfg(feature = "fp16kernels")] +mod bf16_kernel { + use half::bf16; + + unsafe extern "C" { + #[cfg(target_arch = "aarch64")] + pub fn norm_l2_bf16_neon(ptr: *const bf16, len: u32) -> f32; + #[cfg(all(kernel_support = "avx512", target_arch = "x86_64"))] + pub fn norm_l2_bf16_avx512(ptr: *const bf16, len: u32) -> f32; + #[cfg(target_arch = "x86_64")] + pub fn norm_l2_bf16_avx2(ptr: *const bf16, len: u32) -> f32; + #[cfg(target_arch = "loongarch64")] + pub fn norm_l2_bf16_lsx(ptr: *const bf16, len: u32) -> f32; + #[cfg(target_arch = "loongarch64")] + pub fn norm_l2_bf16_lasx(ptr: *const bf16, len: u32) -> f32; + } +} + impl Normalize for bf16 { #[inline] fn norm_l2(vector: &[Self]) -> f32 { - norm_l2_impl::(vector) + match *SIMD_SUPPORT { + #[cfg(all(feature = "fp16kernels", target_arch = "aarch64"))] + SimdSupport::Neon => unsafe { + bf16_kernel::norm_l2_bf16_neon(vector.as_ptr(), vector.len() as u32) + }, + #[cfg(all( + feature = "fp16kernels", + kernel_support = "avx512", + target_arch = "x86_64" + ))] + SimdSupport::Avx512FP16 => unsafe { + bf16_kernel::norm_l2_bf16_avx512(vector.as_ptr(), vector.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "x86_64"))] + SimdSupport::Avx2 | SimdSupport::Avx512 => unsafe { + bf16_kernel::norm_l2_bf16_avx2(vector.as_ptr(), vector.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))] + SimdSupport::Lasx => unsafe { + bf16_kernel::norm_l2_bf16_lasx(vector.as_ptr(), vector.len() as u32) + }, + #[cfg(all(feature = "fp16kernels", target_arch = "loongarch64"))] + SimdSupport::Lsx => unsafe { + bf16_kernel::norm_l2_bf16_lsx(vector.as_ptr(), vector.len() as u32) + }, + _ => norm_l2_impl::(vector), + } } } diff --git a/rust/lance-linalg/src/simd/bf16.c b/rust/lance-linalg/src/simd/bf16.c new file mode 100644 index 00000000000..cfbe5dee971 --- /dev/null +++ b/rust/lance-linalg/src/simd/bf16.c @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +#include +#include +#include +#include + +// Because we might be compiling this library multiple times, we need to +// add a suffix to each of the function names. +#define FUNC_CAT_INNER(A, B) A##B +#define FUNC_CAT(A, B) FUNC_CAT_INNER(A, B) +#define FUNC(N) FUNC_CAT(N, SUFFIX) + +// Convert a bf16 value (stored as uint16_t) to float32. +// BF16 is the upper 16 bits of an IEEE 754 float32, so we just shift left. +static inline float bf16_to_f32(uint16_t v) { + uint32_t bits = (uint32_t)v << 16; + float f; + memcpy(&f, &bits, sizeof(f)); + return f; +} + +float FUNC(norm_l2_bf16)(const uint16_t *data, uint32_t dimension) { + float sum = 0; + +#pragma clang loop unroll(enable) vectorize(enable) interleave(enable) + for (uint32_t i = 0; i < dimension; i++) { + float v = bf16_to_f32(data[i]); + sum += v * v; + } + return sqrtf(sum); +} + +/// @brief Dot product of two bf16 vectors. +/// @param x A bf16 vector (stored as uint16_t) +/// @param y A bf16 vector (stored as uint16_t) +/// @param dimension The dimension of the vectors +/// @return The dot product of the two vectors. +float FUNC(dot_bf16)(const uint16_t *x, const uint16_t *y, uint32_t dimension) { + float sum = 0; + +#pragma clang loop unroll(enable) interleave(enable) vectorize(enable) + for (uint32_t i = 0; i < dimension; i++) { + sum += bf16_to_f32(x[i]) * bf16_to_f32(y[i]); + } + return sum; +} + +float FUNC(l2_bf16)(const uint16_t *x, const uint16_t *y, uint32_t dimension) { + float sum = 0.0; + +#pragma clang loop unroll(enable) interleave(enable) vectorize(enable) + for (uint32_t i = 0; i < dimension; i++) { + float s = bf16_to_f32(x[i]) - bf16_to_f32(y[i]); + sum += s * s; + } + return sum; +} + +float FUNC(cosine_bf16)(const uint16_t *x, float x_norm, const uint16_t *y, uint32_t dimension) { + float dot = 0.0; + float l2_y = 0.0; + + // Combine the loop to reduce overhead of the bf16 to fp32 conversion. +#pragma clang loop unroll(enable) interleave(enable) vectorize(enable) + for (uint32_t i = 0; i < dimension; i++) { + float y_i = bf16_to_f32(y[i]); + dot += bf16_to_f32(x[i]) * y_i; + l2_y += y_i * y_i; + } + + return 1.0 - dot / (x_norm * sqrtf(l2_y)); +} diff --git a/rust/lance-linalg/src/simd/f16.c b/rust/lance-linalg/src/simd/f16.c index bc0356d87e9..7bba4de3dc5 100644 --- a/rust/lance-linalg/src/simd/f16.c +++ b/rust/lance-linalg/src/simd/f16.c @@ -22,7 +22,6 @@ #define FUNC_CAT(A, B) FUNC_CAT_INNER(A, B) #define FUNC(N) FUNC_CAT(N, SUFFIX) -// TODO: I wonder if we could re-purpose this macro to compile bf16 kernels? #if defined(__clang__) // Note: we use __fp16 instead of _Float16 because Clang < 15.0.0 does not // support it well for most targets. __fp16 works for our purposes here since From 0108b96cfdbecd9af9ede8098bfdea156c03d990 Mon Sep 17 00:00:00 2001 From: Justin Miller Date: Thu, 16 Apr 2026 15:21:04 -0700 Subject: [PATCH 202/206] perf: speed up RaBitQ 4-bit LUT distance on ARM by 16x (#6537) --- rust/lance-index/src/vector/bq/storage.rs | 67 +++++++-- rust/lance-linalg/Cargo.toml | 4 + rust/lance-linalg/benches/dist_table.rs | 64 ++++++++ rust/lance-linalg/src/simd/dist_table.rs | 169 +++++++++++++++++++++- 4 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 rust/lance-linalg/benches/dist_table.rs diff --git a/rust/lance-index/src/vector/bq/storage.rs b/rust/lance-index/src/vector/bq/storage.rs index c47dd0211eb..38c194ce66b 100644 --- a/rust/lance-index/src/vector/bq/storage.rs +++ b/rust/lance-index/src/vector/bq/storage.rs @@ -348,17 +348,8 @@ impl DistCalculator for RabitDistCalculator<'_> { let id = id as usize; let code_len = self.dim * (self.num_bits as usize) / u8::BITS as usize; let num_vectors = self.codes.len() / code_len; - let code = get_rq_code(self.codes, id, num_vectors, code_len); - let dist = code - .zip(self.dist_table.chunks_exact(SEGMENT_NUM_CODES).tuples()) - .map(|(code_byte, (dist_table, next_dist_table))| { - // code is a bit vector, we iterate over 8 bits at a time, - // every 4 bits is a sub-vector, we need to extract the bits - let current_code = (code_byte & 0x0F) as usize; - let next_code = (code_byte >> 4) as usize; - dist_table[current_code] + next_dist_table[next_code] - }) - .sum::(); + let dist = + compute_single_rq_distance(self.codes, id, num_vectors, code_len, &self.dist_table); // distance between quantized vector and query vector let dist_vq_qr = (2.0 * dist - self.sum_q) / self.sqrt_d; @@ -799,6 +790,60 @@ impl QuantizerStorage for RabitQuantizationStorage { } } +/// Compute the raw distance for a single vector without allocating. +/// +/// Fuses code extraction from the packed layout with distance accumulation +/// in a single pass, avoiding the intermediate `Vec` allocation that +/// `get_rq_code` + iterator would require. +#[inline] +fn compute_single_rq_distance( + codes: &[u8], + id: usize, + num_vectors: usize, + num_code_bytes: usize, + dist_table: &[f32], +) -> f32 { + let remainder = num_vectors % BATCH_SIZE; + let mut dist_table_iter = dist_table.chunks_exact(SEGMENT_NUM_CODES).tuples(); + + if id < num_vectors - remainder { + let batch_codes = &codes[id / BATCH_SIZE * BATCH_SIZE * num_code_bytes + ..(id / BATCH_SIZE + 1) * BATCH_SIZE * num_code_bytes]; + + let id_in_batch = id % BATCH_SIZE; + let idx = PERM0_INVERSE[id_in_batch % 16]; + let is_lower = id_in_batch < 16; + + let mut dist = 0.0f32; + for block in batch_codes.chunks_exact(BATCH_SIZE) { + let code_byte = if is_lower { + (block[idx] & 0xF) | (block[idx + 16] << 4) + } else { + (block[idx] >> 4) | (block[idx + 16] & 0xF0) + }; + if let Some((current_dt, next_dt)) = dist_table_iter.next() { + let current_code = (code_byte & 0x0F) as usize; + let next_code = (code_byte >> 4) as usize; + dist += current_dt[current_code] + next_dt[next_code]; + } + } + dist + } else { + let offset_id = id - (num_vectors - remainder); + let remainder_codes = &codes[(num_vectors - remainder) * num_code_bytes..]; + + let mut dist = 0.0f32; + for &code_byte in remainder_codes.iter().skip(offset_id).step_by(remainder) { + if let Some((current_dt, next_dt)) = dist_table_iter.next() { + let current_code = (code_byte & 0x0F) as usize; + let next_code = (code_byte >> 4) as usize; + dist += current_dt[current_code] + next_dt[next_code]; + } + } + dist + } +} + #[inline] fn get_rq_code( codes: &[u8], diff --git a/rust/lance-linalg/Cargo.toml b/rust/lance-linalg/Cargo.toml index c3cb18868d8..463dc7f02c9 100644 --- a/rust/lance-linalg/Cargo.toml +++ b/rust/lance-linalg/Cargo.toml @@ -62,5 +62,9 @@ harness = false name = "norm_l2" harness = false +[[bench]] +name = "dist_table" +harness = false + [lints] workspace = true diff --git a/rust/lance-linalg/benches/dist_table.rs b/rust/lance-linalg/benches/dist_table.rs new file mode 100644 index 00000000000..acd0c04c644 --- /dev/null +++ b/rust/lance-linalg/benches/dist_table.rs @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Benchmark of 4-bit LUT distance table summation (RaBitQ inner loop). +//! +//! Measures both the dispatched path (NEON on ARM, AVX2 on x86) and the +//! scalar fallback, so the speedup is visible in a single benchmark run. + +use std::iter::repeat_with; + +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use lance_linalg::simd::dist_table::{BATCH_SIZE, sum_4bit_dist_table, sum_4bit_dist_table_scalar}; +use rand::Rng; + +fn bench_sum_4bit_dist_table(c: &mut Criterion) { + let mut rng = rand::rng(); + + // code_len = dim / 8 for 1-bit quantization + for (label, n_vectors, code_len) in [ + ("32vec_dim128", 32_usize, 16_usize), + ("32vec_dim1536", 32, 192), + ("32vec_dim4096", 32, 512), + ("32vec_dim65536", 32, 8192), + ("16Kvec_dim128", 16_000, 16), + ("16Kvec_dim1536", 16_000, 192), + ] { + let n = n_vectors.div_ceil(BATCH_SIZE) * BATCH_SIZE; + + let codes: Vec = repeat_with(|| rng.random::()) + .take(n * code_len) + .collect(); + + let dist_table: Vec = repeat_with(|| rng.random::()) + .take(BATCH_SIZE * code_len) + .collect(); + + let mut dists = vec![0u16; n]; + + // Dispatched path (NEON on ARM, AVX2 on x86) + c.bench_function(&format!("sum_4bit_dist_table/simd/{}", label), |b| { + b.iter(|| { + dists.fill(0); + sum_4bit_dist_table(n, code_len, &codes, &dist_table, &mut dists); + black_box(&dists); + }) + }); + + // Scalar reference path + c.bench_function(&format!("sum_4bit_dist_table/scalar/{}", label), |b| { + b.iter(|| { + dists.fill(0); + sum_4bit_dist_table_scalar(code_len, &codes, &dist_table, &mut dists); + black_box(&dists); + }) + }); + } +} + +criterion_group!( + name = benches; + config = Criterion::default().significance_level(0.1).sample_size(10); + targets = bench_sum_4bit_dist_table +); +criterion_main!(benches); diff --git a/rust/lance-linalg/src/simd/dist_table.rs b/rust/lance-linalg/src/simd/dist_table.rs index 2c1609072ea..689e949106e 100644 --- a/rust/lance-linalg/src/simd/dist_table.rs +++ b/rust/lance-linalg/src/simd/dist_table.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +#[cfg(target_arch = "aarch64")] +use std::arch::aarch64::*; #[cfg(target_arch = "x86_64")] use std::arch::x86_64::*; @@ -57,13 +59,28 @@ pub fn sum_4bit_dist_table( ) } }, + #[cfg(target_arch = "aarch64")] + SimdSupport::Neon => unsafe { + for i in (0..n).step_by(BATCH_SIZE) { + sum_dist_table_32bytes_batch_neon( + &codes[i * code_len..(i + BATCH_SIZE) * code_len], + dist_table, + &mut dists[i..i + BATCH_SIZE], + ) + } + }, _ => sum_4bit_dist_table_scalar(code_len, codes, dist_table, dists), } } #[inline] #[allow(unused)] -fn sum_4bit_dist_table_scalar(code_len: usize, codes: &[u8], dist_table: &[u8], dists: &mut [u16]) { +pub fn sum_4bit_dist_table_scalar( + code_len: usize, + codes: &[u8], + dist_table: &[u8], + dists: &mut [u16], +) { for (vec_block_idx, blocks) in codes.chunks_exact(BATCH_SIZE * code_len).enumerate() { for (sub_vec_idx, block) in blocks.chunks_exact(BATCH_SIZE).enumerate() { let current_dist_table = &dist_table[sub_vec_idx * 2 * 16..(sub_vec_idx * 2 + 1) * 16]; @@ -159,6 +176,77 @@ unsafe fn sum_dist_table_32bytes_batch_avx2(codes: &[u8], dist_table: &[u8], dis _mm256_storeu_si256(dists.as_mut_ptr().add(16) as *mut __m256i, dis1); } +#[cfg(target_arch = "aarch64")] +#[inline] +unsafe fn sum_dist_table_32bytes_batch_neon(codes: &[u8], dist_table: &[u8], dists: &mut [u16]) { + let low_mask = vdupq_n_u8(0x0f); + + // 8 accumulators: 4 per 128-bit "lane" (lo = bytes 0..16, hi = bytes 16..32 of each block) + let mut accu0_lo = vdupq_n_u16(0); + let mut accu1_lo = vdupq_n_u16(0); + let mut accu2_lo = vdupq_n_u16(0); + let mut accu3_lo = vdupq_n_u16(0); + let mut accu0_hi = vdupq_n_u16(0); + let mut accu1_hi = vdupq_n_u16(0); + let mut accu2_hi = vdupq_n_u16(0); + let mut accu3_hi = vdupq_n_u16(0); + + let codes_ptr = codes.as_ptr(); + let dt_ptr = dist_table.as_ptr(); + + for i in (0..codes.len()).step_by(32) { + // Process lo lane: bytes [i..i+16] + let c_lo = vld1q_u8(codes_ptr.add(i)); + let lut_lo = vld1q_u8(dt_ptr.add(i)); + + let lo_lo = vandq_u8(c_lo, low_mask); + let hi_lo = vshrq_n_u8::<4>(c_lo); + + let res_lo_lo = vqtbl1q_u8(lut_lo, lo_lo); + let res_hi_lo = vqtbl1q_u8(lut_lo, hi_lo); + + accu0_lo = vaddq_u16(accu0_lo, vreinterpretq_u16_u8(res_lo_lo)); + accu1_lo = vaddq_u16(accu1_lo, vshrq_n_u16::<8>(vreinterpretq_u16_u8(res_lo_lo))); + accu2_lo = vaddq_u16(accu2_lo, vreinterpretq_u16_u8(res_hi_lo)); + accu3_lo = vaddq_u16(accu3_lo, vshrq_n_u16::<8>(vreinterpretq_u16_u8(res_hi_lo))); + + // Process hi lane: bytes [i+16..i+32] + let c_hi = vld1q_u8(codes_ptr.add(i + 16)); + let lut_hi = vld1q_u8(dt_ptr.add(i + 16)); + + let lo_hi = vandq_u8(c_hi, low_mask); + let hi_hi = vshrq_n_u8::<4>(c_hi); + + let res_lo_hi = vqtbl1q_u8(lut_hi, lo_hi); + let res_hi_hi = vqtbl1q_u8(lut_hi, hi_hi); + + accu0_hi = vaddq_u16(accu0_hi, vreinterpretq_u16_u8(res_lo_hi)); + accu1_hi = vaddq_u16(accu1_hi, vshrq_n_u16::<8>(vreinterpretq_u16_u8(res_lo_hi))); + accu2_hi = vaddq_u16(accu2_hi, vreinterpretq_u16_u8(res_hi_hi)); + accu3_hi = vaddq_u16(accu3_hi, vshrq_n_u16::<8>(vreinterpretq_u16_u8(res_hi_hi))); + } + + // Merge: clean even bytes by subtracting the odd-byte bleed + accu0_lo = vsubq_u16(accu0_lo, vshlq_n_u16::<8>(accu1_lo)); + accu0_hi = vsubq_u16(accu0_hi, vshlq_n_u16::<8>(accu1_hi)); + + // Cross-lane merge: add lo and hi lane accumulators + // This is the NEON equivalent of AVX2's permute2f128 + blend + add + let dis0_even = vaddq_u16(accu0_lo, accu0_hi); + let dis0_odd = vaddq_u16(accu1_lo, accu1_hi); + vst1q_u16(dists.as_mut_ptr(), dis0_even); + vst1q_u16(dists.as_mut_ptr().add(8), dis0_odd); + + // Same for hi-nibble accumulators (vectors 16..31) + accu2_lo = vsubq_u16(accu2_lo, vshlq_n_u16::<8>(accu3_lo)); + accu2_hi = vsubq_u16(accu2_hi, vshlq_n_u16::<8>(accu3_hi)); + + let dis1_even = vaddq_u16(accu2_lo, accu2_hi); + let dis1_odd = vaddq_u16(accu3_lo, accu3_hi); + vst1q_u16(dists.as_mut_ptr().add(16), dis1_even); + vst1q_u16(dists.as_mut_ptr().add(24), dis1_odd); +} + // We implement the AVX512 version in C because AVX512 is not stable yet in Rust, // implement it in Rust once we upgrade rust to 1.89.0. unsafe extern "C" { @@ -214,4 +302,83 @@ mod tests { // so the distance is 2 * (dist_table[0x6] + dist_table[0xb + 16]) = 2*(7 + 12) = 38 assert_eq!(dists[1], 38); } + + /// Test that the SIMD path (NEON on ARM, AVX2 on x86) produces identical + /// results to the scalar reference across a range of dimensions, including + /// very large ones (up to DIM=65536). + /// + /// Note: dist_table values are capped to avoid u16 overflow, matching + /// production behavior where values are quantized to a small range. + /// (The scalar path uses saturating_add while SIMD uses wrapping add, + /// so they diverge on overflow — but overflow never occurs with real + /// quantized data.) + #[test] + fn test_simd_matches_scalar_varied_dimensions() { + use rand::{Rng, SeedableRng}; + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + + // code_len = dim / 8 for 1-bit quantization; we test various code_lens + // directly since that's what the function sees. + // code_len=16 → DIM=128, code_len=192 → DIM=1536, + // code_len=512 → DIM=4096, code_len=8192 → DIM=65536 + for code_len in [2, 16, 96, 192, 512, 1024, 8192] { + let n = BATCH_SIZE; // 32 vectors per batch + + // Each code byte produces 2 lookups; cap values so + // 2 * code_len * max_val < u16::MAX. + let max_val = (u16::MAX as usize / (2 * code_len)).min(255) as u8; + + let codes: Vec = (0..n * code_len).map(|_| rng.random::()).collect(); + let dist_table: Vec = (0..BATCH_SIZE * code_len) + .map(|_| rng.random_range(0..=max_val)) + .collect(); + + let mut expected = vec![0u16; n]; + sum_4bit_dist_table_scalar(code_len, &codes, &dist_table, &mut expected); + + let mut actual = vec![0u16; n]; + sum_4bit_dist_table(n, code_len, &codes, &dist_table, &mut actual); + + assert_eq!( + actual, + expected, + "SIMD and scalar mismatch for code_len={} (DIM={})", + code_len, + code_len * 8, + ); + } + } + + /// Test with multiple batches to verify accumulation across batch boundaries. + #[test] + fn test_simd_matches_scalar_multi_batch() { + use rand::{Rng, SeedableRng}; + let mut rng = rand::rngs::StdRng::seed_from_u64(123); + + for code_len in [16, 192, 1024] { + let n = BATCH_SIZE * 10; // 320 vectors = 10 batches + + let max_val = (u16::MAX as usize / (2 * code_len)).min(255) as u8; + + let codes: Vec = (0..n * code_len).map(|_| rng.random::()).collect(); + let dist_table: Vec = (0..BATCH_SIZE * code_len) + .map(|_| rng.random_range(0..=max_val)) + .collect(); + + let mut expected = vec![0u16; n]; + sum_4bit_dist_table_scalar(code_len, &codes, &dist_table, &mut expected); + + let mut actual = vec![0u16; n]; + sum_4bit_dist_table(n, code_len, &codes, &dist_table, &mut actual); + + assert_eq!( + actual, + expected, + "SIMD and scalar mismatch for multi-batch code_len={} (DIM={}, n={})", + code_len, + code_len * 8, + n, + ); + } + } } From 682cdd45606a7bea360f78008dcbb7db28d6b842 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Thu, 16 Apr 2026 23:10:30 -0700 Subject: [PATCH 203/206] fix: add dir_listing_to_manifest_migration_enabled flag to avoid extra object store calls (#6507) ## Summary - Adds `dir_listing_to_manifest_migration_enabled` flag (default: `false`) to `DirectoryNamespaceBuilder` and `DirectoryNamespace` - When `false` and both `manifest_enabled` and `dir_listing_enabled` are `true`, root-level table operations (`table_exists`, `describe_table`, `list_tables`) skip the manifest check and use directory listing directly, avoiding extra object store listing calls - When `true`, preserves the existing hybrid behavior of checking manifest first then falling back to directory listing - Includes a test with a counting object store wrapper verifying only a single `list_with_delimiter` call is made for root-level table operations without migration mode --------- Co-authored-by: Claude Opus 4.6 (1M context) --- python/python/tests/test_namespace_dir.py | 1 + rust/lance-core/src/utils/testing.rs | 104 ++++- rust/lance-namespace-impls/src/dir.rs | 372 +++++++++++++++++- .../lance-namespace-impls/src/dir/manifest.rs | 11 +- 4 files changed, 464 insertions(+), 24 deletions(-) diff --git a/python/python/tests/test_namespace_dir.py b/python/python/tests/test_namespace_dir.py index d7271e761b1..1f0a4180d5d 100644 --- a/python/python/tests/test_namespace_dir.py +++ b/python/python/tests/test_namespace_dir.py @@ -815,6 +815,7 @@ def test_connect_with_properties(self, use_custom): "root": f"memory://test_connect_{unique_id}", "manifest_enabled": "true", "dir_listing_enabled": "true", + "dir_listing_to_manifest_migration_enabled": "true", } # Connect via lance.namespace.connect diff --git a/rust/lance-core/src/utils/testing.rs b/rust/lance-core/src/utils/testing.rs index f19d66f67e2..beec69270a5 100644 --- a/rust/lance-core/src/utils/testing.rs +++ b/rust/lance-core/src/utils/testing.rs @@ -17,7 +17,11 @@ use std::collections::HashMap; use std::fmt::Debug; use std::future; use std::ops::Range; -use std::sync::{Arc, Mutex}; +use std::pin::Pin; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; // A policy function takes in the name of the operation (e.g. "put") and the location // that is being accessed / modified and returns an optional error. @@ -121,6 +125,42 @@ impl std::fmt::Display for ProxyObjectStore { } } +/// An object store wrapper that counts listing operations. +/// +/// This increments the shared counter for both `list` and `list_with_delimiter` +/// so tests can observe all listing-based directory and version discovery calls. +#[derive(Debug)] +pub struct CountingObjectStore { + target: Arc, + listing_count: Arc, +} + +impl CountingObjectStore { + pub fn new(target: Arc, listing_count: Arc) -> Self { + Self { + target, + listing_count, + } + } + + fn record_listing(&self) { + self.listing_count.fetch_add(1, Ordering::SeqCst); + } + + fn delegate_list( + &self, + prefix: Option<&Path>, + ) -> Pin> + Send>> { + self.target.list(prefix) + } +} + +impl std::fmt::Display for CountingObjectStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "CountingObjectStore({})", self.target) + } +} + #[async_trait] impl ObjectStore for ProxyObjectStore { async fn put_opts( @@ -204,3 +244,65 @@ impl ObjectStore for ProxyObjectStore { self.target.copy_if_not_exists(from, to).await } } + +#[async_trait] +impl ObjectStore for CountingObjectStore { + async fn put_opts( + &self, + location: &Path, + bytes: PutPayload, + opts: PutOptions, + ) -> OSResult { + self.target.put_opts(location, bytes, opts).await + } + + async fn put_multipart_opts( + &self, + location: &Path, + opts: PutMultipartOptions, + ) -> OSResult> { + self.target.put_multipart_opts(location, opts).await + } + + async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult { + self.target.get_opts(location, options).await + } + + async fn get_range(&self, location: &Path, range: Range) -> OSResult { + self.target.get_range(location, range).await + } + + async fn get_ranges(&self, location: &Path, ranges: &[Range]) -> OSResult> { + self.target.get_ranges(location, ranges).await + } + + async fn head(&self, location: &Path) -> OSResult { + self.target.head(location).await + } + + async fn delete(&self, location: &Path) -> OSResult<()> { + self.target.delete(location).await + } + + fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult> { + self.record_listing(); + self.delegate_list(prefix).boxed() + } + + async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OSResult { + self.record_listing(); + self.target.list_with_delimiter(prefix).await + } + + async fn copy(&self, from: &Path, to: &Path) -> OSResult<()> { + self.target.copy(from, to).await + } + + async fn rename(&self, from: &Path, to: &Path) -> OSResult<()> { + self.target.rename(from, to).await + } + + async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> OSResult<()> { + self.target.copy_if_not_exists(from, to).await + } +} diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index afd9267dffc..4510c0ca90c 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -187,6 +187,11 @@ pub struct DirectoryNamespaceBuilder { /// When true, table versions are stored in the `__manifest` table instead of /// relying on Lance's native version management. table_version_storage_enabled: bool, + /// When true, enables migration mode where the namespace checks the manifest first + /// before falling back to directory listing for root-level tables. When false (default), + /// root-level tables use directory listing directly without checking the manifest, + /// avoiding extra object store calls. + dir_listing_to_manifest_migration_enabled: bool, credential_vendor_properties: HashMap, context_provider: Option>, commit_retries: Option, @@ -221,6 +226,10 @@ impl std::fmt::Debug for DirectoryNamespaceBuilder { "table_version_storage_enabled", &self.table_version_storage_enabled, ) + .field( + "dir_listing_to_manifest_migration_enabled", + &self.dir_listing_to_manifest_migration_enabled, + ) .field( "context_provider", &self.context_provider.as_ref().map(|_| "Some(...)"), @@ -254,6 +263,7 @@ impl DirectoryNamespaceBuilder { inline_optimization_enabled: true, table_version_tracking_enabled: false, // Default to disabled table_version_storage_enabled: false, // Default to disabled + dir_listing_to_manifest_migration_enabled: false, // Default to disabled credential_vendor_properties: HashMap::new(), context_provider: None, commit_retries: None, @@ -281,6 +291,17 @@ impl DirectoryNamespaceBuilder { self } + /// Enable or disable migration mode from directory listing to manifest. + /// + /// When enabled, root-level table operations check the manifest first before + /// falling back to directory listing. When disabled (default), root-level tables + /// use directory listing directly, avoiding extra object store calls. + /// Only relevant when both `manifest_enabled` and `dir_listing_enabled` are true. + pub fn dir_listing_to_manifest_migration_enabled(mut self, enabled: bool) -> Self { + self.dir_listing_to_manifest_migration_enabled = enabled; + self + } + /// Enable or disable inline optimization of the __manifest table. /// /// When enabled (default), performs compaction and indexing on the __manifest table @@ -436,6 +457,12 @@ impl DirectoryNamespaceBuilder { .and_then(|v| v.parse::().ok()) .unwrap_or(false); + // Extract dir_listing_to_manifest_migration_enabled (default: false) + let dir_listing_to_manifest_migration_enabled = properties + .get("dir_listing_to_manifest_migration_enabled") + .and_then(|v| v.parse::().ok()) + .unwrap_or(false); + // Extract credential vendor properties (properties prefixed with "credential_vendor.") // The prefix is stripped to get short property names // The build() method will check if enabled=true before creating the vendor @@ -477,6 +504,7 @@ impl DirectoryNamespaceBuilder { inline_optimization_enabled, table_version_tracking_enabled, table_version_storage_enabled, + dir_listing_to_manifest_migration_enabled, credential_vendor_properties, context_provider: None, commit_retries, @@ -715,6 +743,8 @@ impl DirectoryNamespaceBuilder { base_path, manifest_ns, dir_listing_enabled: self.dir_listing_enabled, + dir_listing_to_manifest_migration_enabled: self + .dir_listing_to_manifest_migration_enabled, table_version_tracking_enabled: self.table_version_tracking_enabled, table_version_storage_enabled: self.table_version_storage_enabled, credential_vendor, @@ -792,6 +822,10 @@ pub struct DirectoryNamespace { base_path: Path, manifest_ns: Option>, dir_listing_enabled: bool, + /// When true, root-level table operations check the manifest first before + /// falling back to directory listing. When false, root-level tables skip + /// the manifest check and use directory listing directly. + dir_listing_to_manifest_migration_enabled: bool, /// When true, `describe_table` returns `managed_versioning: true` to indicate /// commits should go through namespace table version APIs. table_version_tracking_enabled: bool, @@ -954,6 +988,19 @@ impl DirectoryNamespace { Ok(id[0].clone()) } + fn format_table_id(table_id: &[String]) -> String { + format!( + "table id '{}'", + manifest::ManifestNamespace::str_object_id(table_id) + ) + } + + fn format_table_id_from_request(id: &Option>) -> String { + id.as_ref() + .map(|table_id| Self::format_table_id(table_id)) + .unwrap_or_else(|| "table id ''".to_string()) + } + async fn resolve_table_location(&self, id: &Option>) -> Result { let mut describe_req = DescribeTableRequest::new(); describe_req.id = id.clone(); @@ -976,7 +1023,13 @@ impl DirectoryNamespace { &self, request: DescribeTableRequest, ) -> Result { - if let Some(ref manifest_ns) = self.manifest_ns { + let is_root_level = request.id.as_ref().is_some_and(|id| id.len() == 1); + let skip_manifest_for_root = self.dir_listing_enabled + && is_root_level + && !self.dir_listing_to_manifest_migration_enabled; + if let Some(ref manifest_ns) = self.manifest_ns + && !skip_manifest_for_root + { match manifest_ns.describe_table(request.clone()).await { Ok(mut response) => { if let Some(ref table_uri) = response.table_uri { @@ -993,10 +1046,7 @@ impl DirectoryNamespace { } return Ok(response); } - Err(_) - if self.dir_listing_enabled - && request.id.as_ref().is_some_and(|id| id.len() == 1) => - { + Err(_) if self.dir_listing_enabled && is_root_level => { // Fall through to directory check only for single-level IDs } Err(e) => return Err(e), @@ -1004,6 +1054,7 @@ impl DirectoryNamespace { } let table_name = Self::table_name_from_id(&request.id)?; + let table_id = Self::format_table_id_from_request(&request.id); let table_uri = self.table_full_uri(&table_name); // Atomically check table existence and deregistration status @@ -1011,14 +1062,14 @@ impl DirectoryNamespace { if !status.exists { return Err(NamespaceError::TableNotFound { - message: table_name.to_string(), + message: table_id.clone(), } .into()); } if status.is_deregistered { return Err(NamespaceError::InvalidTableState { - message: format!("Table is deregistered: {}", table_name), + message: format!("Table is deregistered: {}", table_id), } .into()); } @@ -2085,8 +2136,12 @@ impl LanceNamespace for DirectoryNamespace { return manifest_ns.list_tables(request).await; } - // When both manifest and directory listing are enabled, we need to merge and deduplicate - let mut tables = if self.manifest_ns.is_some() && self.dir_listing_enabled { + // When both manifest and directory listing are enabled with migration mode, + // we need to merge and deduplicate + let mut tables = if self.manifest_ns.is_some() + && self.dir_listing_enabled + && self.dir_listing_to_manifest_migration_enabled + { // Get all manifest table locations (for deduplication) let manifest_locations = if let Some(ref manifest_ns) = self.manifest_ns { manifest_ns.list_manifest_table_locations().await? @@ -2141,13 +2196,16 @@ impl LanceNamespace for DirectoryNamespace { async fn table_exists(&self, request: TableExistsRequest) -> Result<()> { self.record_op("table_exists"); - if let Some(ref manifest_ns) = self.manifest_ns { + let is_root_level = request.id.as_ref().is_some_and(|id| id.len() == 1); + let skip_manifest_for_root = self.dir_listing_enabled + && is_root_level + && !self.dir_listing_to_manifest_migration_enabled; + if let Some(ref manifest_ns) = self.manifest_ns + && !skip_manifest_for_root + { match manifest_ns.table_exists(request.clone()).await { Ok(()) => return Ok(()), - Err(_) - if self.dir_listing_enabled - && request.id.as_ref().is_some_and(|id| id.len() == 1) => - { + Err(_) if self.dir_listing_enabled && is_root_level => { // Fall through to directory check only for single-level IDs } Err(e) => return Err(e), @@ -2155,20 +2213,21 @@ impl LanceNamespace for DirectoryNamespace { } let table_name = Self::table_name_from_id(&request.id)?; + let table_id = Self::format_table_id_from_request(&request.id); // Atomically check table existence and deregistration status let status = self.check_table_status(&table_name).await; if !status.exists { return Err(NamespaceError::TableNotFound { - message: table_name.to_string(), + message: table_id.clone(), } .into()); } if status.is_deregistered { return Err(NamespaceError::InvalidTableState { - message: format!("Table is deregistered: {}", table_name), + message: format!("Table is deregistered: {}", table_id), } .into()); } @@ -3727,13 +3786,19 @@ mod tests { use lance::dataset::Dataset; use lance::index::DatasetIndexExt; use lance_core::utils::tempfile::{TempStdDir, TempStrDir}; + use lance_core::utils::testing::CountingObjectStore; + use lance_io::object_store::providers::local::FileStoreProvider; use lance_namespace::models::{ CreateTableRequest, JsonArrowDataType, JsonArrowField, JsonArrowSchema, ListTablesRequest, QueryTableRequestColumns, }; use lance_namespace::schema::convert_json_arrow_schema; use std::io::Cursor; - use std::sync::Arc; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + use url::Url; fn assert_plan_contains_all(plan: &str, expected_fragments: &[&str], context: &str) { for expected_fragment in expected_fragments { @@ -3758,6 +3823,56 @@ mod tests { (namespace, temp_dir) } + #[derive(Debug)] + struct CountingFileStoreProvider { + listing_count: Arc, + } + + #[async_trait] + impl lance_io::object_store::ObjectStoreProvider for CountingFileStoreProvider { + async fn new_store( + &self, + base_path: Url, + params: &ObjectStoreParams, + ) -> Result { + let provider = FileStoreProvider; + let mut store = provider.new_store(base_path, params).await?; + store.inner = Arc::new(CountingObjectStore::new( + store.inner.clone(), + self.listing_count.clone(), + )); + Ok(store) + } + + fn extract_path(&self, url: &Url) -> Result { + let provider = FileStoreProvider; + provider.extract_path(url) + } + + fn calculate_object_store_prefix( + &self, + url: &Url, + storage_options: Option<&HashMap>, + ) -> Result { + let provider = FileStoreProvider; + provider.calculate_object_store_prefix(url, storage_options) + } + } + + fn file_object_store_uri(path: &str) -> String { + let path_prefix = if path.starts_with('/') { "" } else { "/" }; + format!("file-object-store://{path_prefix}{path}") + } + + fn build_listing_counting_session(listing_count: Arc) -> Arc { + let registry = Arc::new(ObjectStoreRegistry::default()); + registry.insert( + "file-object-store", + Arc::new(CountingFileStoreProvider { listing_count }), + ); + Arc::new(Session::new(0, 0, registry)) + } + /// Helper to create test IPC data from a schema fn create_test_ipc_data(schema: &JsonArrowSchema) -> Vec { use arrow::ipc::writer::StreamWriter; @@ -5387,6 +5502,7 @@ mod tests { let temp_path = temp_dir.to_str().unwrap(); let namespace = DirectoryNamespaceBuilder::new(temp_path) + .dir_listing_to_manifest_migration_enabled(true) .build() .await .unwrap(); @@ -6087,7 +6203,11 @@ mod tests { // Describe should fail after deregistration let result = namespace.describe_table(describe_req).await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("deregistered")); + let err = result.unwrap_err(); + assert!(matches!(err, Error::Namespace { .. })); + let err_msg = err.to_string(); + assert!(err_msg.contains("deregistered")); + assert!(err_msg.contains("table id 'test_table'")); } #[tokio::test] @@ -6127,7 +6247,11 @@ mod tests { // Table exists should fail after deregistration let result = namespace.table_exists(exists_req).await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("deregistered")); + let err = result.unwrap_err(); + assert!(matches!(err, Error::Namespace { .. })); + let err_msg = err.to_string(); + assert!(err_msg.contains("deregistered")); + assert!(err_msg.contains("table id 'test_table'")); } #[tokio::test] @@ -8333,4 +8457,214 @@ mod tests { "Filtered analyze plan should preserve late materialization and filter pushdown", ); } + + #[tokio::test] + #[cfg_attr( + windows, + ignore = "TODO: https://github.com/lance-format/lance/issues/6557" + )] + async fn test_dir_listing_no_extra_calls_without_migration() { + let temp_dir = TempStdDir::default(); + let temp_path = temp_dir.to_str().unwrap(); + let root_uri = file_object_store_uri(temp_path); + let listing_count = Arc::new(AtomicUsize::new(0)); + let session = build_listing_counting_session(listing_count.clone()); + + // Create a table using dir-listing-only namespace + let dir_only_ns = DirectoryNamespaceBuilder::new(root_uri.clone()) + .session(session.clone()) + .manifest_enabled(false) + .dir_listing_enabled(true) + .build() + .await + .unwrap(); + + let schema = create_test_schema(); + let ipc_data = create_test_ipc_data(&schema); + let mut create_req = CreateTableRequest::new(); + create_req.id = Some(vec!["test_table".to_string()]); + dir_only_ns + .create_table(create_req, Bytes::from(ipc_data)) + .await + .unwrap(); + + // Build a namespace with both enabled but migration disabled (default) + let hybrid_ns = DirectoryNamespaceBuilder::new(root_uri) + .session(session) + .manifest_enabled(true) + .dir_listing_enabled(true) + .dir_listing_to_manifest_migration_enabled(false) + .build() + .await + .unwrap(); + + // Reset counter before the operation we want to measure + listing_count.store(0, Ordering::SeqCst); + + // table_exists should use dir listing directly, making only 1 listing call + let mut exists_req = TableExistsRequest::new(); + exists_req.id = Some(vec!["test_table".to_string()]); + hybrid_ns.table_exists(exists_req).await.unwrap(); + + let count = listing_count.load(Ordering::SeqCst); + assert_eq!( + count, 1, + "Expected exactly 1 listing call for table_exists \ + without migration mode, but got {}", + count + ); + + // Reset and test describe_table + listing_count.store(0, Ordering::SeqCst); + + let mut describe_req = DescribeTableRequest::new(); + describe_req.id = Some(vec!["test_table".to_string()]); + hybrid_ns.describe_table(describe_req).await.unwrap(); + + let count = listing_count.load(Ordering::SeqCst); + assert_eq!( + count, 1, + "Expected exactly 1 listing call for describe_table \ + without migration mode, but got {}", + count + ); + } + + #[tokio::test] + #[cfg_attr( + windows, + ignore = "TODO: https://github.com/lance-format/lance/issues/6557" + )] + async fn test_dir_listing_extra_calls_with_migration() { + let temp_dir = TempStdDir::default(); + let temp_path = temp_dir.to_str().unwrap(); + let root_uri = file_object_store_uri(temp_path); + let listing_count = Arc::new(AtomicUsize::new(0)); + let session = build_listing_counting_session(listing_count.clone()); + + // Create a table using dir-listing-only namespace so it exists physically but is absent from __manifest. + let dir_only_ns = DirectoryNamespaceBuilder::new(root_uri.clone()) + .session(session.clone()) + .manifest_enabled(false) + .dir_listing_enabled(true) + .build() + .await + .unwrap(); + + let schema = create_test_schema(); + let ipc_data = create_test_ipc_data(&schema); + let mut create_req = CreateTableRequest::new(); + create_req.id = Some(vec!["test_table".to_string()]); + dir_only_ns + .create_table(create_req, Bytes::from(ipc_data)) + .await + .unwrap(); + + let hybrid_ns = DirectoryNamespaceBuilder::new(root_uri) + .session(session) + .manifest_enabled(true) + .dir_listing_enabled(true) + .dir_listing_to_manifest_migration_enabled(true) + .build() + .await + .unwrap(); + + // table_exists first checks __manifest (one list on __manifest/_versions), + // then falls back to the table directory (one list_with_delimiter on test_table.lance). + listing_count.store(0, Ordering::SeqCst); + + let mut exists_req = TableExistsRequest::new(); + exists_req.id = Some(vec!["test_table".to_string()]); + hybrid_ns.table_exists(exists_req).await.unwrap(); + + let count = listing_count.load(Ordering::SeqCst); + assert_eq!( + count, 2, + "Expected exactly 2 listing calls for table_exists with migration mode \ + (manifest reload + table directory fallback), but got {}", + count + ); + + // describe_table follows the same path when the table is not yet registered in __manifest. + listing_count.store(0, Ordering::SeqCst); + + let mut describe_req = DescribeTableRequest::new(); + describe_req.id = Some(vec!["test_table".to_string()]); + hybrid_ns.describe_table(describe_req).await.unwrap(); + + let count = listing_count.load(Ordering::SeqCst); + assert_eq!( + count, 2, + "Expected exactly 2 listing calls for describe_table with migration mode \ + (manifest reload + table directory fallback), but got {}", + count + ); + } + + #[tokio::test] + async fn test_migration_not_found_errors_include_table_id() { + let temp_dir = TempStdDir::default(); + let temp_path = temp_dir.to_str().unwrap(); + + let namespace = DirectoryNamespaceBuilder::new(temp_path) + .manifest_enabled(true) + .dir_listing_enabled(true) + .dir_listing_to_manifest_migration_enabled(true) + .build() + .await + .unwrap(); + + let mut exists_req = TableExistsRequest::new(); + exists_req.id = Some(vec!["missing_table".to_string()]); + let err = namespace.table_exists(exists_req).await.unwrap_err(); + assert!(matches!(err, Error::Namespace { .. })); + let err_msg = err.to_string(); + assert!(err_msg.contains("Table not found")); + assert!(err_msg.contains("table id 'missing_table'")); + + let mut describe_req = DescribeTableRequest::new(); + describe_req.id = Some(vec!["missing_table".to_string()]); + let err = namespace.describe_table(describe_req).await.unwrap_err(); + assert!(matches!(err, Error::Namespace { .. })); + let err_msg = err.to_string(); + assert!(err_msg.contains("Table not found")); + assert!(err_msg.contains("table id 'missing_table'")); + } + + #[tokio::test] + async fn test_manifest_not_found_errors_include_full_table_id() { + use lance_namespace::models::CreateNamespaceRequest; + + let temp_dir = TempStdDir::default(); + let temp_path = temp_dir.to_str().unwrap(); + + let namespace = DirectoryNamespaceBuilder::new(temp_path) + .manifest_enabled(true) + .dir_listing_enabled(true) + .build() + .await + .unwrap(); + + let mut create_ns_req = CreateNamespaceRequest::new(); + create_ns_req.id = Some(vec!["workspace".to_string()]); + namespace.create_namespace(create_ns_req).await.unwrap(); + + let missing_table_id = vec!["workspace".to_string(), "missing_table".to_string()]; + + let mut exists_req = TableExistsRequest::new(); + exists_req.id = Some(missing_table_id.clone()); + let err = namespace.table_exists(exists_req).await.unwrap_err(); + assert!(matches!(err, Error::Namespace { .. })); + let err_msg = err.to_string(); + assert!(err_msg.contains("Table not found")); + assert!(err_msg.contains("table id 'workspace$missing_table'")); + + let mut describe_req = DescribeTableRequest::new(); + describe_req.id = Some(missing_table_id); + let err = namespace.describe_table(describe_req).await.unwrap_err(); + assert!(matches!(err, Error::Namespace { .. })); + let err_msg = err.to_string(); + assert!(err_msg.contains("Table not found")); + assert!(err_msg.contains("table id 'workspace$missing_table'")); + } } diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index f0573709d56..eedbab5dc16 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -428,6 +428,10 @@ impl ManifestNamespace { object_id.join(DELIMITER) } + fn format_table_id(table_id: &[String]) -> String { + format!("table id '{}'", Self::str_object_id(table_id)) + } + /// Format a version number as a zero-padded lexicographically sortable string. /// /// Versions are stored as 20-digit zero-padded integers (e.g., `00000000000000000001` @@ -1903,7 +1907,7 @@ impl LanceNamespace for ManifestNamespace { } } None => Err(NamespaceError::TableNotFound { - message: object_id.to_string(), + message: Self::format_table_id(table_id), } .into()), } @@ -1923,14 +1927,13 @@ impl LanceNamespace for ManifestNamespace { .into()); } - let (namespace, table_name) = Self::split_object_id(table_id); - let object_id = Self::build_object_id(&namespace, &table_name); + let object_id = Self::str_object_id(table_id); let exists = self.manifest_contains_object(&object_id).await?; if exists { Ok(()) } else { Err(NamespaceError::TableNotFound { - message: table_name.to_string(), + message: Self::format_table_id(table_id), } .into()) } From 9d3d1ef58343f7e5925860b6488928df7cf1066a Mon Sep 17 00:00:00 2001 From: Weston Pace Date: Fri, 17 Apr 2026 15:21:38 -0700 Subject: [PATCH 204/206] fix: scale default memory pool size by partition count (#6562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The `FairSpillPool` divides memory evenly across spillable consumers. With up to 8 partitions, each sort consumer was limited to ~12.5MB from a flat 100MB pool, causing merge_insert operations with large payloads to fail with "not enough memory to continue external sort" at very small batch sizes (e.g. 5 rows with 1MB payloads). - Scale the default pool size to 100MB **per partition** so each consumer gets a reasonable allocation. Explicit `LANCE_MEM_POOL_SIZE` or `mem_pool_size` settings are respected as-is. - This is a partial fix — very large batches can still exhaust the per-partition budget. A more complete fix may involve revisiting the pool type or spilling behavior for merge_insert. ## Test plan - [x] Added unit test `test_mem_pool_size_scales_with_partitions` verifying pool size scales correctly - [x] Verified with a Python repro script that merge_insert with 1MB-per-row payloads no longer fails at 5 rows (now succeeds up to ~50 rows) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- rust/lance-datafusion/src/exec.rs | 38 ++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/rust/lance-datafusion/src/exec.rs b/rust/lance-datafusion/src/exec.rs index b3a98275853..108ec8f5fad 100644 --- a/rust/lance-datafusion/src/exec.rs +++ b/rust/lance-datafusion/src/exec.rs @@ -313,21 +313,22 @@ impl std::fmt::Debug for LanceExecutionOptions { } } -const DEFAULT_LANCE_MEM_POOL_SIZE: u64 = 100 * 1024 * 1024; +const DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION: u64 = 100 * 1024 * 1024; const DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE: u64 = 100 * 1024 * 1024 * 1024; // 100GB impl LanceExecutionOptions { pub fn mem_pool_size(&self) -> u64 { + let num_partitions = self.target_partition.unwrap_or(1) as u64; self.mem_pool_size.unwrap_or_else(|| { std::env::var("LANCE_MEM_POOL_SIZE") .map(|s| match s.parse::() { Ok(v) => v, Err(e) => { warn!("Failed to parse LANCE_MEM_POOL_SIZE: {}, using default", e); - DEFAULT_LANCE_MEM_POOL_SIZE + DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION * num_partitions } }) - .unwrap_or(DEFAULT_LANCE_MEM_POOL_SIZE) + .unwrap_or(DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION * num_partitions) }) } @@ -1051,4 +1052,35 @@ mod tests { ); } } + + #[test] + fn test_mem_pool_size_scales_with_partitions() { + let default_per_partition = DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION; + + // No partitions specified → defaults to 1 partition + let opts = LanceExecutionOptions::default(); + assert_eq!(opts.mem_pool_size(), default_per_partition); + + // 4 partitions → 4x the per-partition size + let opts = LanceExecutionOptions { + target_partition: Some(4), + ..Default::default() + }; + assert_eq!(opts.mem_pool_size(), default_per_partition * 4); + + // 8 partitions → 8x the per-partition size + let opts = LanceExecutionOptions { + target_partition: Some(8), + ..Default::default() + }; + assert_eq!(opts.mem_pool_size(), default_per_partition * 8); + + // Explicit mem_pool_size is not scaled + let opts = LanceExecutionOptions { + mem_pool_size: Some(50 * 1024 * 1024), + target_partition: Some(8), + ..Default::default() + }; + assert_eq!(opts.mem_pool_size(), 50 * 1024 * 1024); + } } From f3206e0f36b25df175b4374e1a040a62eec75884 Mon Sep 17 00:00:00 2001 From: YangJie Date: Sun, 19 Apr 2026 14:19:26 +0800 Subject: [PATCH 205/206] ci: pin nightly toolchain to 2026-04-16 for ethnum 1.5.2 compat (#6570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The `linux-build` CI job installs unpinned `nightly`, which broke after 2026-04-17 because `ethnum 1.5.2` uses `unsafe { mem::transmute(()) }` to create `TryFromIntError`. Newer nightly builds reject this with `error[E0512]: cannot transmute between types of different sizes`. Dependency chain: `lance-arrow` → `jsonb 0.5.6` → `ethnum 1.5.2` This PR pins `nightly-2026-04-16` (last known-good date) in both the toolchain install step and the `cargo +nightly` invocation. ## Root Cause `ethnum-1.5.2/src/error.rs:16`: ```rust pub const fn tfie() -> TryFromIntError { unsafe { mem::transmute(()) } // () is 0 bits, TryFromIntError is 8 bits } ``` Rust nightly `e9e32aca5` (2026-04-17) tightened `transmute` checks, making this a hard error. ## Follow-up - Upstream fix needed in [`nlordell/ethnum-rs`](https://github.com/nlordell/ethnum-rs) to replace the transmute hack - Once `ethnum` publishes a fix and `jsonb` picks it up, the pin can be removed ## Test plan - [x] `linux-build` job passes with pinned nightly --- .github/workflows/rust.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a771a36af4c..6ba6a1723fc 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -94,11 +94,12 @@ jobs: RUSTFLAGS: "-D warnings" steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # pin the toolchain version to avoid surprises + # pin the toolchain version to avoid surprises (ethnum 1.5.2 + # fails on nightly > 2026-04-16 due to a transmute size check) - name: Setup rust toolchain run: | - rustup toolchain install nightly - rustup default nightly + rustup toolchain install nightly-2026-04-16 + rustup default nightly-2026-04-16 - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Install dependencies @@ -112,7 +113,7 @@ jobs: - name: Run tests run: | ALL_FEATURES=`cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v -e protoc -e slow_tests | sort | uniq | paste -s -d "," -` - cargo +nightly llvm-cov --profile ci --locked --workspace --codecov --output-path coverage.codecov --features ${ALL_FEATURES} + cargo +nightly-2026-04-16 llvm-cov --profile ci --locked --workspace --codecov --output-path coverage.codecov --features ${ALL_FEATURES} - name: Upload coverage to Codecov uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 with: From dec39edd8d312510f340e4ab7f0a114d25c8510e Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Sat, 18 Apr 2026 22:39:41 -0700 Subject: [PATCH 206/206] commit --- rust/lance-namespace-impls/src/dir.rs | 148 ++++++++++----- .../lance-namespace-impls/src/dir/manifest.rs | 87 ++++----- rust/lance-namespace-impls/src/rest.rs | 169 +++++------------- .../lance-namespace-impls/src/rest_adapter.rs | 67 +++---- rust/lance-namespace/src/error.rs | 16 +- 5 files changed, 219 insertions(+), 268 deletions(-) diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index 4510c0ca90c..1dbfe2b6cfd 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -783,7 +783,7 @@ impl DirectoryNamespaceBuilder { .await .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to create object store: {}", e), + message: format!("Failed to create object store: {:?}", e), }) })?; @@ -927,7 +927,7 @@ impl DirectoryNamespace { .await .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to list directory: {}", e), + message: format!("Failed to list directory: {:?}", e), }) })?; @@ -1068,7 +1068,7 @@ impl DirectoryNamespace { } if status.is_deregistered { - return Err(NamespaceError::InvalidTableState { + return Err(NamespaceError::TableNotFound { message: format!("Table is deregistered: {}", table_id), } .into()); @@ -1118,7 +1118,17 @@ impl DirectoryNamespace { Ok(mut dataset) => { // If a specific version is requested, checkout that version if let Some(requested_version) = request.version { - dataset = dataset.checkout_version(requested_version as u64).await?; + dataset = dataset + .checkout_version(requested_version as u64) + .await + .map_err(|e| { + lance_core::Error::from(NamespaceError::TableVersionNotFound { + message: format!( + "Version {} not found for table '{}': {}", + requested_version, table_name, e + ), + }) + })?; } let version_info = dataset.version(); @@ -1523,7 +1533,7 @@ impl DirectoryNamespace { .read_transaction_by_version(version) .await .map_err(|e| { - lance_core::Error::from(NamespaceError::Internal { + lance_core::Error::from(NamespaceError::TransactionNotFound { message: format!( "Failed to read transaction for version {}: {}", version, e @@ -1657,7 +1667,7 @@ impl DirectoryNamespace { | Err(ObjectStoreError::Precondition { .. }) => { Err(format!("{} already exists", file_description)) } - Err(e) => Err(format!("Failed to create {}: {}", file_description, e)), + Err(e) => Err(format!("Failed to create {}: {:?}", file_description, e)), } } @@ -2226,7 +2236,7 @@ impl LanceNamespace for DirectoryNamespace { } if status.is_deregistered { - return Err(NamespaceError::InvalidTableState { + return Err(NamespaceError::TableNotFound { message: format!("Table is deregistered: {}", table_id), } .into()); @@ -2250,7 +2260,7 @@ impl LanceNamespace for DirectoryNamespace { .await .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to drop table {}: {}", table_name, e), + message: format!("Failed to drop table {}: {:?}", table_name, e), }) })?; @@ -2284,7 +2294,7 @@ impl LanceNamespace for DirectoryNamespace { let cursor = Cursor::new(request_data.to_vec()); let stream_reader = StreamReader::try_new(cursor, None).map_err(|e| { lance_core::Error::from(NamespaceError::InvalidInput { - message: format!("Invalid Arrow IPC stream: {}", e), + message: format!("Invalid Arrow IPC stream: {:?}", e), }) })?; let arrow_schema = stream_reader.schema(); @@ -2294,7 +2304,7 @@ impl LanceNamespace for DirectoryNamespace { for batch_result in stream_reader { batches.push(batch_result.map_err(|e| { lance_core::Error::from(NamespaceError::InvalidInput { - message: format!("Failed to read batch from IPC stream: {}", e), + message: format!("Failed to read batch from IPC stream: {:?}", e), }) })?); } @@ -2326,9 +2336,17 @@ impl LanceNamespace for DirectoryNamespace { Dataset::write(reader, &table_uri, Some(write_params)) .await .map_err(|e| { - lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to create Lance dataset: {}", e), - }) + let err_msg = format!("{}", e); + let ns_err = if err_msg.contains("already exists") { + NamespaceError::TableAlreadyExists { + message: format!("Table already exists at '{}': {:?}", table_uri, e), + } + } else { + NamespaceError::Internal { + message: format!("Failed to create Lance dataset: {:?}", e), + } + }; + lance_core::Error::from(ns_err) })?; Ok(CreateTableResponse { @@ -2466,7 +2484,7 @@ impl LanceNamespace for DirectoryNamespace { } if status.is_deregistered { - return Err(NamespaceError::InvalidTableState { + return Err(NamespaceError::TableNotFound { message: format!("Table is already deregistered: {}", table_name), } .into()); @@ -2759,8 +2777,8 @@ impl LanceNamespace for DirectoryNamespace { builder = builder.with_session(sess.clone()); } let mut dataset = builder.load().await.map_err(|e| { - lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to open table at '{}': {}", table_uri, e), + lance_core::Error::from(NamespaceError::TableNotFound { + message: format!("Failed to open table at '{}': {:?}", table_uri, e), }) })?; @@ -2905,16 +2923,36 @@ impl LanceNamespace for DirectoryNamespace { ) .await .map_err(|e| { - lance_core::Error::from(NamespaceError::Internal { - message: format!( - "Failed to create {} index '{}' on column '{}' for table '{}': {}", - request.index_type, - request.name.as_deref().unwrap_or(""), - request.column, - table_uri, - e - ), - }) + let err_msg = format!("{}", e); + let ns_err = if err_msg.contains("already exists") { + NamespaceError::TableIndexAlreadyExists { + message: format!( + "Index '{}' already exists on table '{}': {:?}", + request.name.as_deref().unwrap_or(""), + table_uri, + e + ), + } + } else if err_msg.contains("not found") || err_msg.contains("does not exist") { + NamespaceError::TableColumnNotFound { + message: format!( + "Column '{}' not found for table '{}': {:?}", + request.column, table_uri, e + ), + } + } else { + NamespaceError::Internal { + message: format!( + "Failed to create {} index '{}' on column '{}' for table '{}': {:?}", + request.index_type, + request.name.as_deref().unwrap_or(""), + request.column, + table_uri, + e + ), + } + }; + lance_core::Error::from(ns_err) })?; let transaction_id = dataset @@ -2947,7 +2985,7 @@ impl LanceNamespace for DirectoryNamespace { .await .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to describe table indices for '{}': {}", table_uri, e), + message: format!("Failed to describe table indices for '{}': {:?}", table_uri, e), }) })? .into_iter() @@ -3018,7 +3056,7 @@ impl LanceNamespace for DirectoryNamespace { .load_indices_by_name(index_name) .await .map_err(|e| { - lance_core::Error::from(NamespaceError::Internal { + lance_core::Error::from(NamespaceError::TableIndexNotFound { message: format!( "Failed to load index '{}' metadata for table '{}': {}", index_name, table_uri, e @@ -3035,7 +3073,7 @@ impl LanceNamespace for DirectoryNamespace { let stats = ::index_statistics(&dataset, index_name) .await .map_err(|e| { - lance_core::Error::from(NamespaceError::Internal { + lance_core::Error::from(NamespaceError::TableIndexNotFound { message: format!( "Failed to describe index statistics for '{}' on table '{}': {}", index_name, table_uri, e @@ -3126,7 +3164,7 @@ impl LanceNamespace for DirectoryNamespace { .load_indices_by_name(index_name) .await .map_err(|e| { - lance_core::Error::from(NamespaceError::Internal { + lance_core::Error::from(NamespaceError::TableIndexNotFound { message: format!( "Failed to load index '{}' before dropping it from table '{}': {}", index_name, table_uri, e @@ -3144,7 +3182,7 @@ impl LanceNamespace for DirectoryNamespace { } dataset.drop_index(index_name).await.map_err(|e| { - lance_core::Error::from(NamespaceError::Internal { + lance_core::Error::from(NamespaceError::TableIndexNotFound { message: format!( "Failed to drop index '{}' from table '{}': {}", index_name, table_uri, e @@ -3455,7 +3493,7 @@ impl LanceNamespace for DirectoryNamespace { .count_rows(request.predicate) .await .map_err(|e| NamespaceError::Internal { - message: format!("Failed to count rows for table at '{}': {}", table_uri, e), + message: format!("Failed to count rows for table at '{}': {:?}", table_uri, e), })?; Ok(count as i64) @@ -3480,14 +3518,14 @@ impl LanceNamespace for DirectoryNamespace { let cursor = Cursor::new(request_data.as_ref()); let stream_reader = StreamReader::try_new(cursor, None).map_err(|e| NamespaceError::InvalidInput { - message: format!("Invalid Arrow IPC stream: {}", e), + message: format!("Invalid Arrow IPC stream: {:?}", e), })?; let arrow_schema = stream_reader.schema(); let mut batches = Vec::new(); for batch_result in stream_reader { - batches.push(batch_result.map_err(|e| NamespaceError::Internal { - message: format!("Failed to read batch from IPC stream: {}", e), + batches.push(batch_result.map_err(|e| NamespaceError::InvalidInput { + message: format!("Failed to read batch from IPC stream: {:?}", e), })?); } @@ -3531,8 +3569,20 @@ impl LanceNamespace for DirectoryNamespace { Dataset::write(reader, &table_uri, Some(write_params)) .await - .map_err(|e| NamespaceError::Internal { - message: format!("Failed to insert into table at '{}': {}", table_uri, e), + .map_err(|e| { + let err_msg = format!("{}", e); + if err_msg.contains("conflict") || err_msg.contains("CommitConflict") { + NamespaceError::ConcurrentModification { + message: format!( + "Concurrent modification on table at '{}': {:?}", + table_uri, e + ), + } + } else { + NamespaceError::Internal { + message: format!("Failed to insert into table at '{}': {:?}", table_uri, e), + } + } })?; Ok(InsertIntoTableResponse { @@ -3600,7 +3650,7 @@ impl LanceNamespace for DirectoryNamespace { scanner .nearest(vector_column, &query_array, k) .map_err(|e| NamespaceError::InvalidInput { - message: format!("Invalid vector search: {}", e), + message: format!("Invalid vector search: {:?}", e), })?; // Apply distance type if specified @@ -3665,14 +3715,14 @@ impl LanceNamespace for DirectoryNamespace { fts = fts .with_columns(columns) .map_err(|e| NamespaceError::InvalidInput { - message: format!("Invalid FTS columns: {}", e), + message: format!("Invalid FTS columns: {:?}", e), })?; } scanner .full_text_search(fts) .map_err(|e| NamespaceError::InvalidInput { - message: format!("Invalid full text search: {}", e), + message: format!("Invalid full text search: {:?}", e), })?; } // Note: structured_query would require more complex parsing @@ -3687,7 +3737,7 @@ impl LanceNamespace for DirectoryNamespace { scanner .project(column_names) .map_err(|e| NamespaceError::InvalidInput { - message: format!("Invalid column projection: {}", e), + message: format!("Invalid column projection: {:?}", e), })?; } else if let Some(ref column_aliases) = columns.column_aliases && !column_aliases.is_empty() @@ -3705,7 +3755,7 @@ impl LanceNamespace for DirectoryNamespace { .collect::>(), ) .map_err(|e| NamespaceError::InvalidInput { - message: format!("Invalid column alias expression: {}", e), + message: format!("Invalid column alias expression: {:?}", e), })?; } } @@ -3717,7 +3767,7 @@ impl LanceNamespace for DirectoryNamespace { scanner .filter(filter) .map_err(|e| NamespaceError::InvalidInput { - message: format!("Invalid filter expression: {}", e), + message: format!("Invalid filter expression: {:?}", e), })?; } @@ -3733,7 +3783,7 @@ impl LanceNamespace for DirectoryNamespace { let offset = request.offset.map(|o| o as i64); scanner.limit(Some(request.k as i64), offset).map_err(|e| { NamespaceError::InvalidInput { - message: format!("Invalid limit/offset: {}", e), + message: format!("Invalid limit/offset: {:?}", e), } })?; } else if has_vector_query && request.offset.is_some() { @@ -3742,7 +3792,7 @@ impl LanceNamespace for DirectoryNamespace { scanner .limit(None, offset) .map_err(|e| NamespaceError::InvalidInput { - message: format!("Invalid offset: {}", e), + message: format!("Invalid offset: {:?}", e), })?; } @@ -3751,7 +3801,7 @@ impl LanceNamespace for DirectoryNamespace { .try_into_batch() .await .map_err(|e| NamespaceError::Internal { - message: format!("Failed to execute query: {}", e), + message: format!("Failed to execute query: {:?}", e), })?; // Serialize to Arrow IPC file format @@ -3760,14 +3810,14 @@ impl LanceNamespace for DirectoryNamespace { { let mut writer = FileWriter::try_new(&mut buffer, &schema).map_err(|e| { NamespaceError::Internal { - message: format!("Failed to create IPC writer: {}", e), + message: format!("Failed to create IPC writer: {:?}", e), } })?; writer.write(&batch).map_err(|e| NamespaceError::Internal { - message: format!("Failed to write batch to IPC: {}", e), + message: format!("Failed to write batch to IPC: {:?}", e), })?; writer.finish().map_err(|e| NamespaceError::Internal { - message: format!("Failed to finish IPC writer: {}", e), + message: format!("Failed to finish IPC writer: {:?}", e), })?; } diff --git a/rust/lance-namespace-impls/src/dir/manifest.rs b/rust/lance-namespace-impls/src/dir/manifest.rs index eedbab5dc16..8a6a8ee0d85 100644 --- a/rust/lance-namespace-impls/src/dir/manifest.rs +++ b/rust/lance-namespace-impls/src/dir/manifest.rs @@ -182,7 +182,7 @@ impl DatasetConsistencyWrapper { ); let latest_version = read_guard.latest_version_id().await.map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to get latest version: {}", e), + message: format!("Failed to get latest version: {:?}", e), }) })?; log::debug!( @@ -205,14 +205,14 @@ impl DatasetConsistencyWrapper { // Double-check after acquiring write lock (someone else might have reloaded) let latest_version = write_guard.latest_version_id().await.map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to get latest version: {}", e), + message: format!("Failed to get latest version: {:?}", e), }) })?; if latest_version != write_guard.version().version { write_guard.checkout_latest().await.map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to checkout latest: {}", e), + message: format!("Failed to checkout latest: {:?}", e), }) })?; } @@ -293,15 +293,15 @@ impl std::fmt::Debug for ManifestNamespace { /// Convert a Lance commit error to an appropriate namespace error. /// /// Maps lance commit errors to namespace errors: -/// - `CommitConflict`: version collision retries exhausted -> Throttled (safe to retry) +/// - `CommitConflict`: version collision retries exhausted -> Throttling (safe to retry) /// - `TooMuchWriteContention`: RetryableCommitConflict (semantic conflict) retries exhausted -> ConcurrentModification /// - `IncompatibleTransaction`: incompatible concurrent change -> ConcurrentModification /// - Errors containing "matched/duplicate/already exists": ConcurrentModification (from WhenMatched::Fail) /// - Other errors: IO error with the operation description fn convert_lance_commit_error(e: &LanceError, operation: &str, object_id: Option<&str>) -> Error { match e { - // CommitConflict: version collision retries exhausted -> Throttled (safe to retry) - LanceError::CommitConflict { .. } => NamespaceError::Throttled { + // CommitConflict: version collision retries exhausted -> Throttling (safe to retry) + LanceError::CommitConflict { .. } => NamespaceError::Throttling { message: format!("Too many concurrent writes, please retry later: {:?}", e), } .into(), @@ -703,7 +703,7 @@ impl ManifestNamespace { async fn execute_scanner(scanner: Scanner) -> Result> { let mut stream = scanner.try_into_stream().await.map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to create stream: {}", e), + message: format!("Failed to create stream: {:?}", e), }) })?; @@ -711,7 +711,7 @@ impl ManifestNamespace { while let Some(batch) = stream.next().await { batches.push(batch.map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to read batch: {}", e), + message: format!("Failed to read batch: {:?}", e), }) })?); } @@ -746,14 +746,14 @@ impl ManifestNamespace { scanner.filter(&filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; // Project no columns and enable row IDs for count_rows to work scanner.project::<&str>(&[]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; @@ -761,7 +761,7 @@ impl ManifestNamespace { let count = scanner.count_rows().await.map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to count rows: {}", e), + message: format!("Failed to count rows: {:?}", e), }) })?; @@ -775,12 +775,12 @@ impl ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; scanner.project(&["object_id", "location"]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -825,12 +825,12 @@ impl ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; scanner.project(&["location"]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; @@ -944,7 +944,7 @@ impl ManifestNamespace { ) .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to create manifest entries: {}", e), + message: format!("Failed to create manifest entries: {:?}", e), }) })?; @@ -960,7 +960,7 @@ impl ManifestNamespace { MergeInsertBuilder::try_new(dataset_arc, vec!["object_id".to_string()]).map_err( |e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to create merge builder: {}", e), + message: format!("Failed to create merge builder: {:?}", e), }) }, )?; @@ -985,7 +985,7 @@ impl ManifestNamespace { .try_build() .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to build merge: {}", e), + message: format!("Failed to build merge: {:?}", e), }) })? .execute_reader(Box::new(reader)) @@ -1065,12 +1065,12 @@ impl ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; scanner.project(&["object_id", "metadata"]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -1133,12 +1133,12 @@ impl ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; scanner.project(&["metadata"]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -1195,12 +1195,12 @@ impl ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; scanner.project(&["object_id"]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -1272,12 +1272,12 @@ impl ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; scanner.project(&["object_id"]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -1480,12 +1480,12 @@ impl ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; scanner.project(&["object_id", "metadata"]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; let batches = Self::execute_scanner(scanner).await?; @@ -1594,13 +1594,16 @@ impl ManifestNamespace { .update("object_id", [(LANCE_UNENFORCED_PRIMARY_KEY_POSITION, "0")]) .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to find object_id field for migration: {}", e), + message: format!( + "Failed to find object_id field for migration: {:?}", + e + ), }) })? .await .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to migrate primary key metadata: {}", e), + message: format!("Failed to migrate primary key metadata: {:?}", e), }) })?; } @@ -1707,7 +1710,7 @@ impl ManifestNamespace { Ok(DatasetConsistencyWrapper::new(dataset)) } Err(e) => Err(lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to create manifest dataset: {}", e), + message: format!("Failed to create manifest dataset: {:?}", e), })), } } @@ -1785,12 +1788,12 @@ impl LanceNamespace for ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; scanner.project(&["object_id"]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; @@ -1992,7 +1995,7 @@ impl LanceNamespace for ManifestNamespace { let cursor = Cursor::new(data.to_vec()); let stream_reader = StreamReader::try_new(cursor, None).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to read IPC stream: {}", e), + message: format!("Failed to read IPC stream: {:?}", e), }) })?; @@ -2000,7 +2003,7 @@ impl LanceNamespace for ManifestNamespace { .collect::, _>>() .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to collect batches: {}", e), + message: format!("Failed to collect batches: {:?}", e), }) })?; @@ -2035,7 +2038,7 @@ impl LanceNamespace for ManifestNamespace { .await .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to write dataset: {}", e), + message: format!("Failed to write dataset: {:?}", e), }) })?; @@ -2087,7 +2090,7 @@ impl LanceNamespace for ManifestNamespace { .await .map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to delete table directory: {}", e), + message: format!("Failed to delete table directory: {:?}", e), }) })?; @@ -2132,12 +2135,12 @@ impl LanceNamespace for ManifestNamespace { let mut scanner = self.manifest_scanner().await?; scanner.filter(&filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; scanner.project(&["object_id"]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; @@ -2290,18 +2293,18 @@ impl LanceNamespace for ManifestNamespace { let mut scanner = self.manifest_scanner().boxed().await?; scanner.filter(&filter).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to filter: {}", e), + message: format!("Failed to filter: {:?}", e), }) })?; scanner.project::<&str>(&[]).map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to project: {}", e), + message: format!("Failed to project: {:?}", e), }) })?; scanner.with_row_id(); let count = scanner.count_rows().boxed().await.map_err(|e| { lance_core::Error::from(NamespaceError::Internal { - message: format!("Failed to count rows: {}", e), + message: format!("Failed to count rows: {:?}", e), }) })?; diff --git a/rust/lance-namespace-impls/src/rest.rs b/rust/lance-namespace-impls/src/rest.rs index 3ef3221f377..80766632a25 100644 --- a/rust/lance-namespace-impls/src/rest.rs +++ b/rust/lance-namespace-impls/src/rest.rs @@ -32,7 +32,7 @@ use lance_namespace::models::{ DescribeTableVersionRequest, DescribeTableVersionResponse, DescribeTransactionRequest, DescribeTransactionResponse, DropNamespaceRequest, DropNamespaceResponse, DropTableIndexRequest, DropTableIndexResponse, DropTableRequest, DropTableResponse, - ExplainTableQueryPlanRequest, GetTableStatsRequest, GetTableStatsResponse, + ErrorResponse, ExplainTableQueryPlanRequest, GetTableStatsRequest, GetTableStatsResponse, GetTableTagVersionRequest, GetTableTagVersionResponse, InsertIntoTableRequest, InsertIntoTableResponse, ListNamespacesRequest, ListNamespacesResponse, ListTableIndicesRequest, ListTableIndicesResponse, ListTableTagsRequest, ListTableTagsResponse, @@ -534,93 +534,38 @@ impl RestNamespace { } } - /// Parse an error response body and return the appropriate NamespaceError. - /// - /// Attempts to parse a JSON body with `{"error": {"code": N, "message": "..."}}`. - /// Falls back to mapping the HTTP status code (using the operation to disambiguate) - /// if the JSON body doesn't contain an error code. - fn parse_error_response( - status: reqwest::StatusCode, - content: &str, - operation: &str, - ) -> lance_core::Error { - if let Ok(json) = serde_json::from_str::(content) - && let Some(error_obj) = json.get("error") - { - let code = error_obj - .get("code") - .and_then(|c| c.as_u64()) - .map(|c| c as u32); - let message = error_obj - .get("message") - .and_then(|m| m.as_str()) - .unwrap_or(content); - - if let Some(code) = code { - return NamespaceError::from_code(code, message).into(); - } - } - - let message = format!("Response error: status={}, content={}", status, content); - Self::error_from_status(status, operation, message).into() - } - - /// Map an HTTP status code to a NamespaceError variant. + /// Map a reqwest::Error to the appropriate NamespaceError variant. /// - /// For unambiguous status codes (401, 403, 429, 501, 503) the mapping is direct. - /// For 404 and 409 the `operation` string is used to select the appropriate - /// "not found" or "already exists" variant. - fn error_from_status( - status: reqwest::StatusCode, - operation: &str, - message: String, - ) -> NamespaceError { - match status.as_u16() { - 400 => NamespaceError::InvalidInput { message }, - 401 => NamespaceError::Unauthenticated { message }, - 403 => NamespaceError::PermissionDenied { message }, - 404 => Self::not_found_for_operation(operation, message), - 409 => Self::already_exists_for_operation(operation, message), - 429 => NamespaceError::Throttled { message }, - 501 => NamespaceError::Unsupported { message }, - 503 => NamespaceError::ServiceUnavailable { message }, - _ => NamespaceError::Internal { message }, - } - } - - /// Pick the appropriate "not found" variant based on the operation. - fn not_found_for_operation(operation: &str, message: String) -> NamespaceError { - if operation.contains("namespace") { - NamespaceError::NamespaceNotFound { message } - } else if operation.contains("index") { - NamespaceError::TableIndexNotFound { message } - } else if operation.contains("tag") { - NamespaceError::TableTagNotFound { message } - } else if operation.contains("transaction") { - NamespaceError::TransactionNotFound { message } - } else if operation.contains("version") { - NamespaceError::TableVersionNotFound { message } - } else if operation.contains("column") { - NamespaceError::TableColumnNotFound { message } - } else if operation.contains("table") { - NamespaceError::TableNotFound { message } + /// Timeout and connection errors are mapped to `ServiceUnavailable`, + /// while other errors are mapped to `Internal`. + fn request_error(e: reqwest::Error) -> lance_core::Error { + let message = format!("Failed to execute request: {:?}", e); + if e.is_timeout() || e.is_connect() { + NamespaceError::ServiceUnavailable { message }.into() } else { - NamespaceError::Internal { message } + NamespaceError::Internal { message }.into() } } - /// Pick the appropriate "already exists" variant based on the operation. - fn already_exists_for_operation(operation: &str, message: String) -> NamespaceError { - if operation.contains("namespace") { - NamespaceError::NamespaceAlreadyExists { message } - } else if operation.contains("index") { - NamespaceError::TableIndexAlreadyExists { message } - } else if operation.contains("tag") { - NamespaceError::TableTagAlreadyExists { message } - } else if operation.contains("table") { - NamespaceError::TableAlreadyExists { message } - } else { - NamespaceError::Internal { message } + /// Parse an error response body and return the appropriate NamespaceError. + /// + /// Deserializes the response as an `ErrorResponse` model (the spec-defined + /// flat JSON format with a required numeric `code` field). The error code is + /// the sole source of truth for error classification. When deserialization + /// fails, returns Internal with the raw response as context. + fn parse_error_response(status: reqwest::StatusCode, content: &str) -> lance_core::Error { + match serde_json::from_str::(content) { + Ok(err_resp) => { + let message = err_resp.error.as_deref().unwrap_or(content); + NamespaceError::from_code(err_resp.code as u32, message).into() + } + Err(e) => NamespaceError::Internal { + message: format!( + "Failed to parse error response: status={}, body={}, error={:?}", + status, content, e + ), + } + .into(), } } @@ -639,28 +584,24 @@ impl RestNamespace { .rest_client .execute(req_builder, operation, object_id) .await - .map_err(|e| { - Error::from(NamespaceError::Internal { - message: format!("Failed to execute request: {}", e), - }) - })?; + .map_err(Self::request_error)?; let status = resp.status(); let content = resp.text().await.map_err(|e| { Error::from(NamespaceError::Internal { - message: format!("Failed to read response body: {}", e), + message: format!("Failed to read response body: {:?}", e), }) })?; if status.is_success() { serde_json::from_str(&content).map_err(|e| { NamespaceError::Internal { - message: format!("Failed to parse response: {}", e), + message: format!("Failed to parse response: {:?}", e), } .into() }) } else { - Err(Self::parse_error_response(status, &content, operation)) + Err(Self::parse_error_response(status, &content)) } } @@ -680,28 +621,24 @@ impl RestNamespace { .rest_client .execute(req_builder, operation, object_id) .await - .map_err(|e| { - Error::from(NamespaceError::Internal { - message: format!("Failed to execute request: {}", e), - }) - })?; + .map_err(Self::request_error)?; let status = resp.status(); let content = resp.text().await.map_err(|e| { Error::from(NamespaceError::Internal { - message: format!("Failed to read response body: {}", e), + message: format!("Failed to read response body: {:?}", e), }) })?; if status.is_success() { serde_json::from_str(&content).map_err(|e| { NamespaceError::Internal { - message: format!("Failed to parse response: {}", e), + message: format!("Failed to parse response: {:?}", e), } .into() }) } else { - Err(Self::parse_error_response(status, &content, operation)) + Err(Self::parse_error_response(status, &content)) } } @@ -721,11 +658,7 @@ impl RestNamespace { .rest_client .execute(req_builder, operation, object_id) .await - .map_err(|e| { - Error::from(NamespaceError::Internal { - message: format!("Failed to execute request: {}", e), - }) - })?; + .map_err(Self::request_error)?; let status = resp.status(); if status.is_success() { @@ -733,10 +666,10 @@ impl RestNamespace { } else { let content = resp.text().await.map_err(|e| { Error::from(NamespaceError::Internal { - message: format!("Failed to read response body: {}", e), + message: format!("Failed to read response body: {:?}", e), }) })?; - Err(Self::parse_error_response(status, &content, operation)) + Err(Self::parse_error_response(status, &content)) } } @@ -756,28 +689,24 @@ impl RestNamespace { .rest_client .execute(req_builder, operation, object_id) .await - .map_err(|e| { - Error::from(NamespaceError::Internal { - message: format!("Failed to execute request: {}", e), - }) - })?; + .map_err(Self::request_error)?; let status = resp.status(); let content = resp.text().await.map_err(|e| { Error::from(NamespaceError::Internal { - message: format!("Failed to read response body: {}", e), + message: format!("Failed to read response body: {:?}", e), }) })?; if status.is_success() { serde_json::from_str(&content).map_err(|e| { NamespaceError::Internal { - message: format!("Failed to parse response: {}", e), + message: format!("Failed to parse response: {:?}", e), } .into() }) } else { - Err(Self::parse_error_response(status, &content, operation)) + Err(Self::parse_error_response(status, &content)) } } @@ -1141,26 +1070,22 @@ impl LanceNamespace for RestNamespace { .rest_client .execute(req_builder, operation, &id) .await - .map_err(|e| { - Error::from(NamespaceError::Internal { - message: format!("Failed to execute request: {}", e), - }) - })?; + .map_err(Self::request_error)?; let status = resp.status(); if status.is_success() { resp.bytes().await.map_err(|e| { Error::from(NamespaceError::Internal { - message: format!("Failed to read response bytes: {}", e), + message: format!("Failed to read response bytes: {:?}", e), }) }) } else { let content = resp.text().await.map_err(|e| { Error::from(NamespaceError::Internal { - message: format!("Failed to read response body: {}", e), + message: format!("Failed to read response body: {:?}", e), }) })?; - Err(Self::parse_error_response(status, &content, operation)) + Err(Self::parse_error_response(status, &content)) } } diff --git a/rust/lance-namespace-impls/src/rest_adapter.rs b/rust/lance-namespace-impls/src/rest_adapter.rs index 94a09d87cfb..83901e62eb2 100644 --- a/rust/lance-namespace-impls/src/rest_adapter.rs +++ b/rust/lance-namespace-impls/src/rest_adapter.rs @@ -155,7 +155,7 @@ impl RestAdapter { let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| { log::error!("RestAdapter::start() failed to bind to {}: {}", addr, e); Error::from(NamespaceError::Internal { - message: format!("Failed to bind to {}: {}", addr, e), + message: format!("Failed to bind to {}: {:?}", addr, e), }) })?; @@ -309,73 +309,46 @@ fn error_code_to_status(code: u32) -> StatusCode { | Some(lance_namespace::error::ErrorCode::TableIndexAlreadyExists) | Some(lance_namespace::error::ErrorCode::TableTagAlreadyExists) | Some(lance_namespace::error::ErrorCode::ConcurrentModification) => StatusCode::CONFLICT, + Some(lance_namespace::error::ErrorCode::NamespaceNotEmpty) + | Some(lance_namespace::error::ErrorCode::InvalidTableState) => StatusCode::CONFLICT, Some(lance_namespace::error::ErrorCode::InvalidInput) - | Some(lance_namespace::error::ErrorCode::InvalidTableState) - | Some(lance_namespace::error::ErrorCode::TableSchemaValidationError) - | Some(lance_namespace::error::ErrorCode::NamespaceNotEmpty) => StatusCode::BAD_REQUEST, - Some(lance_namespace::error::ErrorCode::Unsupported) => StatusCode::NOT_IMPLEMENTED, + | Some(lance_namespace::error::ErrorCode::TableSchemaValidationError) => { + StatusCode::BAD_REQUEST + } + Some(lance_namespace::error::ErrorCode::Unsupported) => StatusCode::NOT_ACCEPTABLE, Some(lance_namespace::error::ErrorCode::PermissionDenied) => StatusCode::FORBIDDEN, Some(lance_namespace::error::ErrorCode::Unauthenticated) => StatusCode::UNAUTHORIZED, Some(lance_namespace::error::ErrorCode::ServiceUnavailable) => { StatusCode::SERVICE_UNAVAILABLE } - Some(lance_namespace::error::ErrorCode::Throttled) => StatusCode::TOO_MANY_REQUESTS, + Some(lance_namespace::error::ErrorCode::Throttling) => StatusCode::TOO_MANY_REQUESTS, Some(lance_namespace::error::ErrorCode::Internal) | None => { StatusCode::INTERNAL_SERVER_ERROR } } } -/// Convert Lance errors to HTTP responses +/// Convert Lance errors to HTTP responses using the spec's `ErrorResponse` model. fn error_to_response(err: Error) -> Response { match err { Error::Namespace { source, .. } => { if let Some(ns_err) = source.downcast_ref::() { let code = ns_err.code().as_u32(); let status = error_code_to_status(code); - ( - status, - Json(serde_json::json!({ - "error": { - "message": ns_err.message(), - "code": code - } - })), - ) - .into_response() + let mut resp = ErrorResponse::new(code as i32); + resp.error = Some(ns_err.message().to_string()); + (status, Json(resp)).into_response() } else { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": source.to_string(), - "code": 18 - } - })), - ) - .into_response() + let mut resp = ErrorResponse::new(18); + resp.error = Some(source.to_string()); + (StatusCode::INTERNAL_SERVER_ERROR, Json(resp)).into_response() } } - Error::IO { source, .. } => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": source.to_string(), - "type": "InternalServerError" - } - })), - ) - .into_response(), - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": { - "message": err.to_string(), - "type": "InternalServerError" - } - })), - ) - .into_response(), + _ => { + let mut resp = ErrorResponse::new(18); + resp.error = Some(err.to_string()); + (StatusCode::INTERNAL_SERVER_ERROR, Json(resp)).into_response() + } } } diff --git a/rust/lance-namespace/src/error.rs b/rust/lance-namespace/src/error.rs index cb367215e0a..8a73d4db8e8 100644 --- a/rust/lance-namespace/src/error.rs +++ b/rust/lance-namespace/src/error.rs @@ -77,7 +77,7 @@ pub enum ErrorCode { /// Table schema validation failed TableSchemaValidationError = 20, /// Request was throttled due to rate limiting or too many concurrent operations - Throttled = 21, + Throttling = 21, } impl ErrorCode { @@ -112,7 +112,7 @@ impl ErrorCode { 18 => Some(Self::Internal), 19 => Some(Self::InvalidTableState), 20 => Some(Self::TableSchemaValidationError), - 21 => Some(Self::Throttled), + 21 => Some(Self::Throttling), _ => None, } } @@ -142,7 +142,7 @@ impl std::fmt::Display for ErrorCode { Self::Internal => "Internal", Self::InvalidTableState => "InvalidTableState", Self::TableSchemaValidationError => "TableSchemaValidationError", - Self::Throttled => "Throttled", + Self::Throttling => "Throttling", }; write!(f, "{}", name) } @@ -258,8 +258,8 @@ pub enum NamespaceError { TableSchemaValidationError { message: String }, /// Request was throttled due to rate limiting or too many concurrent operations. - #[snafu(display("Throttled: {message}"))] - Throttled { message: String }, + #[snafu(display("Throttling: {message}"))] + Throttling { message: String }, } impl NamespaceError { @@ -291,7 +291,7 @@ impl NamespaceError { | Self::Internal { message } | Self::InvalidTableState { message } | Self::TableSchemaValidationError { message } - | Self::Throttled { message } => message, + | Self::Throttling { message } => message, } } @@ -321,7 +321,7 @@ impl NamespaceError { Self::Internal { .. } => ErrorCode::Internal, Self::InvalidTableState { .. } => ErrorCode::InvalidTableState, Self::TableSchemaValidationError { .. } => ErrorCode::TableSchemaValidationError, - Self::Throttled { .. } => ErrorCode::Throttled, + Self::Throttling { .. } => ErrorCode::Throttling, } } @@ -354,7 +354,7 @@ impl NamespaceError { Some(ErrorCode::TableSchemaValidationError) => { Self::TableSchemaValidationError { message } } - Some(ErrorCode::Throttled) => Self::Throttled { message }, + Some(ErrorCode::Throttling) => Self::Throttling { message }, None => Self::Internal { message }, } }

  • 2O+t+gSGxAj(Yt!rB8Og?G*-nl? zMbFG>{G=U&1~zCeO^n}p&Hh8Ujoq-Omz}|r;%PT`s;Nx$FZ554tYPY2J@*btK~_0T ztXGy&sHi-Kh)|#&#uD$vk%ZPuL z!#jL-B3+fs9Sxg<(Jg>!P0{09p^*nWNMWa7s!qhCxF)`FHQ9vRL;7qh1L`o}QI)`x zd2L9n5|#*!P0^QG-r66p@8Nz0hqkTESJfw;Tn@*p&#{jTX90~X!zG&!Y;7V0W&k$) z@L$=oDq-Qf5!0_-lFW~;Lb|C>b|)Nv;(s_RJxcO9ZcjR{-_cLn(57R17}|6Qk(;{n z2z)xWbJ)YDdnQGG#*6Z?OS_W$1*Q8ZJmL{d_zQky_w0Sw6`jB{xPw2bd7k#>|*|K+9Y#F+IcCF{Vq<^_B@EEcz z{kmMGUa>Q z2tr32bT*;}Owh!*NjQKdP~&(Fk(iK(5e>}|G^M=gr_yOfdo)(n;;2r4;luDfN(ZNh zRT`*1P0DfxWg@9uQVa%M({umOQl~z@6(j>Ne zrl3SIL%$19SwXl)Omso#b2X&MiQjOQUy%`K1HvVFRjKj?}+mZ8CFx1v)@3}NUmA8YC z+dH=Q=S?jKZfPz6j5~ZWM9SLwTE{;7)YY2Rlt3O=s9H}+LiR+MG+C#cS|t_@=sbZC ztc;q-2p9i{)g`muJEOOlQA<5&G^j$#Dgpv)n<+9X4P}=! z>xwpOje$1=#Q`k!ZH#&U5aqhGevkoF$-&CGxkl*@z8T~SRslQAjVB^mzzXgc$# zG|%+?PbZBwahue-BTUC>Y^rf7G2((uC#_7!ZBnDS;*tiUMg@tA0@}o=Ni@>bCT^(H z+DH@?jWOZ^1`$nMATGFX;Eo%pAma9W=XcKcZ_n|7=k1a?wXYrrwq=dnV8QLcGV=0*oK9% z`YB8UCYqG^_F%qlL#EY=C z{}`d3I9cje#7Po0#5O280fVgrspTy%q`M1N67m5uj=p{8HU z1Cgm+n2TXL$yy7Z>$yk}+IDZUFW=VE4S!PT+CCGuz{23$F%{nBXX9E}fK4G`Ep-n6 zlSui#U>8t&tjY`9~^@kq0m?OJUi%M|F(qc)}Px@mwnY_6cnn|o|rxc|TjQw-T z2@b}H?^cR#6Azy_-WXdus|#ubN`S~ak+|hwS_P3-Xq9O}#dEi6h>rsdYfN&LPWHra zVv@+DBfFW!x}A~AQr#B8;1Xydd-rG^S~3^7rDsdQ0>xN;hFH zF|}4ivLQnKe% z%bPK7+f7qpL}^U(wyN6kci65b`Jg&hVCES_!bF*~|ozI>*mr^sY5#y!E`Jx0o~?_;pwv*GOD7&8tz zB@stg=t<|FF%Y@Zy!<>y8ibv10SVYkoKl?_|GmSA88Lr*aZSD4@*~pg=&Nu zLQF86TR7(*s`87SwoAb58s4lga*W&mY0A2*jk{hx`#2dKf!`grU-*n#6|Wax`@GCw zJs&G4c8M$$VcGI7JUk^}IN z@||@A%)wv7HtxAH)XFAE{%C3+y@;x4oDF^=#`$Z~4@1NeH>0tLp}*8d!g%F0&d@(K z5pgMdFcgnZP0u<9rq9ZQ1{@kVoz;ERu^_JyFV1yZhpDSuK(S(OebbV}M|I*fC4cnvwP3W5Q^`olr*NH{wQ~ zW`4zq34ibO_HQZYTOIylp0_s43C59{GN3^3QL^2~Pc?4VqB`4+aVh>*<4NbW@iq+@12!FFym2e|$1jkIjruW+7qyA@e^#54*5d z6o;$D_nfMAjAos&X@)vVnT3sgWnEbYepxudDP~Pt%#LifB;az7*jM8k%@mnujB?dh zo=aY3O)>K~iAyzQAMK(DOgA?}@1Uh1zj51 zC>KnyN4!B9`9t0}Z7sbecCT(GUA^;vTEO>l#8o3K`Sm2g#^!pvHEmFD`{~ok7ss5T z>aNAXz$SUmeL_}ap0+na)wM5O&zZ%I5=8e=r_1-!yK<0Tg@A-<>b+u8ZanP4!UnX) zgkqi4)s*?0l4pHRMEvtsoWMdm-I4O%>FW#=>=DuVR9f?f)$T`Ocm4q&3yzJl;-&Li z*+w0J5cf^I!?HGF0RYBv$%4h<(VGbvcgDE2WqWkpo;Y0AWb5Y8YMVw8mx;1(5@U`9 zm+Ca`@SUihp|B==AYei8c!k>!^)AMfNaQM)g-$B4tMd?-lc-OB5+>L%`IP$p-ivRZ zs1B>+VMkW9_IX@y@WAH^d1v2tmIdw6`>B#l5VTM(k=5j?zxO&|U#w-3z;0=@n(i=V zSupxf|DlLCcdI%ER*E$dpVzmMDFcUfh-2TsFZ&(VWh&_DZmp#9BWNGFOU}57D0YZT z{F=Ggn?Ech&vfQkHg%Q|gm~SHO$V)2bEK6Lc>e1LsMvr;Pef@)*^*OVMsgkeksq&g z*V9K*@oemt=;#7=?fDQ`$9@0X+e)FpGbP8LeQ^ zWI%*te%DhbjF$Y=SnaUx(H@V}eq%6Fbl z@xTOr($X25sjZ}gFX(fs-3$A!%`*!pknye4mmY6=D9V=2e2zGFioiTnZHlq1Soz$G zg19LPuyXV8*KNvhk|Li8!~3a_aM`O(-ww&NQq`f(+Pt)X@Q3fa4K!(3F$F#8J+^t< zv6%=N=j<}(UQ@H~ zrjT6J_vvXHN=WtrNuE#prbEXDg zTQS-f7yWMUO&H)XWmiU4a`bxmi|@}-*+;z->&x0DD{)zlmO_!Wo5U23g!)Izd^aX3zP#kyznd1J8>-H2qOHDdi??Y}drD0y0 z@l6M;OuDV0#c7OH|1HYl|$y zB#p&09mS-2*rupSP$R8~qMD(%F)i8IFV5uYvITd$m>hU}lPO@n6S94iyRIL+Lg{12 z=+6sRe&~=1>Vev6e(PDS;hTcNmYy?aF27t0-VDogS>K({W*4ecdtDcWM3JK~aC@!C z6gX(MmyU<$_7xWfw& zi1&V{3|wXF6C2|Y;>0L9j`o76A)=ltJy1pQkv)v8ZrTzrRe3N#D@B7(16W9>!9V3B zJNOtYmHD)!F%Uu}O;@OU&bHgnvO!8mdd0a+w`^op!j7*4nlen}yq8|s=y6MWoz$5}Wpx>Q2S9r# zocqqDb(@94A`|vTod$l04XMWoUiYidyL+$jxc9jW{i+%U$P^wnT5Lsq;n<2&L=+XX z!t-6KA=57}mKldE-=va$wQ#R6$&-j1J0&4e56;c~3jxmR`S;j)&HR)P4^RJ<=j!}R z2^%bb2qkX~f|)FM{xwSoXs*oP*rE209t7PqQZ>2Q^Oa@D5has=I8skAm~p8$O28@6 zu!V@D<-cRh<<&ZUi@~TYUrM4o*}-u-wIo9Exs4tZYOXq{uG$3Itjr(WB*XCic3jL0 zKX29oq}EC>;?}7jM^2q%=&gViF9dhI#Ugz)aa?tHp01QST@QApI3cgC5d4jp@U2BB zDE>fc1uUx_NklKriyGB9WrDjQr`o5?2~{2v(26{Mh{~wAMmZ5Y>T``^z{ZQj|1nGZ z!#0H#5l+(Mehbl_g>(E|X;oF6*&&i7P`{&ac4@xHe!<`-j)i6O>}XB41Uu?=u=Q*p z2%?p(7VP*pzJsq`a@wgDWfG z(AJcMuBF^S=%)B*O|>Yy!Y)s${8PKI&rP>JnOjX^wiv|Q`aA518i}R%oXMi-ndz`6 zQwz1ia}eHT3?S^-%e&?kw>gnBx9$5@aajcrYsh86Xm}|g>RKm{GzU0NOHmZEg-(KR za4p(0<(Y@Be&fZtG)z4Jcowg9Y`IX4!%kXkNZaY|tz9D?9gG%H+XIi;3+P1Syw#E= zu3wzf9v6iOah#TIRSKCm4^5gw5>xvFwGna0F319?cFtT{*E3dtYU}0b?dyzU^lkn0 zp)<@$Hw9t45xzbFVN-VXTq6(jjL=QTkW;Ds)INLBerg!45^NTouEtG`9hDx&Q;DPN z&9+CmbB&>2AAA89rLVnM@jh)IwVd96I)yrILf)C%wFQ0^Z{H2z2qEM}B^x0x+_dG) z_ioOXpQ<x4siPl&juunv9`aL-9*q!Kd5oj)Qw!&ZXOla~{oD_{wkexqm-v z{)j+cm|@Ow7PY`_l$p8I@v8Fe(<-WqXlx1!6bMX>oiO^|Y^TU?zQK}L;LXWyI{*rJ z@FD4|yX%mx<Ml42kKtHzf-{ITReml|1u5H(GvV60Je`r;05`SYI_#HM)0 z`Y-$qBrHVA3&39NNVtVqXG^d&5mV2}%G^#WrT8aLz0t4b%=ar61CD-+lZhcq+*%3y zV}Z(@l4)hED#EbGu)LJJ<>G%&w~UE%&e9#5_Lt8aaRzf`k_QbC#Q=mcJ{cyVcv0Fz z8O5pZCKpPrRz%YPh;sV4S=ucAOf?rGl07=pcnYM1U6lV`s2nL&kY z_JEnQUsZ0&VZ%|i&y69+cI363bPVp%z?HYetGI1^YIlO>sDY(T6Mpy(;%mTcZZx>6 zkmp8cd)Sf^@pk)X%yigp-sF1R=rb{X?DY_<^YM!`rj`F?XB^|+6esdkPn6I01$02_ zzD(>F?Cl5|yr_YYEE>%XYv|`gCI4<`E`OIpo1iVe4W!=wK3ydfB;cxJz3WxjO4~vc z)Bw-p%&b(14wF0Hdu zP3s)Z9$Jp$-oH^o* zery{+nzqtO7{Bd)q<)L!Lx{UuUH_d28TUj`bEr1R^NMp{ ziO2_()@{|%qrw%R$%|gQ=Hr(`;NPOg_(AG+V9=D&3G1$?{v%{i#HZVE$(9CH|0*R< z%3F)N(U>sG@ppPuBi zUKB4^Q)Mdsx-J;nB*@iOS-?PRRzV4dIbz?i^a>)bG~@Z=SzlE9`=Wurf*M=j`?wO1 z7a%jWAVrt>_wTnh>W~cur&7i*tuqFvcTj0*Bk6U2tV(UF{Yk3+p0rz-NH;sSM!G`0 zwL3s5vCUgt>>s;acpD_oOGw?!<1PS3WC+E98U5aRuYJfitn8WCpY+0Kx+c6PVG*5M z^?gapplMmr=wnov_l{_(LbDv2@%`*WRLD%P1#|MDBYs-hNS0T>H%a>AyVBITLChO2 z@1+rg-Hr7_a)JQt!C|`SyCayYRl}Lb@Mr4Vq=F%i(@*8!2JM{kz17XyBMu!&fC)VF-oP5F2e z+OgWZ$td5qpGrQG`Axl`ruUsa-7pjcU6TSNtl4E?icg$6-cDMo1gCSI%N+M3p5L9+ zS`2L5b|<9=FY_7-Q1%I(6>(1##m6;9sl&lk)Ykt%mc#*Ew}I;uDVTe4C31{lRD$~q zp(R@4>xz@uLH|0i?Q+Ct7VY&r3WPMLJ4lODw$vRS zGWRv$p!O) z^04&Re<0k14rWU?(gIc%CzC7keRS5##J^TB2YFZD|DP7%r#n;Rn?SQlKO)1s9lv%; z9rIOMm|y9I@Kp@)JvtYhE?jVor_db_dwut1#MPQK(t-a$lg->GLWmXAE?BZRKiBn^YkIMY>_b#S$zxpb{NIkm~RAOqaj&U_x>oSQbHh z-r27Eb_NLs!ET_di1ifN>|>?QZxntnN&DsszemdYb(C~n!6lmzV;VX@SJo$xw1lX zp!lN_L^HSOD5kBxJQ)P9ytJ_f;}tY&A&8v%-<0S2v=haT&l;}z5^tB<<( zmOL|dJu_hFCM2!OC8uu_NKCBavBt@3CgTgEnr^Tb!QH;?VtJ>uxo0tnmti!^AxJ(I zw)RxH=~4o*L^w$N$M=+0JXoAjYC<6n*#fsgw?d0F@JBu4R=jN`snzX`6KezQd!HUPG27$%wD79BgTEX%hK{)| zKI)BS0=$a%`l=pf^>XW>w!4$O2sjywvl+8Y==Avv#_qeSEc$M7;!-W~u#7jR(+!fA zJ|R2!Ogl9#ZLekfpvx{!FnV8aG3_CSg3tiV*KlqN07AURTXKcHb z5+Zj0k`YoQ2IZR#kY$aBKZ){T)>_~WsP<*$XrK>n>yt6%9G27<4+H0>B`E@ zn3EG^Y1+bv=F<;;^f|$9$4NPLoFgmJ8?WOmgQpaZ9b0>UgQQGNq%Ysevlx&rZ7yl2 zo_vQi_s{|t8mBe(wbT(S;AtTFz|L3hV6b{5q4FEsMslHX4JBcdk+=o0W3X1yCZUS8 zA%Z{l67cJiC$uPaCP%xWihS-T?81xCe;75ivgSt$BM2Aj72KbpU&GRj3gB^^xV1;= zU6&CG6y;sE;xBJA$Iy3ve)D?Sxa*b*iDm+G2$;E#ABSeKy2IyTf!84)vws7@+n+tgWUSy+% ztx|{)PcsHM`Pj*t5*2QM*tPngLYOec0FJ}$m!yi2 z5|+QAH(Z$tw+%>x4=jJc@4Z_y4B4dLR69X`2MF z>_J_gmQtg6z~mepW@Y|p!PCZEpfU!-#SKg+i3RUg*4aj%Xr3OZb z_nwvcr&gSWppSr7hduCVF4RH(Aw>ATmH`8?ExZ$lg80=W_Ih$S+@`N%a*3GPPjP(^#G3n275hxuSK@$ePCS=-EIVqQcTurHW!epXz0t zv9%6qo{x^}*=tl+6{zjxBJl0Em+PK8;15kukvqU*g-G$e+Tsx)qy*)3-G0Jpo+Dmc z&5qel{=}Kk*^(`D9&^V2taSq6AMCMuul?Dm|8^MmjBu!p9Rh%vb?tj&gox+CjvBJ?qK%ASOggqZT_$6oVPS-$? z-a84YmyL)bZCGq~z&Gqsv^1Wu#l!Z3n~5%kZiyvyrTqCi zcsIhfEgM?>wJF5@KV59NZYvQj4qbQHgKY)627qv>@E;1gE5=7y7VD}lcmT0RSQE~nzyz{Kb zIv8zhcco6o@HI2hXE{~o95$&S!|ddtKYRGUStpzLyIPi~;P`sj z{+>n~DmawzdvbRb))glsJ)a~UKd@A>O}Rj#1xoXW2SzN7Q?=umZ`yKbO+>&KjF`lrR<7)p+uhuR`8|EwE;<%Jz2k2hG5*pfz4>--I=}Ynw6;Z-u(gwz zhUdQBo-yDoE^b}}ekc=%4g43FNjf*vHf9RgZG}toFFs1k`prwYc8h;WgPFvNsdE;6 z<^I2OebyO*l?Ud*%m-bimyKPYO#vk%k`bTL>&K5hjxRvUCl(rezQ+o=nwrqZ0Kd*( zo5fPrLUpHKYT9S@IWJtu5qGV)u76ikVyZfb+JZ|VY;~Tt1NNA0UU5rJ61! zV|>ZTi+fuZsRRFtos4>lt;IYSa3_;}ec1+;%WqwVt%PYm-UZOn*I#J5Pq(k~8O*Nr z$58y?V)b?W5splv**)t^xn)%vSp{E@=<>2izcNMD*6p6kO!TvWi|yy!EYb{VhdnL} zoooe zXM?6v_FP9mBv@);i+qA^xe~Xm%_vJ0c?c7|2S(mD51F<*7o7n&)kC{>H;sb;$S4*x@vCC{7B`s5Gq^O zbL9sg4yS8zx7OO#fN>F_qk6UN%B?b>L??&9a~K9>;DOn4E=a2jIJ{1yo>c3-F$;+v zMC~_*I060Yw7nnGyMA$5=>F*D0F8zeeuOZG+Tf4pO_z3={YSEOpK|jO7(!?C>EJ!h zY`E82Y!=<<t&GKnHRQu^4>VZdubJVgwc6E`oTTp-;bUy{j&=bV5|qt?aOQ|HzDI}Gz1y9HZ{!3 zO)cU=z%NaZI|G&hiTep7{sgK~`Vv|!%7iGm5SLyf^6O=wb_A=@JgP@#X;^)NNc9f? z8hI$%wL~{ya*R&j^`D=$u3?mgua-o7g+QGeCx)ZagE$9zE#wj+f&P;IcSN}>Yz4()|yd8650K7N{Tk*w2vNoTZO z{vo!u66`k4#@;~765aPsSKCe$&&kdaMU$8x5m2InA7!%knO677CzhbtdOzEAPsD^! zdePg`Ed&N=Q^W}D@%+CLb{2q8d6xO6DDO4e+VV;CB5=4FZm~H_yr8JjsedtGw6YEH z9tQDsh=I$(uV!c(G{jD#Y&7}N9mm?&HY$x~s?YhT?}v$AvMk!~_T3AeF_}ik1`Qk8 z(8y4&B)-Ab(`NTq&3;;p?+`4^DwYMYR~7PIy%<2i{Q zv1#o9FvNqC4*!C+Qyl*wxce~KgRJQ1t$txlC|vt#ILCSCc9E#=xG%$O;7?STx1qTb znt!cAZPy3_?hn$w>_%zqAJ=om_Zt8_Q>(B4t?Qkg{gYRNJ+OC8pe@y=X1tJpqWsc37TT1j222|cZOv1M!~wueFh3%r0eH1W zMwf7(I;|ng%4#fl**We(Kx`@;|8Q!d=W+q!;&am6!mno_HY@O8yyUm+Zg^Rje1e)P zrVduw#v6_L=_G%fGi?x$yRwk^Z1{4M=+|qzv!PYgF40J57dKi9p`%YE`>1@uVPGW4vv9}DEB&OZksRQRNEzdm8AiK z4kRdvBB|@1yvjk+?covavQS09ZD}C&I#zm3!G})=f6FVRu}yKJksH@+j|5g=ylDn1 ztgJDx&}&Km=8lrMB)PYJ@v0;faO)7gKCbpyct7TaCDaSViKZGFAIhr3yuhK$pVpa{ zZ;F_Td0Zf`!Um+H;)|p~;m$^24O;~89Gl5M{AA$}Ash5fxxe6ZId#qSm=K!4uNA+6 z1md0<*K*iv`WLi3Wh2<+!t!e@W_>dV!9RG%NZ(c1I<37Btp93vaE8jk zI^!+#NdIi)4*oT-ul4K1rA1xTxQj(n0p~;ZTWp#nlPNptg`7HX4aXPazNhmfdS~o! z1CdW>*bQh6SCVjH)I?WP3oQ6T>3FfQPz|=hGC+sXb^@fj($*Q3C>QI@rrb&$(%O2a zORKMNb3VPmS)6x(z~z9g^nxy8FMKjuHhTf=9;(=g*sPt2o<@Ub^ZHEO5jaLm=h`EV zLD*7LSOl-7Iw#|7Oo!w%?3Q0xg;UDay;oz!C}Uf|?Da2HlNfRr@}(x#t`; z%qGk+AQe?ue8#8mPMu^q)91K2c;5hZ+(=Qsq>;C3W5hkzcs22O3S`i<|FKq>Ck3!z z*^h$x@}aW8@nmx35mLI~7Z!W84DHpjo&M7MrUQ*N%})IFOoq>_wuPYV2iL}zG1ThrM|#Y_clIdYmMv=}%Iw4=1#RYQ zZ#6A39o0|F3JyMaWx&%N6?~a^OP=OmqIYsq03?|c=5f3_sb<0KJW>ODHF7jCR9>&L5qZfw;Q zntLw)+I~HjV^9Fo!YW}rAPKW> zv7-5m-}HqqFM6i{Q$bMV%%VswA^V+y#5Jw`@xEp3=~&C+*YFVI1@oy~O-&ulQ#kxt z=2iQ`Ri5T>1KE>2LJe?W{ofGkb6Jf^jjjVP{!zzPed)-~DH;C6;%Z}m>~s3n%`4(} zftObTj#9O++A~;bu6oaWHeL>%cag04(5^bGT5qB4lvf5+i=E%bD-*U)e{MThzM<$) z%?=lXtsq{qy@%@^g49Kqr_T_bpWB*7KlwBKc&T_KI6@?H$t63fHjx^0O~WDz95}7T z&+~&Ki(-ZASm%iRsRaRWkHQeU`aiJ8h1RB`8J+-UGn&2?sc&PrEt)-kHk;BQ1fw)w zIktxwa|}jbnA9X@@-p-Vw^&^wo>8e?0zQ~=7W&wa2EW6qN%=3ob?|#gY3jN>O*e^O zm4+kHFdsKcaqeWlbpj9nr%bH zv-9}jPs2hLdGSp8W8b!fT&2|C`ykb9;64P&cS+Xj0ei=qQu~oQ#A#Cd-OjhgMMc#a z7S9VCS&2fNIMJu#;GG^b6{#N5uQR~fsV2bjGJ_s^7CMSo0U$wXaJCgsG*H`WR zv%+LPbU;gE?)){N#j5Z6&FA{s=&Qqzk+e~h0+<=TSARV#nHledxpk|Be!-G5uRZZ56(du_Z{NFs;&F;61u*5-r+MxSjuJQv0S@pS za%E7>U3kH7cP|$AbIJYZ(woCoK$$^+ESegvfqgOXxZr;$IPWZC{kt-kF+I-W1eKLB zwb0c!pSSnfh9~x}*mUiex!UEzYt$7GZMTLK;OV1$y&*5Y%wE1kFJTo)UiHKx7HWgA zG;R&CHILmYBSyXMi7hp$;oN?`pQK9JSLY6gxotX0>R|)bJ?kchMjM_`&x8?S+8fCQ zUs7g|S!v@T_sojZ*H+H*9jfOhJl8c*9aYK$_b`Yxc-eXKy9 z(Iq7&E1oC^mCQeVj+eW9X$}-qGul6WyPCG6S_~R!>K3gRTiMo>&x9O4UYUwZdqbF{ z9S^2=6PDHWM@(eqT-vbC)JtT;)AvwEXcZyTAp^%P)Yq}iAfPy@S?D8xTfneWW zN+0{d)({OJ@%ei)VC+_Z(ngw;sO0GoC zt&Iu_9QnpH? z$_D;nO@`i)R(cOSVgiP$xM_vas%mY~gK}*R;lxiFyZ4KVLg0Po5nZApe*PKyRcnq4 z$pBZRQ};I=XYGDxUE{PJWxCx^pi)U^S*XmM0qZmPC=w5P+tte2%@t|;F+FG3y)jiK zYlSH$R(D1oaFJ)G`}1U?=~a-Z7DgKPv$gYE2V2(4mTi)6;V?g+pq%a&i_jEBc&$vF zR96Ye_*(?O!^nCuucbI9l^*2>v^)WA6QlaHSw5h^jIQ?;MI4Bl1%2=h+Kz;uY=%H02^#P8YFzV%*ng`;Sj6~ znXbl8MsgwI%%?_a`|}}Q{Rg)xgnP-rS@;m(#LdN`e42q}E)myawepm^ivmG(i*M`u zAhuqs`R<186aHI|{g9`CB=7IQ9%YfvzWGuI2_&n(|pbfPk&DfhTvkuYS zzCdg1VvmtvQO5(x?J>IwLGOvk_OxVkXAYpVXM*Qo{FAPd(*dQr5$1Nd*iQ_TWumg2I&`oRYzMzY`0xB}frI{p0m*ZmH*(YmcWzE}Hj;CW`p>AV#434}i-hVmjg>QPK z=>Blub#Fx=f2?`E97ZZ%pk(a3i01aWDPfZ1Z>eLA`#QK;YQVlMVTlO&HfP`70-lmC zr7-u0-INok4Dmi^`f$e7Y*S1j{}f8O^9$)Stpc@`08khy*7wep`9lt|PTZmlr}vwc zSRgI})L2;)fx&P`l${+auhwgno`q!G<6HTJS!~URV|KYlT4_v6;w->DXNa>0IgE zbtD6h=zL#A=&9X2Z z$gYiExV8L{#MeS*aPlN0&u{7~VXbSLE3?|pdIOA;dHGOvGujdB8l9X=ZjEufQ@b`z zSjudRT+lDbZ+vDO0AWz#ZW|o7mG6c=&rf;kb7~^IB>VitA{is<+lXtkrnKs(5t_Qc z_6Q*o>h}MJApDZ;ar{`!Gz7OF!KPybWYG^L90S|3YQXT~qr5=$N$CfsH-C?U+v!^D z9d3q)IZp1q(`!SWsWYpsx))EiBsypKxW#eYY&iz;l4qJIheQ`9aT$Z?D+s>nwEhj+ zA*lZfGbNpNkK4flIimMw z1MYV)EKxB&N8D5Dq=0VCZ{W|a_jdt?AOs`4&HtwFr4K$4BbSY-Rqm;P12{ev@$J>J8n`8nDv)@b<7%mR*quK%`b5yk|lDm|Z`=sIFV)K5J2 z*Tlx42vJw_(97=YpgTY}Y*C!EB;)2Ib5Z@ib+*t|z8%O?M@?2HZJ3paj>3oZ{=T2~v&r$IbmrRR$jn$<{i2URvHDOd7L~D$Y_w!#E&&|rkoe{61iX5`0LEB)T@i} z8hY6DZ>>sM`$V7=@Z;Ki_}Ef`x28`3mOhKyTOse??Y7CPE0QK5y~s! zzQVq%2L`b`GK3)de52 z??>;Q{*Gl?Vm_~cgzKJJ=h+Eu&`s;+Kn!jHgeIj}!6h(}TaYE}$kV3G&wO(4r6vQ^ zVssPsLq--HTs#8RYXL5FeW-04H&zj6q}COPWIPc_o9XYJ`;3?kY0u4kvrle6c|#M2 z-)?9Iy1X0{Rnf64&%||#h^V)jJp{f{NVH`cfq!H3$oin7kmwMwPfy#YpN6}WAHAoC zAyn?;vX0HsIk5lX_tsWm$^y4trlMSR3DQ#9w)g3W zeD*fNb?+#?=-2gF9k;_&>T)Dll>E0#>3`SLY$oivpw=7Gbgw2VYENb<^Y<9e2SdR` zWDLf$17u9H60j$6o;EYzo!L*(FCSQPUA=QAd*xrDteiojd&kW7E33=Nk16las|)(y zGLP!2ci-Psn3Mr0te(#k1v|&_t${zNe7d4Q(<)w%>9!&LU2!H~b0`fX)*s7H#1QxQ zzAzd!8@k{Pog0q6im8rQjTF_cX(he5=M5q$08{(F5n0*4>8CN%19n~S`x!CQ7J;<{ zomxJYfW#OsFCP5gbmxs>p8VU=dY0`psF@T0;zE}A^_EDNIV;8*y5WQNiNlDrY3RV# zC+;BK)85tpq-SPw+d5z3Khw;idhaGb_vl$g`Q&iHYv&B-W?kh~>}+1U=I+JsIQhRe{~=G3V%O>JIGX<)71wQVYAJuJQy0mIWOOx{ydlo2R4uQPBT z5Ike_NS|dx75jipjHd8<%>z}pL41ZSbO})3vqB{NIK#ltqRBMZ%2)aR7bHt9^6BXUdXb=Gj@BAeirC0 z9sNJPz631EwBP$QHd$jMV`iEnOryn2mS|?;0=&JI=9n^Nisgb!$)t|rz5qHd<+w8D zm>bGlR_2Q4m^+AyyOO!*!s0G&sL0~>G3UMB^PTT}=bY=ll;?rx0_FeQ|NHk}?%%Ju z@8CBH13b~nd#vR&db0$4Tk7UX8V~Z02U{Od4bmSVRn$rvGBG`#|NX)Dx@7UfPV!|~qmGk1!*NEVOgsj%**k9YwZn&a zQEU!<`w8pw!8TZ`JQ0ZG=Nc`fJyj5auuLGs-nY&mHi?_Z<;A-N@c1QTAXy>(O1KPt z@=AAi&t4$x!1JpdaDu)OS9F+P!*pu)ez)t`SLtd_muO9fVf>Ri?U6=JRFMGdsk-Iw z1IQ&Y5xNUAfg^3wi~Y0P4*l*bgUE$NEQMdOw!T218SI)}q&OxRP%5#h35)wD1+dV%)r9Y%E{ zbSpT48cHY&Jj&;%!(GTYFUdps8ln-5ZTn53YRH=-$K=VtU0;~+h6gF7f8#0#$egi4 z%2L$arMOnU+00vI=n4pO$%SxI)+Q!ZlF`(cAj}Z59ysPskok-Yk>IsHY^U6RiG<5Z zuUlDavy$7(Kc=QW)Nwh~QyL#DOMSQQtT%NoJ(IO|xY!KW=R{BU{prD@*3T$}yp6Wm zsx*i8>6`JAS=q;`8^KLGEBv1h91)^0>G){=Xx_o|52Oi%-|1bPMxSRXz1IVk4m5}! z^p=L6F^I*bcq(XNd;@m!bMtQm&NUBO6R6NeJE$Tp0|YN3X&l_vgoWE*2&JZ~*Y;VK zlTEzM@NbXZX(Fir2oWfvcs9vG+2sr{&w#j{^wh$|uyG%BD$`qrM>YCz`@O2l1sIfo z8`{p2_eklx+B4WNdG-SEfq#jgz2Jer9SF8{yuWVe`*=X6*@9Pz19O~zg!C}`2vSjf z+g{&&m@_+gq~Vtd34ISE@k-tIfYQAbWQsD`R$&48bLT;3Q-sVcq8Q-TZf)Cn3=BlF+a6_uX6u{wB)ihaC187yx68NmoT# zRb(Z5XMOb{kXzf?J5royZHX=|jq%uXC*WR>aphfKA>)&4Y)D7=FSTKb%%y7T>lQkj^OI)eN(dN)t{RR~HhZ~lq1 z5R90d?nFCeP3}({s7%B2V12*YIOIso?Tyi}x{5IWwJDWSY00!(>gF4ntPd^>T`k)T zKaS~zGiLJp6sV>u@v)d}@|RdYHgTBXM%GO{N6_VO?58p|rC zQmhW46+Ry*leTf)G1xMC<3`wudX~Tj$h(fO_$BS0iv!;YOG^No!Kf!a<78|^ZzF|$ zJ1!^xUKsq0%K`+t)NjRn!)|WSatQCO(!0X4^*K#iKYqc1wG!V-Fi4j~YgN(b87MOC zZ*tF8xosjG0-Y+kuGK@_!|072iojjB~KUtd~!f~uW6JJvc_F&dQ*c_VVcM+50CuNPzy` zpar%4sh=NBu~`MA$C~L|gKN!67x*0_8h~P`Pl@gAUAW+Lh6H{#M?e!&=eODjAlO4? zmPw14kRc&tj<26}Is|@R7_B6%(L|E44VtGK(Ycv@8FNEif8!FcK-ieNgMuE*rBcd? z%*%%>Z`kvH?mgt6T%as%PmuS%j2m_$T7R!-v@3IeuW0Rg#LJ&4*@^3w5zl0(Re|pv z!d|FIrCn%e{yg0Cm0Sm*%=f9tuwl3^=QE77xNT@!N+676KNTHUg#Lnu7g;ZqdhU5m ztoNN#gsj|k34;&l_<4Gg0;v#nPUB>Op);Pw+Km9lk?W z+A&!0oCDS%LqX2Z#$|GGKUMT*58>(1gyL=;Om|$Q0cwV$Ot6ORVq1!5AGSPLH$jlZ z_Wgjh3B8C^GoJrY46qIC|2YJ6Td(*sRo$h!;Fe4rX;7;?-b~qInG@SnI@HQQmT2i( zXf`=w9n7=}?4r69F#T}(e5ij|PT<>wGg7V^7)Y!j$QsPO@&oTUn^^eQd2v9@IbMP} z*2y@Dxp5PTh}`>ACO2MrmZS!x<@C_6$ar6B)1D>TmUtIAEuAOJC|ZN9_q)?`vL(l3 zy>pHz+bK)?;RQ-PZ(K&`dN7}K zH`d^h&SCGBMffGMk)qsm!#56q=$tTg*qBzMcnI;C^m$_>yC@~I{p^K|_OuFRvo2>Xg)mn62OO_j$ylb zOz1W|&9_7j{JuA!KB<&fgyMtpZG^KQf2;~LWIJ>koff$yf5B(!9E9f)WYI|*MRIk%gwV=N`iYR9CApx-?sm0zXuKp$ zB~+W)&1OJC*26y&g7SR0LlMzxUiOdb|eKQVd-~YylCdAG~sQV0WC`Sk= z#W#8aN)A;DrQ#i2?O0x)BY%BLIq&Ks5#SikolCg{Ba|3-4eu&B!QfH&(60)pO_G^7 z<4tn&rzFSq5}NC9{9tZl+Lwi3b#r><`}yn)=~e1-YgcSld9sWc2DkKt36VAPUXi28 zH2CmOEkQO8@Un=)fnKy4*~?MDm|&HSO4{VsdSL3V*?FBX~$04N;T6k~L zpz|K~hG3Z*y7W^XDMgkY=yE3ZDr2K&M^taDHGS+^I95?YnnBF(PX1$tkg9)$k}Edz zJk3YcN+tb^ftL{!Z+%LrF{^jM1BZzBz%_aHOUyp0vweu4%b?_CN^5F=h0p0gquxA& zU9Dre*u~V+eg2Dx{!!>KS|pKnjXvldbrPi5XAzu#&Lie^tb1R_D7W;{K+eR=i3J+v zxRJ3Xz7XvaDs`!(e;cscKf0EEod)Lkb z&{BkO0c!o#r~<;^YExaCSZqAzp|Pl9n)E3~R?+**A&-z{j7c;1SqtAF=F2xKa2E4% z%Bq_9Mm>u^bL`4iXFe|O680FMXTVQB0`h!LFJNo8zgXY(Ja{!!jb&4nwrG93*ZoWd z#(1R8K?5_j*XW_J2#eh+XT1qe6eP8#dSd6jx}O#qZ`OaA4)jN0t3uZ85O*#zmd{8U~s z)GI0UvYuA?kRvmuOWkLAtHe^SOvZVH)MT6of7p>jugdCAKamFEUuP6j@VxJB6jG$W z(ef8AjV8Oz9>s`o!?TNdEazdcMM-q<6{XHfcLBX%DhpZZoP8^Pl40gxy zx><&!!DT{cwlHA-;P7yLwa!SlI>`f`&av@of9YFLOX$UZp$t==CBKMV4=Pe6Q)yN3xED#Bt7`zs>4Kg`52`0&7c3EF84?ZIA!^h(+dFbY|xD z;zOTQ)tv!$-tfYCx~c3BrGE`0EbLxhVlVhOo3nQ{J)Dip(5F^((XeWn6x@KBAd%i} zivn|*)iY+YThhXPW{TN<0vk6hx0SXCU+L06b*Wzo9fa#QEKSjAoKB+4*wgw6!GXUz z6T-GM{VE!y+}XO;1k05o|9Iq_9qY-SI=))8X_^8C(gyR_a7C7&1_YznJ3hV8Bb5w< zL?k+vd!_f>-e^grw+NmZbRVHg?hI)oriVpfpdz6`Y&fcd#lifk+Jewtx*uf%sVg4UcPpz|sxcfu?f)ZQQ>d#3x2E_D zz?OPpl4i~|&tF^~W8=T3j%~h~G3w4vsVG6#IZ!tU$H_By?$Cnv+OB~U5CS)Yey_*i z1cw0YZ?s@^uKS zWz}Ip?PpQA<)Wr{mF8O@@UcvNGpQ0VGNkUcxx?Qfbn(C~9`gN{=}Pnn9eYJF^NymX za#W=&56M#)>GYkn#AS?PFYwpcf|VezBFR8#v{loxW!ZK%i?FyFt2Ph$4&pB^UA{|& zH~*k&ocLHXA+ntb@|jCod-R8nQ-D78-s2X@aEBacNgf70-gJ)Ryt?lJ2@K0)Y9$D|l;$@b_8qSw9aGu}BmM~wR$ zD&DEV=4FjE|>;D$ue%9H% zuhWxyOG_!7y-h>rr5OZmGdwNr?3sG2)19SDXRj%XzM zUh6%od!_Z}`r8YF-6~}xM3>@1yN9qiKK7b@PH;$xdYGAO%_)f7IpMqQm-nFqIZa zT$kvsexOC3c0>_O(If7=#0`xP(!N+#uzmSPkbz*gZG_HRD%L(YQ8-5MFXy##Cx&CK ztVv`G?itGZouSu}GWwEPD8gpUnZ}*uw*0^v6r1|Y{UQ3%l{qz6gz}14iDGsglYNhZ z)umKr#dTd&maUK1XJ-2oWJ*PZE8;mWe+cxKn;c$(m`y1{(E^!L0prOeQM8DyaHodF zszlYGVg9SQfcxoECWZ@6AZ=-*$nF&P;p$ditSvn!KmWe*8G&0UY#P_@Ovp-?rbG7D zU_}mf#VPFDG4MWR`AaTa+3!vmO|HRC_+}Ksmgr&~8-f(Na{{uLTGfu=o<2Npj1q6( zzFZM-A362oJ@iJq5=c>GX}t$dNC`Zns_JjXSP7deiX@kKXp`GDA<5#RsHeyjjQ{c} zj|;rFZ#B+R-?j}JikM@kaaGdq&dTR_igwYWB3q3 zik(xBa%HVdzba3K0n8@yvo(tdrs?IKE*V8%%D_8kCs5cZ@o9i!!JRuOw9~?hiTc$7u>vY3nMKeiauwG9wct@9SUM zu(Gm|VN^L&cr8nJWjT{NHnN$;odIAKdq#n*ZW0N>*i>fiDOs|I;{BKfV3~MysU*n? zQkavZ<}9zljkTB>hKBh2S3qoh2y;I0_gl<7#7OW$%Q>4q!@Vw@1NWz)ZLK6ttb6YI zO~Pb?Jjp7>F5a|uc4&rET+K=3kjG=Xj!(=~aWiA3Zi`;2BS#f4NEoSN*AorWki$CN z+HQ|NsDFNb#lE*2HSCNDLMAx!;j;%~DbINK-I}<9KX|%%5P2)Hs0=>1)UL{SWosSJREb`wshAu+dc7G;X6pS?$z5BPujh1xN==>)UoN3 z=1qODCTTZ&N@;OQ&^cF2zsKrZzCK<_L(qjoDKskGba)^8H)Z0y4B#EcaISwZ@zY zFY_-uW)W}lbQz|H8{Ga@eR?T>u02SeFU_p>GHkcc_Hn= z8r9Buz7CyPNl*T>0lPTuMnKPOLhsWLN8e335@~?lB>ML2XBV3Q2vJZfeG02`#y6PE zi!qQkipiy6L>rs_mmDWLrzQ9BzG`G;aWgLtqE|rX;skb?m5H>+&;XzN4NdVc1I_BA z=C>a0TdKeXO+OsoWseC*5Lt!vjkHC#MIb<$Yv`=1zj{}1=V>aY<6)E#9l7@8bZ*&? zq`b)?d;e^RZ$NZr$4M{}CUM{c4)ZX=aW_Hg>k(DjgK{qgm&gs?a;0IUiN_9uY-4R_ z0d@nhP8w8osAkGOHo)FCpww`qB!)Wg^$SMa`V>__*tdyy~v0;#^M?lBi00Ik4 z=BnSvYbeGGEp@dBWtsq`hOI7RJa!tX6Er1{R>@i(9aYJ78l;?k;o?G=`vqxkSeedg z?SWLhqtt@uXg!MjO_uXZ_) zHt~@kH|6|WES>Iz-ECnuTc#_KulCP#V+}VoEU&d*pei1x!MOGWoMOI(;xTZAvrl6J zz4h(ey`Gbh9RvU940_|0)-f>e&FS6kHAaulIzeJVxu~lOE)YoG{0?T*&3H0j(%8s* zqXG--KxgK(%0pSe#>P`V!YJ$p8olV`s=SraJR9RPa#)OU)6{~VN7UHa_}VwODsA1S zOVWg@iig4%8W$^EuKK&xpHegOwpY7m6goL(9DeT0eybk{<}uLkcGl*VFP*%kUzk+8 zQO>)SK8QrEd1fQ6^HrVS%M-U>C8*w8#=w`dN@Inex|hYpY&TwO{eMM?i`<;KAis23)e^~-0K`iJPt zO%9B_p`b)K6MhOq*^tHeXGDtL6kPtA@=B`hq=o3jf*0jE1bC2P7BgHp^k*V;`iV7< z;?C?K2CTqa!XGM=o7ls;EZ4b)Qw{_O{>@vb-&bKww*NAG(rQ!&$r_0F@H~p^aAPaO z>=XuI6G(h5DkN`Kqf(D-Wt-hg&*zc-Z&40fX^#kdkX>z7I%2%QwiHox%X%}*axRX- zT^uY-cVZ&pHnX_y9{`4{0V;=aTWL8lQ%r!(o^!rRgQ@-WO=~|HSBP^0!q{dz@~KBG zTtX)EaQPdIF2U(sF~ksFkAh*A$MCP;4t^FcTMuc|>W882cRuafUdq>m^ggkU_$tX( zkznyf;RW`2E&QaP zxyRb7+VIWZlG2@TKScNiTni-y1_S=MEV54#9u$YuqxiJ)-3FJdiC_C_*VU{X2=7de zzG%RgtYvGIX%!7Gj+xeB0GS>vY%?LG@tDF))fYx4F0ttrdI5K)BHV1`2cIH#b3K^# zGYCumU6j|s$~ZI-KGKj{6am|6`{>IVPL<^|FuEG&HyO`$N<`Ajwv4B_0kE-+kFuiV_{>yPnTC)58y zg`Y3eqPq0o-@-<9Hee)OkljbLQ)*+0Mey~lNVnnD2c(@n+fsb(Rn=b}1r6A+A3w#z z#4TK_!|g(fxjVb7bLZR?>u`IEayoKU!4q>ZP#Ah-i578xVtMmJ$_{o}06gL!C`Ci#5#0<lT*2xh#_S>4v+Z_)+~^ziz3CilMxc zF41;zWUZ~s*kFz;6S?}NW6tSy>cQ;(+T$l-3vc?3Q2kbi-AHj|p|VY5A=wZ?&-m?7 zbU2RO<1~gnD#mR%i|0lYB7VTS^V7P;;^ z?%rWn-^psEjI1IAFMaFvyd=Mz*Q2!d2xW`xcSf=ATSLMT4+p!Z(Xu^QnpM@P5uGAg z>$&%yip?c5MD}e2QC9ZL4{ZDY9orRlJZb$Q@O#+Vko~x5qH@OxU$|lUP3wq1Tf3Fd zC`QT}XCK<`1ao9>A}u?N*{Kiv+hDTb`x-AX^I*`dYZg8F-xl- zXbKi_`?S^uf*7$Z;ahLp!=*n54E@R#W|t-j!TpE*CA$}DH+ZU;$sq*aI^qfRyC8!- z$Sn%1j9|f*udEt2CTLS^)l6VB%cgSAFa!>ot*{}9jLkcdDkYw!s~7I_-oy%~5?V!B z#{0KhbnI!NA&~;c*STR@2ic19xzo#(Nx{dIn?U21D#s*>Ap#h*5@%y=Fx@sx*^mLJ zg7%aYDzKCZlvJT7k`?3~`&8%=UTfS~jk&mZFgZNsm!{`!)ZjP;5=anla13J4`qDg= z(QGjm;an-ayXUG5K4#%=f6`F}cF;w4*TE3nU-_J8fbF zjOU4{uM5N#m4(-WA;osBQY$no2ct=CEac!0r>26zj&BLnBUXv!F`hP3-y_!i+50NFP z&g$;h-kq;>COD!5R$melTmAE*T%twz>Iib@LX)$oN9U*HUDo0-0_Ki?U?RoIz96FWD`ky-89?&c zv)2Odebstw>-d>`whh9!zvf)z`oJRLlSJ$6c(LxnoAzRGpCB!*j8AUO$F($v91cA6 zx$@r58J1#)%$d>Bavbg2ic_9flD(Ua0B=)Z_*aQ!OqooW4Ufryl%}zN+(=72>+Dx| zX2Ln0xjrks>;SmOsmf2$8)3LxrtFNcy)|*MFA*8bH&mpQW_M)Jkf)N;2y;*6bg>+ z-p+?O?3*#VR!^6lsn(E`9X%HEG~sI*^24dmm}-EFxRfpTN_fEqjlKm*CG7ijYX% z$?ro&KEHjoADuwtB3NxsbkCS1j!K|C6?;(|Dtz=hS3>x-9lkMtL3Qa8$s9a*(w8|MMS z&ax4Y6ad}-bo$q%wDps%F1HAE=OcY;FQRbV>b1I$DYdv_1uUuc+9|^%YDZ-}Mp108 z=5Vc2-1@1n!e^sW9>spcd#*~Kj$a<#YQ8RCwdsMTrfm$E9=IQ@VGbMepUdIn04FsF z+Yl{`zZbinGG^$9*l84{xPT0F&3p(cQ}m8i@5unWg--Nd2AqJKht1yiE?B=OnAb6* zxi|)O(bv<+4XD2Bce2D!<5A4))@ADF_vN%8rSR;TRuyo)uHFVt%F-PvR@IB60>vR< z?<_l?kbB|gVNTT{2(7#Fx{~CuVgPw^=~L8JHfMyQJbku9OIovei}LVS)ktzP~i zAiiL^{Abxa|@hz8nKXxY>I@N?{l* ztqrFiPMeCtyjf8CYQu}ei#zkg-L1Q+(GgQ23pH|o4N^#RYgfkXk@>HbCw@4S+U947 zh{4;A*+o_+Sig5kA#p&Aa(DX)Ei%c~J9&6-W}dJl7IzlUk8y5~ln9h23n;P(TeDVE zjFU%n*M$)Rt@C&7@?ZX58oP^|t?`?qW_B+JvUl>Hm4#(3FmmIcfEv#>C^y81ZG#$9 zaNByI&Zo+$tlKdOfRj@zj92n;3Hse>$R(g4y{DH1a0-wtQ}-qZ*P?YAX`HJaPSDS=2C&H>wPunkV;6SHErPm7+;yiZkvvQy!-T8(}2N5N1w0ur?Wk zzl>}aA9%G}zA~adZ!Gq!Ce2q(;u5kQG_hsK%9*!TYa`CMZfkaZ88vow&;9K}f}K8h z>42!|)UJZoYfrTeTvfiK;$JcqIk*(TYuByFg9f)QqgW|T zyUpf9Q~Tn@s)w-#iO|tDHA#ySP|#sma^gkjOJ3V6wuJE;6O9-VFwU_{$L(V*`n3qP z_jZ%zZ9to61v2ei{Ur|b8pX0U)R=SLAm3u!Z$Amqqy|0s22yO}Q1UUjC}hj`@M@WG zt{Y(1bQvz5BvyogFVWidH;D8(5j-su5lKrk#?)t0F3?m4CtBK7I4jQZ3?9ZqkeH{JJWqo&jW^p1# z60$aIMNplU!eqU_TYsQtKF3}K0O7b?_?=h*=XCpx`T@CmSF^>f=C2o$WtCMooZw{= zI4?M}O_4THCW!c$JqDYBL}lp`ymUwON?=^(6I~FJ_)v0Qb29X!Um4lF!o;A%s*dG^vO4-SA?(b3xX=lEb4|^UTTm~WV>|FR4abn#0 z5ZPU@Q80-c2AZTc?b_Y(D$*Yg&e1qf2Ltl5V5NfKlW@0KE3mk~f}s&>1nxpdNm`*F zxl^l_B%K-@mB+QGdi+6(*f!is^CN6*#Tp^gR=^Y`BgMUH7jNV46!@wAofKF8qPOC%ZxP1MO8InKF5t&LEC;Kf@P~OO-{hSf$@c$-N_K2i>d9tIe&NwTl0o_5a;(zfw8jbYEUnX_VXAfmYtA z*uq4VMO@D!dSJfE6o+tR;~zoaMyDg%bpe4ND`YtWP~YNxF%gyvP~fsPN?4!LIHR6=M=v)Ej~;N35S zG=0MS(a|26sM=!URm$8mtKN+uV1olZ(=LxN^iBU=!~i1)T?#y9o1$lc_^`|#^|5nb z7v8@!&pwzsY`5Ly6`~C2W3e4CEzAvj_r|=N2m#3)r{OY-JuXvEhR|HG;R(+CGUE{k zT$e{1Q&*9J-n|j}U03k^!bW#h`n-MJ+&Pr278*^>GU-qN$Wg+XkNQ)oBjz2tU`c|v zjm204fT*G`d78;u40~61!Q8#ws1bIBIyoRzqP73|qJ|dagK6D-gSH}g(R@r9tq@tn zjHxRr>-TRSkZI?XEK-C|NVwLwhXiauOtvIs-@9>f@P{?&znkJObNn}Ba5UO+;4fSM zzoTO?npJ(6j0SxQ|9u4Pbm4{?;M;NdYYn%ppMFnapX@jjEpS!z8|J>N_u9e*K2k6* z2b~;|Pv!N(B+L#nW@J9dfT07fo{6`j^jgH^0yI{1k2$Fd6AA(1jjT{|^(04Go>uO* zXcGVVl-yedu$e(}Ew`d$m_iNH+q2lgbwK%d^LUF-e8iGr!zeY6h$FU7!KPCuTSsmY zg0(@Wx`y_kNk-9BPSXgI*J6QF_XhIQniJWTT-Cs5jE)z3??HmfG|n)#DSftj%R?1S zX(>9kUp38)eR?-(UsV-?3G`5DtO(jOSNo9tTZjD?G2&G?s;wQfI^sT_F8T?DG9)FY zXp(3S8Ww6FGC1E@tlZ7ppg`*3;}0po40fYZXe% zQLi1BMUCjU;rR2hUWMQjPMZ^MGly>|hf8;mn_-x3N$2IpqyPUTWdEvJd!DU0`9BPT zrU~ce#M4WZmLr;8} z#3>EFg;aO5^{$4zb;7ENefi#57l&Q=MOg!cKSx#2;RS^~PElyVJ(&_ichrc5WFxx9 zNct%9Vy%DgraOQ4JC^_}AS{BflnDzHpLKHur{Q{+Ph&s^cy|WF0g^cgPgp+=4H4tg zXG>jZ%T)Z;O)mON3t?5hCcypOWqnVFh&rB%PS{r3QL5q&?QO1Q#pWqx3+d=qp8rR5 z{#_cxiQ5dJ%(}s!CuWL{+Xh37n_JIf9upbj80H_V8709J3NvsqY{ zi{o;pf+-_cZN!;p8*X~R|CG^=_#w-^&8X?YT|!}dA;1E0nK1yILrPCbICw%YZ* zFKo<)S0vjkoI!WOR(HySj2b0Hak=ZIpuORKi&XdvAhXQD`&CHDwVBd0W7)s;s9KQv z;+@Ian$KQ?fY~Ik`16eWjmD0duB{NTaS1xI$!}$ zp*85zaQ9>AQJAaey~-y0@F4;gxXryQdUwbLjf<59+pUE5)-y%oA<)Fnf5`1$ln-fK zz83YQ4k~R7vVgMxa&YBxqmln%v;Sxo|!W^r3N@I*o^Lq?MMR?#BSK#s`2{h)4r)3!C zbI2HH?gKwpd^pP9hd3dApZ&I9zWYi-)6QK|r==Mz$XtKu!sr7qGgcKay?1{o9-o1( z*|PLCOf{P|;l09UY9Xq#(1%k_PP9Gq;C4hJrfx26W{eQuflmYrx*nW_|8Bv8@07=% z=kaUuAEdVpDDaP$hS#>WVTj`e3x3n9dUmdDaY>}=`cj>ctC?~>?X#m>niBiGEFP2o z8TXh{OzR&$CvPGPVaC3L38&Rve73TilU||>PQuA%Ap$py#q@hsqDx^MKk$FERM&EI z-_F16^dFC|>?44Q&XY^$a^xc`Nualku#fZ6PV=ByW+6$%R$>+glrZpr`;8tbTU6?v zrDL4YKJ%N`R5LLPuKXA@qBdnE%w7`idNx^KAkP1hml%sG5A)oWhgt+D9KnnZaF0JD z<-C8k^j(xmxLO0}9~%Ch(boM2_&%k~umXpvYs)7v!lI(h?sKP*-ea=#vvXU=gutuR zOcR?@A|2qW5C4D+u2|AwXKafrxTre>LyN52H7Vj4!?hK?B5vqmJ>U?#?1^tF+DvkefYbU?2UAtAd)o% zVLI(hvIS6j{gaPA0~JcXLcTA_1t?Nf%;wX^8W17mab&(nN%RCJlWeYOYisC{Cq!u|#j8@Zy=Q#b-Ic}gO3!Htpx8R^zR?`BL9 zQB#xd&M2$>WKq}e#i%+3Tq!V7j`A#o-~xW48t2vdYwHz;IRGSuF*WdOT@{8EjO(kEErRNB9OZ+rc&}O$2^qx)=LC{W9=L&DoG&^1pmp{~>VLj}X zEmWr}Il{N|@Rmc5cCT3*%##n+wXX$>pp$)R5kY$ zoS3iQ36X3h0VWfZrXo34%BA1*BNDA3qw2XfF1*|-4U6vp#?6mt-CrJCKLI)uI+tXl z%he~fxe(oZ-uZUi^qOxSAui%i*59Qs`7b)oz15HzDb3cIM0ho$LoH3O8QYG?{Ty>P zg?n~RL!&&+FKF`&x^Y&?5zOT*p}d}=NqhE_T9cWP2Zc|={r|F4m=%=!*E-cP7OnBW z!*+JGwLeH6JP+}B9JS!>A%XikCSE8->47{1Yn>W>tA;nfJq83u7j~9kW+A<^0Z!D?M zUHubOK((_cu$pwSnofKDSN#vs)k0&R6qbuew_9Aa)|96;C8D>a%|%rD*#M0xH@1m-1u4jOpfys6NxW;pvuv88!dPs@MW?a$M*zRwl(@0*DO+UA&j;TulnR*8cP;|+V$EDgFZ};Z+ z9i!x%elB%o7GGdI9#YmkHlN$heFLekd_cW<2M(Omm?$RIa%Vkgtu39b@9EPwq!+`u zs0v(x$wjtRA3fS-JG|(HbN+vxQ2+I~rbf=F>c2jT3Y2+0TZ+7IReU%|Joi#-Z|$YV z$^PyIM!r1BPW}dfWUCmYWvM7w=Vy@A#BS!sAJJ(fCs)a1CwSu@C>SRl8w4q+qZj)iz!^4@N0>=DI5 zn$)=-t$CjXp=mYnSMjn6SR_|D^yy;-k)pzHD9*h7?^6|Eo3rI!D>a`@a4E z!>WHiLm5;2D+NiB50cqy1=Jn>?Sd?ra>K&7g15$mrT6`6`^(os{j<$b{)2u-7|dI@ z-04&gIZ~VPE*G4dp2OYf4$K(+{z6{PsSY5!6cS*S@BT1JhrMj8@BYYHe_P4l!i=>e zI)nmwG-?Eh=RL6X53AG&WemcnfH3w6pvM24UdnuK;8LrLTiG@^>8OgR_4ehy0wDH} z`_9VSdM8kouw_ZU7pmh`Vlg%WS#F#LYadC!^xo>YftfQW;_CpZHGRiGQ`-1!-Jc?0 zZ)Y#4-WhtIR7i1GA5Izkyk~q{LLW*|mW4JPi~%zx7MiN)$`@`}`l?WE_)4l^v%R4! z6RHOWC*%+A<{2JzrlR3E*pTDGidIpU$@&%OUS=D z&AaXm-h&gI&YZA4Ags&KvWEr`GIT z#N*y@uchGT1cixTmK0L$F7L^r5C5P@#0oE&S7p&AqqWTLnC?g{qG@6^OM(A~uQ!is z>g?KwTR|ymi5L+TNZNv?iV~0lVMy{muS&!rsYM9N3|3m8kT8WIKou34i>N3=(uy@A zLqMv`i4Z~<6OmbgkO9IR0tAvk!gqXr-?P^DuJ!)D#UCtA0-Tlm-22}9+Sk6W3YVyO z+m9tCv2s=% zauPltHVqx#2@gZ~%_RJ`W_=5U;dF!#tLOXRKa26deg7W|^q+--VsxC=z{6TSqW}>9X=NX;E`CO5{!2(@dIn>z?Qe9P z<$Y_=PUO3X8z&@wZQlqfZMSbpTbc87*HJ6rjpvj(ASfOgyki=fctg6wS9Blbxq3%n zSR5_)pTC`og|u5BW?FxRq|xOa+}d_}^h)WDGM)GfpbTH1xt;=K-IYs-7~?8aAoR&* z9enfv^eE|=>Ifz(qPFroCgD}tN9RbdH>s9UHacVzElfji!;qFMFjV7~0vT3`+lDqV z7zcRMk&%v`oKv#3jGVeC!SmFz)?H_G!krB2OF`0;@U1tJ0NHGHtIfPUgs5nJLY+Qr zthWj&w2;ZT!MYam<<6qt%q-Usy?DjO0yXaT)R~!uzwJwHL)J}#;&|(<+>A;-v%)rPsG)%U36;IP;+XO z+llSnp7TC}6B`$~$-NgJcvt>!>+;En4`7~)kE_^5dq6WS1IV|pb$+wi$ zxT-bN&J_E%*{hRP^90rs1jxS02&-}o$clK*@3bNQq*8RBk<;>?b<<1v?*36|58i=FdN4$Lkg&fm1#~Y<$dX20W&qWQlK2Yx;J5}+}7T+yMFp#7{!qgGD_8+x_He; zO*Q_IGve^LSZc0t&Hj4TVr#}PB(&jaNAy)#V%E>6at1$)Q#hrChyM5|0WR8IgbI6m*qk3L-x3hXXFia%?($+ni^fUbAaa120g;}fol4(nbD=-kP%*!F9CfOpoE9O?t6NTzGM_Pn@Q;lhoOHorm% z2N6TfZ!2pmQIFt}hn#9g77)&AKUWJ^|jXLvI;$Aq+%mrrJa3v+qrc?YkM zD&nT|JR-8-p>tW>^PuqT#_3DLJ1G`*;)gb$E(~-`1on|k#?Ff!S>3qeH|yLM--$kH zM5L-ivfkKeUX!6FZ+h8wV-;J%}r@Cff;8j(r=+d-de~??WF;-Te-FR zlBK`C--|XC)RyT~?qLr6Q-*U=v~?CR$B1PKchW$Y^1H0(ep@irvu$qeq&StkEQAi- zsf2>po^SjaSaZ*S^bl?YY5s$^R|~(BwCEkWW^>SIE4Ve>1U;{48E8(MxF0#BwSJ;X zkmJ+S1v16h@65~1VtUP#`v)cZr$ULcocq2ZfApvMZl&bsD=jEZid|*HK-66Ss?b5u z_GHkA3?2M$yKeKX!HI-cUxvAb6XAyYo=+DD_pM*$`S@&2y@F1A6FR`hBFQHCHWS$r zqXZMy@M7m4d~M%dX2hUN2i(q zuE788OR+|pbdUWXIL30kiQ)r5XmrgV4vgPya{AM>rpM`QAOK=$)tVfvM89JR1pbiU zg&BG@`K3t;RH$y)fC%Fh!^%cDv_brxMDesH`qm@Uu+>TIYKnx(+Nc|f=uR1P2%7({ z_6Vk8Xr)rkJ_Dosx!3Px(N@foa*UdD>B!px__4%0NPBk^>XnmY8M(6|IYpH*KqINe%0B5p8}()%iTw98z6ghEDm|Z z6=D=4Ipvr)ZMH_lmj>G%$p+}6l{NvQbd?g$78&GOFs8jv_`Z>Wm~~HC%l;f+d8mH$ zMiNu9+DK`@4U3>tnRyiNg)3+<|4`Fb1$)-Mk7uS?si>ZT3iN9u@0aSy&Cm_G%c(!R z))J2{6UdnX^yN9-w?m_eCn`V99pOrD;_>phC{1ZXAC1z(San6K&c0gWGS}~HuZvko zVvkpxv?g+sut~_dg#(BLR5g5?-T&xQO2Xc;z)6VKX5eJw_6KOJs;#S6y)=&8wPXdn z^WQI;LbW!?zXD|2gteIUnF(rK;}VZNw$X0_QkGAvj?Fu?-xQyLB>ui8e>X3gZM7dh~3)i;%MF-3CVbWCqdrL0Vzd9a7s6V&{bbERcOxqW}M{vL>h zrYydJX_Gy06`1E1n`Ore-(~PaQqrn*pkq|@v@`a(SBeF%s&8NPNz9^HB!qbHq%~k8`p-$&P%-35YVF)inv+J#+{9f%|1l~5ZCW_9NFR|`1*VA&g;!%Vxj2C%i3b8U(n@7l0C>`80tsM*FC;isSxuclx!eix2 z%chM@gQsjE#E_ycU-{EjJ462Y{j|B8D!&k~BH_wkQl~XHv7Qw1r-c5Z7<%%SBpjj_ zsRA}@Z2eU|Cjbl@eYMOxlKQa*k_DJ!H-8$;@GXwTj{v*1r8D{=|KcpF8m>YpBLfP+ zcvzWPh{(}blM4$-np$i+pJ=qfwu+o;=5ZHUi!9_W)M3u={5ErF(yyyV70}v=#_f$m z2*+JMbKlLa{C``R-cv63X&7~afg0bm4j)!O%~_?q9m!55V{yzZDwty2KbVR=`=0#>Xgfnmlj!WmCtccW_3rP*>YsLe$urDo+lW?)oQ zB?uA~K3Q`ktls@ACx>wjpPQiiS9liMBP530muiUG!3q1?v9&7NrSc8PhQB;Xm#^DQ zpM*pvYWPBzPshhNcZ}oK`v9 z<3Q3`?vbx4x@6&?M<0GXGbQORE~%_YA-ck(yky58TUPaic@bIfa^`N7?JqKR2OnN-)fBFDe^6M6OtfWY|I+`|eBQ-D?ToeG_GQSlEmJx+M?3*dK zZ1}n-M<*vbWG7!Z`8sF{Cm)*Tu43Ex{v$Hv?YolN{ZEFcwxAjodi#e5=uPSNENy#j zL&L$f=Qhu2#D+;TX;;TX^+>I{rkYIJ9Pz=c!d{|~GFrmXDU=7_K??4~{fdnfwEf1| zAJd;FaCc|im zqB*{-n$h9t#eV*6z-4O0tEq?JgBxj1`?nwB)i(^Gd|f?IkykHi zMRlI({ow4PRd~nFSGUJB@wTt5x^zmP>w3~S%*y>cjGPX91P{uC zO6qRhq{ow=&)r_V5hwc znRGq|Siyhau8)h75RfF;FS&|cNkq6hf-r>F-{R>J47wi*tLkiy2lXvmCLesh!_xv-P@3}BBJR1NFVB9RJscm_GZta`sd)9CJzE9%qL5C{*#ZPQJ zpMQD{VL$dMPvT^Zfby84ysDvLvn@Zfu$?lLs82CTn9X$sZ&mn;uqVydwQ; zP%-bT!^+n=YlFVtZ*HO#DoWofaD;KgK?6H3eonw9L-@`zOj4Avw8iTr=T+QV)R5zq zJ&?FNrYIZcjH<(TLL0N_%DK)m(xsZ99P+iL(Cab+lRE9SVV;j z)-TdZ98kO^lPYh)o?Rid6(RIv`oufXNdUE3eD7ZbXIT(=&BB9iM?c+gKY}>*UC50x z2b&bTj?70dXIJU*RT-ev$W-v2^*dl&o5ffn zYX5cyUn!IJUmRXLmfhT+_+h(WG%K2*`SeQ|i;X^shGdT@du|r3s9;KG zHY<69R9jI_j``a7#(IOSsS?OVvjJOzw@MNq`%*%z#yHhfyPr_L|( zR&0d-%zs-XmFY`5*~hY7xx=Np+t@aeej}^Y1&8C3SoQP2B1qMv#Pz~&*1Xqr-_B#^ z+tBt{LONPl+5qfb!9u@YMn7sX9&S-y5aS^uewO@M0%MVGVBkLfPrMw^PK_MK zHC4gYaqV#*UE{g}VZQ1|)Pz>x2Xdoou~~s2jST$5N;Bf-iNv`D*!AN!_E_=j2Ln+N z3uU^J$|TufRO@k#sg1yAkaf>>%UkLXT+mJFC$@$k6|(4-p_W2w#GGrq)cA&(oE#DZ z(ML->x%L3lxQZvSN*&$_lT@W8M(FKHh+zDk6i4wJ)UKWH2M!e}LhfL*Shdm?2fS$R z3!E!1TLu=K(m*~LxW_4K!oRU``Vw4|OkeWU^!!Mu3z|{b!5R}DFa7o)J5pMczWJv# zX$nT&KAkieG$AcUMKT(oALWUsIJM_)ZvKpOc{7|Kjn=TGiWJ`Vlgha{q^2i`_l1+= zOX1G5Jm`woUmsiDFgycyc*@RASn@UqsXu=>D7Zs2(zrmPuw!Ro>$SwS!f)Zson`z} z)TiWwn^JQtA z`*=vgmEkp0Jz|PAb*gvh+?Kvimd(MU<_ns{EW3t*|HdB$XacG}V>~aTQu5wPaN7pQ zNGrDSd{k_5kcNCI?;9+dd^Kq@HA5mz8R}`Cc8{a_M+>h&NBtB7P8FnQ2jv2TkUJdT zdHswWk1b)1NC!S9X-6pqg zWqIJCupaC^@B2#yo**YSt&;q8^qU-Z#_Dg68Q{?zec2V~1OoVk34 zxBZ$%nar7vpYkn;_FQaKE)#0y)82E-)a<#w=xAyR6V7tyZTqRXh~5Hnx}B}UD~$V zd|T4rNPixXNGaQ@NZtbDw&wv251Q`=oH09i`44%wr$OV#O;z0b&&@0q(O#MRE@M7d zS`0|CV2NtQfb{!@+>ZW@(ThU|urge*L)P{$mxVdOl@9eE`@L}jaoXzh7hr6h)Ajx^ zYA~g-U~BrQ#^o|F9h+QSfQ>(aQ&I+iIbX-n#Yau}h_2(9kvtTj8S&r<)}|O3B7Oz2 zjcaYbK^?f0z|jrFvC$Q?&uKYBQLS;;^AayyZd4Jwb48_e0^j%!J+Ha9mi4X9eC?`gJZst(w_KZ0h0iz z30qpsB{7;bJlFalMOfUom}K|U4MDowwb@vMB(L8_hA6WUZM*e=)T}V^*vgSb)6cMS(%fRm z7Ow%oUT5RZ&LKL#{(nQ&$~UH=6*@1vT*Bgu6sjI32dLN?>4iz|REPZhH?b{U{%<+L zmd{YKnY@KdmTkm~zlYkd93`y$0AOVP;F`@3)bj{uO`}4PYE8OSz{`dm+Mii)TGBoA z^9?+m@f<&%4|3FSgL-|(-`s4-<=xM~W)(wW5G%q)(@fc9C(+LW#Z14}tGv&lweQ9a zR>X?O?I&erk!&qfibKb4PkXj#IN_`Qb4gKT_bme*G`P+LPi6u>OnI}IBm6GIL@z?! z7ZImR9bi;@fh}y!R!QGVfo@LT)lOPlSjH}8Y25BzmbZBQcEEQ>=Ru&Jwle7PxaTXaeMa|n@Pp(Mah0k&`Ez8u2ZPTm& z?6=#~v0G$)Y3vTcx)rEKo(GblS37(62M*>;Th><#g;B@CJ`|0W3w-S{R%LqY%n*2> zTVs93&vt(`?Ujcmbf#789&@CJxvP8d%+rbfyC)cmq^-Y`H5YN)!bcuFGrK{*poD@z z7+;@}@XRR7pmuZO>zK9X{(4_Y0*{m|DGlM%7^Wmy9_=G8IO5rB(L>I!mkBxI(j{+7 zP8)g=jBR$XSMCP3nZTdzk>*o8kb}b!TcrqGaO<~#P8A>x5Cg~y5SFSCz6~!hh?K<6 z!0jvCyu=Uv{Xo2Wjax?*0g5fmM@|hJux7k#gg5)Kiz?V`L z9!$l?tA4o*?<`G{$u-Y$>uY8V3aSCuDE+A@JI|Yph@%WVU(a)5-$e?@KQNFZa!s^Q z3aGjKdf6c1HBPntKo)|VdcEHU$nt0_8|;5B%a-l*0(Iy?#+7AJ25NjbefhV+{+Tm8j0 zBAA5!9aNs6qn8U+F>d$gu4Aw|Emq0e}F zoW3Pqho=6_bl&)HV=&#kCLuefS^b$j%L5fuQj}nq!Q^r#vi#fd6IqU0*x5pDPO>3m93h{S%(S^xTz_>;^ z!HW9{+@!9SW?Ng`SFUOjd7$e_%lQ=YbRyHq9NoU%58|_f$K-90vd97O+gF@~mBL;) z0+j?NF@4Emb3>1kBRxSCduu(a2rJ`*z{r>r({1mQ+z$N7wrI!aMjPQKTi??P94`&E9K+c*=S(#0W|jv%8L{rVSlO%> z|77_k9?~MWSvZe@w$yi_5vHM8m|>;Ab3N;|TXKkMAP`Xf1G(qSTlGiowlMXvKUVpM zDFkmaH>4WRr+S>AEjrK}KGmGhD$iLKc9u;mFAst1AH(gM;f{dWmuk^6nqd*TO z_&jAWpO-fIL|IcIOod{ZZFUmtu3BNrEr}zmM#m`Gv^Y1)S6)RzhTdu3a7>uogx3LT zlUpg)U5|Q;E~5cCQF;s_DdIt77JBt>36IA4DOI!sK8BrSFT`%G&x~=zvwS#d>!At zw%3ggxGu9Dn0`&yI}#J|T3_(bUuC`duyP{lPqVjPq&OjK^LrYZ{WR?(o8!oOj|Y$y za*R#Oq}wOGJuse$MNw7o$3Z&Hvb596Zoi?RF~7*mOn>_$YiG#zFeKD%a*Ou+?dZMq zwu{*MUzQNmFNN$E{d;2IcG01d-s_%Zzfx0(Bd-bN}d3^p&%hQ=z*T5eQkd0Pm?KNzbpMFnd_-5y{}^^YN|qozT2@I9dBT0Fo)_uBv1%b4 z8SUwta5y6@N>mFeV^GQHB}Vs(y2Eg&ZhsR&IpNsiC*8gHA1pvc@e+vNF+^8$6p9#+ zBBcf2pO{X!5MA&S#1sjsl;_CSSu?q~cHyBb4!0R~9Vl|rm~)PwekIot9NZOL?Z>~> zQiB~9k8|5N&HIb$NHTMY?z(tn4^1@t99R0Qq3`8JMesdqhFEgkrZgx&s7gp*zkj*1 zv0$>bls07E(y2L5nG;&)xnea{3ap3TonO~Cm*MUJQb^9e4D} z`6ZK{b=o{>;}nHLF;1eND%rGK8vP@;Vs6YRf`1F2&tK8BA>?nboM*B+GR z^Tr*!<852(n6|=shw9bg;lQ$u-PYT}O?X?TM*ruYltN%;zk zRquF}nyhNx_+_hKihQ%k==R5oHnVjTbh0Ff7K_Y!ZRbA=NxxA1%M&dkoke`#PtXkaVL-8Mo&FAa3-WIHmPUR-L zSVZJ-n>bbN71-*`C>!7T+~qv5Pg3KUv$2g`q_M(zq%Fn2_F%0f9p-SzzB2x6YoUHh z8>KEv-*#(g0ZED(3$?tH%>zPSLe=_FkC+hK{mpR(h!Ew#NR)Lho@elwF+D%hO6Mpx z3qYlZ=1(9hf|NDq{4jBDn!~#=p`F8@Nf~z&56}a8z-XK2AXNom8@im{jSWA=ttIyd zovhu#gH}Z#AP=-Wp9=?3t=f)91~I&i=MWF81eEF-b{$}%MeDC)R@*YpWacJk(Zfrw zpU}BP?iqFd$;w8f=efAG{9f*{w*Ft2Yl=n_n6rT9eRiF*LtKXoyw?%roj*B+7A>Hn zO|~H!jA3`R%Gie-ch#slzJU>-nDtaOwXURqXw?$E{-bg4hnuS__m5?Rv-#;6>dPIT ztIu-wTr#Y4hGo~kv!dT^{BrWknmz7@e=St;V8wj3mVs)EPt6D*ee-VgE1i~LQ)4f^ zBRV7L;A11>U;Y>J14l3g$PX%eG-i_^ah14UDG5x+dQ&Sm~y z<>z0ri}WwESjB{Y&q}E7$_f(JJ_tJ+c9pIfycuEDwW?jV1pb`YO>YjrYzyDKG}Z%J zAMZW0V}z-h6Z~#i>kiU24)S>&;gsd`Iv^+ZzXbm6dO9FSRel{1%gIuPI6Rvt>6B`X z%}Vq5sTjoIK=Q@5`R^DR>|u4{9C--AM$aA}bD_+E*2P6y5IXth11H(yTq<%FJZ2i# z+9fs9EbYMdX6uuK(E{@GV@-|7#D6s2mjhm1qz>Gm&j31`pG@PHRL^Mra+Q9!*6ygA9$3-yan?~&a ziJUEdSBaRnZ;niqr~&xEaTwhqGuSp8b}fqc=c~I$FCHgU)=${i#YE~!V@Gw3M+GB- zjs(mKW;1#CLuE~x@`7K-UXcS|-kh+jEgsfldn;ZiXeQ!iD%^Wr^ z-mIgfJ_*>^v^5DTPByM>2p3cOzp|S@rM63+E~Jj}0WkJ-((z-M+x*ng4Ij&Pa=z*} z2W787E5s{J8nkXOBrSGIam-3yDTXsf9J$w-PVV2~nU`rY_JluiF2xI9Jt__!BeqT= zI}O6}X>~~~0Tv;ULm%gmft4Ane9<*pPtGQX18%+>>R+(ZnnQ)sV5UhDX_QHMhHV~M zjI5~HxYLU|OO@-=t}A8Lc8*Y`O0y5Xc_!bUVUZm;X6YOgN$d^!j@IU~x4A%oiBDQF z==K^roEp1w5%hYRh2B~d{E#0=zFitv!CcW!0YqLVI_^PJo#K}k#!1gv=SChR47H05 z9Epy9A? zV7NC_lAdGiQoH&|SMN=>LOG;RIS-$!K&(B^0x=FV0(wM_ts;o5)03%yRmK7O+c%K{ zeuC`>!NsjE8=(Zp!<;g}4WIjMxik3yZF3-6D0i5ZPy2G{KE95r^f+y+eh%fz#PpoY zsUg!_xAJS^_$j%#6+cgzPHKW10B)%DH;ztK#P)GqO7bvw>)%HRfB7d{TXgn8wn_Gr zez)ps+lSm^ihWObZC_5lOEK{uAs!jU7lO3*z?SB|_?@`V%+_iAeCwW?blwKgfC?AV zBPxo_jTr-QoH89qBqk=Yh^ar1S>fwni|%qln)gTBJ7W4A@#eoflFvo2a}JOHdGs3nbE_{i`;*;YtDEuPg^`(N+^nrUBUMX_ zgN}zwRl5P#o-=pUeEq)80T>85=`SmwYjd@Z*tN&3bZ8wZf`KCaIxOBE8DPZEXrCEa z`)bHnBCO2=b5-wqvjA>={NaW1-iv`eV*qU=OTI9-9XkWsZ@2x_8RU#ya8l#``@QVy9KH|j4BI2Fp+h~YOP646uw>b3zCXx8TOotWN9 z9Bh>`@rM;p-AsP7!M#&;yo}69ImWAKfLi~ zzIxJq_)ci$9$clhx8sBK=!)y?=(3Qp^KW{m>xU|vR_0nKM5N65gpD1j?YSd-^z(vo zotN>kBV$2TAk%6LYy-%BSj~S7HiI`VkJXL}BF0(@8hHUlE=%K1xARz;@c5=BBYN=d z%M?drMm+z%^pc-EB${`E0Z5N3CKZ!ogP@GaVONArF`?_r7|wA_6M)~DVo{DhfF&Hl zt11>(6){!wDY?#@d`LOMm2pxN64a0zKG<@#CFm5XBs%+6 zdDdC)n!xXq2$sljmBq>h=m`iuXJ>vQtAE(q1bLYFfv3&X3^mO>U;!yfLh=`X1}+aa ztPM5{YZ$B_cL;wroOUHKgWDP=m-=u)1f|ZT08Y|)20jspr$|$qz5iMg2!bP z*NtO*U*X=Y4oicfvx-b%e{_;&Jq0wI;DSH56#F1~+J=@c1^TzMo^rtHKq1(JiaQI{ zROTQ{nx^~`_q%ct0+-(LhKnnIO7cC1+hHPAFEqh@Q&R#@>=#@1;=r?VuBD=@EW{vjukC0S(%N$yjw(+>;NnIg~RPyJB!V)8G_Aevz8`C8-e z{k^G=#P>>#{{qtRB)w?t`Pb{>b~vNokT%)mWkBrz`6z*W+r_7(?{b74 z74!C0o>Vs*hlnhh2;V+G!d%u|be}f86tw`5jEwwE60#pXdiRIHFF&*_i{ zsxPbE)tK*>HDv8?SlDCPrvspwcr!+gRgrbVU}_!?AjTzB0d@>FF%(AoxrU^F?&Yxn zK1EA$TdKXF4GO5ZD>R*?hkfx!8LJ(;S@-sEe`((T1XHyP{QpBmNqDSSyrBy+w&Lp= z+qbkdWl8k8#QUozKtwtO@HTc~UPH3%w;k=_QP@L8ab%3CW4Tp~0woA4q!ApTU7#5%7AE;Z^7L%9JVV^lQvlp$`GO|FC++6|8VJR-~V6%NfX|NECEH( z7Ly2H-_3ytRy%C$nb*j+8;8WdGG6(Pr!}_$>m+k6DVoMEz?V*(+z zB4`H<2OOX}8Ik#AsM%8!*D3|9=GN*wLVEaTI~hct7S|ida5v@#Pw6N-TM*!)ob_bR z@q<2bFT3N9IU4J|dFg0eqs|=f;MB2zvi$z!)y*&fz1ZxXk`%r7m{K~b)cLoHFj5={V|9G-TZ;YO9R2az_mwGh{G^d&?@gc~IkK^>u-Nu*phP z`65~2R$AZb&;AyYIkxsYy>8mZqS;|a@ovV(E6ZCFJF2{zIK7Z&RD9*+7CERshm2`h zKDyEHIN3V`7Pc1ZCccYNmo%Bb<>uiRwX)mx8$yyRYU!9m4dCAIjgCcLj?(l zvdat0e^Vwi7YuNA`(^UF_L8#1*(79`@SV(>YZg~c!^#Mm*;=u(NtfRKc$ffNt`9*c zs5}x+P<&8TNKm}ZyE(6X2)f zgC1RMnM2(AmYk^wij`s1llXH~0l`iE3uK{V{Yo@+J_8koojSX$yy>!u3s)NczChR= z?aIEaWjH%zTJ1uwskROHr_}ZUO{>e8OFr<)+5U;2A4vyI*1LUW;m8@iAw-aDl#QIa zLvI=~b)g8;(XH>(Oi;*?L2Wp`$vl(*D_VK?>mAN-0QY2PZ930x#7&(u`4bolxGc14 zgF=vCb?V(ugW4H7F$QSFh_^amemfhSPf?XuuC&2WfHl0YnJg%lNP0p*9`UUtlM)|j zE#)=%5xH~zroeN_KDhA%Ko1~`gOLyXMu6%5$tKEB%#UGk=&qhlzVpn5M)EY`W<%^z<0Ka{oAIB}U#7C>^@ zLsd_l%0{2G0;plw*Q^%jm=P0bvKK+VYE!t`bnfPp863eN5W*k*>dejc8UN4zM6Sl$ zm}+uNL|0roSbEuyZw3pCqiY{K&0`2`=t;pC7x2d1N&T_II7Ekn0t2k^Liwz4IpTCXV)0t1(#K~vQSVnIQ29G>qOb}jPeZ{=cA=MB_L!N5a-k_- zw+GQKa-)k4r9M9h3L|Gs4u)6yx=S|e?-9SYYUC2f_Yr&j8NwT>k-gOb@;c_97t(AY z7(N#|e9qArT~wJCGeVf_JBb6@Rs_^X*I4^rZS<#;jFEV~l#qLYIg?I{21!YC*4f(B zkE8AgE1Y6^p9iYK;sq#80plP6$Lf#$pxnI&V+Y^NANPvY`2qU!_NPJCUjd~0)+Lp; zDQ1i&W7VhU*wq288K&@|sgEezHFR@#2I(%RRL9ZSqKP)r)d)Bsfn852ov6kM5mSLt zUPeGrbTm!|BvW_O_`XaxN_VVtN=Z8Z%%O(E_ZE+y)0CtZ}%Ze>ohafyptyXYb z1?E%>#BD)f_{}$~6;A;E`svp+->S3_h`=>RHe>zzQ_1y6M429B$YcJm`K>MamybKj zmX3NG#v;ZLqrF+MR?JvIO>`_t1~yed5?nXkl zYL_wZWbtnEu&LNmB}ervCqMSU3OQwI@C+(aDE_0@NH7%keS=c`B}IPe2ei)Z7ZtYO z8XpNgqq@bx=2-XIWfQ7m$=~?zPk2+mOjx$YHl5XT9x({&C2`VldSv&efNn5^i`ef} z1%xYEitnOpEV}1-mj_{GR4`a+p&pw$vTy=7dOriK2OmE_33yO-TWexT9z}JsrQqG` zclv{N!$Vh|X2l9(Wby&Y&v(IKz6-KbXM)^rzT^TY?jytG+97xHYn109o!@$J2JV|T zxO&BRh3CWe0;fO?WlsO(=#;J&l$(cIL`=hg51`BK-Lb9%W65V30AJ0r%5efC6tPo3k&gi| zKVa!*>WEs$0Bo%1?Y8I^z|_nDGsh>omn*T@I2L?V z^Nt@j4lnFZ1s~*+j{7t`eJ4Z;7V7f&#fOi@!+(AvOpp>oP@ z%swYFEtnq3lqapuPP$8UVr7E6zPdghPX^;IA=uu6WhW#O+37}cJn3U!UbUbAvYxhf z8f|(`5{TOG@kWXenWws*^r^9sHr-nc>D1@% z*73Bj2)b=;E3>8t*0mY%ZH8Ucg2(f_eU%|zkups?TwM&aGD&DRFof>sUb}3m??LpN zI+AGO-$wHkEI$uZ-v(|Bg}_I*OdYxg+y!jC{BZ$pszQYV>4n!0MiUz26-|2Xmuplk zoz(|2*5-qvQofZo(tWsvt-+IkW_ zj^Vm^Q@b+4K_ZkM&XU+nyA^dKHlC*(Zr<0nzW|b+EaRDnRlR2+t~V}cwiv!c#@E!xN1SnE42TVZYsN7Jzat|%}qwg zci+V4jL2I0cBVRjYd?;@hKv$P{9HXuZ!>+c6$d>0!{LO>bJS_U1p1Ntb34^q6tL!4A6gr719uloiW@7-EFMXjE! zWlwgBApBBT@Ut3-JojtLyF<8hVRI>To9h@qvG3|Z*YbkTN*p+Z@}{QakemqWx5m|aD(hH!P===6IZm#szUfW*W?#Qm$_!6IkvsC#&(0D#HrZZS-cVvJ#Iko_p-gLuvsh;Tfs8Iz7K9a@%F{EpG&`c?Awoi^CzN9 zVss24hjj7)?Ig!9+I|PD5$Art&4w9%_H$a$OU*M&HGn`*UMVE^Hk#S0({qsf!F$qzv>l59(ydV^gP2IugeR zz^5r)@PxrZYM{2=*oh=WiuWLOB-oeOD)?Hmdk;o|(7P!kR*Qc6v^Oogo)iFS02?K^!`{=l z>C}=Q?lpDd?dln-TT_`AyS-ir2JITslmiA!PT1AVX;a;pcJnaHtwCGX>%2)EF`!7S zOKQL=Vq}YtkhcNctL^JB_F>fKKi#f5%jwO+hhXC-WxAb=gowG_Pi#nszx_D)Pq)EA zSeOTD+D$xj=qDYZrP8EWh6>A`)2Hk98-?Kk?dPO+`~lYXQAWIbQ4wH;7iC>KVh~PH zxYxB^8Fs0h8yW`t6e(n_fUt}nyywL5z@5Osgmpg?*buC#(TN3EG-3vJHl}}VIN^Pn z=F$8QCSd+JGxvTW@1gzHNqo^qMCsIXStK_GrVFgnnQyUN`nkM;fXo# zc(H8u-!~C}S4JDi&M6F7vE>{~VP|fyObasS9N-~$@TF;7ys$8{YqYAiT0WIQpllZyO_6PBZBrHbRz6Yf}h zDCbJ$7uIanuc;S0-9RzwRFG89Gbhi$RyKub73jP6MhqlJ)|cPl81Y?=oyv(1VTsyU z%9~@nn@)|l;w0VZV8xMJrSQ$z7ppdZYOw*qn;^;+>Sixr?u1e$uMCqChgRo!FW#=b zn;38a%DNO*ejMV2#cG?Z{s7J*RK^Y*zf0wPg=FT9`6<(W=E7y=xU)=Q>P5XqRofnC zZ(&F6&^>F1L#v8+B>h6($>jATwJH%WUd+pHZl@UyzJe-%2Hac$GVX}r1RY_9Fs^A5 zG<0NCMt;CULS(MtzM@03Gp40wob=}Gx;1mz$c*?;oolR2#qBI+bVX|NqrcUOe(}cG z;3OMZ!-)l(%IJly6Zp00JBGuI7ucluKcYE7S%tnsjaQ7v2;?kp@fmzz&bmPuo2>)V zpU-tt?z0j*&F%bj0+qZY=xUBVR0)Lnb}OAcd%nrrkFJ~FhszftCV92%CmRYfwj72j zmN%CEg9T(#(S=S#Jfy**PX47uByzTHv><1!Z7zC%*WH7TcbZ;T2MB!esKulZ+$bfK`-938o3?u4y>H>*W`I(gSqCSyMZLXuE;=Nj=Is8-Sbbdt5F zR+x!yvTq`q*=T?*$}+;sroMmsadA-hAk7KmVYRA!2MnV1fffG`Rc{)X*1dI)-#%$# zQwN%Q;}nP6$2Q*9F~tED@b;fuZLf*m)WmsCBTHzPVjB~QBU&6# zK!Z5Mpb$lzMFuCFz=1)Wey1;<7ynnhJDktqu)k}sz4lsO%AY|&eEa5#NEG{4)=~H8 z@WZcsvzg+#sO4X#t>Ym(>?!J}>IL{f1ZZR#NjUFw(6^PG$D+KuSbh6nzDXtrg&$VK znMMISxdUB&kMT&HH(c}i$rF8NItDBv_Qxx0x}J)8;GU z+wa-!vc9*E_)D@CplD%jW$Ku0LWFud1*ymN{3k;yEV8@-3V$9JA4m=Ab?@yK2GED~ z69%k@`XhFR|41fADykN3w6G4!kG3D}jm@S5#?YeQseD?Z~ zz7$+F9Km%@kKb=8riA(anTSd)zWuopmQIZ;x)}{LbJoEF?;3fXldN_Z; zK6AijW9B*CjY#y}k7k1pRR4eZS-`=TaUtwKJ`NY0Pc(t8byn0uIW;B?vZH_y#vWU0 zC9T!p7c@pm0(P?WKw9?aXr?h)k8n?sqb1w_MzKZ#oh*Dzf$c9-6&8Rl&D$6p0(;@i zURg-|5zl4M4pj%Z8|i^8FGClNiDn&hQvN+(j9_2+HxCSV%1yamkkX+A%RaL9$Z6dv z2cS5PAN2VjG|}Gr0`W#(fe|0M`(!(5&EF5}_b&kHj5KCv zw+x!;<^P=XgQ36+vizhPAO0Nwp*?KA-p**+kC5G@o-l>a<2U&qH&m0_12(@TNm6f> z1^3Zd(huOUg{=#lUwI{2igD?`UEPc(qGm}8*~;lj>+Pk#bv8t7HK0+SK)S@2_`3QJ zz*D(q24uCIS`;RiKE2JQN*Wd9mS~AVdS32@fbhYK2A5yh%`fgEJ@olnw~OxSsU9Rb ze1E+mYc=1yX9esN>Hs{Wt&7#bv!5Ywo3p9v`#n9S+6%?{oD)E`aC!Yg^#TyQW91u& zY47{9!OXel;JYQ0BP4x~OiJT3qt4t~Bw3ytaO zd$V1vzr+8dTAk)9>}Nb-7YD?r{Mje@dx*KUsX3sU0CE?KWY?D7b<~H2Vwp{lof%#U zTF$%_kg=2HqSg5Xq<>U@+4XYAK{Kf?rxa0CegK>@RzNE1uFeXu1Qi!-W(`>4Tl=Ji z4yWGoYZ*=l%pKw38AETL=okN-IDy+A1q>Tg$H*C?$d7#f0z*>wprrLW=uY`1FT$88 zFDU8qiJqZPMytil=`D{P@!ayqC9~-Y;cwNUJI!*4X!zrFz{VXw5A|e+-CW$9;>(Oc zIQT)zJcZkm?B`DF%4Xs}wfb5bU?Y=6>P5NAv zH?y-&s_;qvZu8P)MEm8S2^fWXs3^WU=`K z2Ya~pQYCNkOezdp`s19d2?CN$UHX{DzDU-nnd~XLHk`g`?PC-eY8@)d`IQwf%gw!^ zDpoyJlL*N_HD#Ib4GM#ox@T)peOu@!st<8~GDYOj`AdQ@BC{UYimb1`j_s_5{H)-WD6?3j;vgem-6Ds_&!85Ew~x3IK)u0}oQH zV0kI;_wBt;H-|JmLOdWJi>{?XM>KmG1IqlA|xUb6WAJAGGRKo3Jr1mvlEJpe83eqaSP^ z(YUhEFcX5eGBVXD+rGeE?Wu||?+jH(4y^gukh1Cpabx7_k7$64RipkF{4bKu- zD0XYia4cw@2rs{=Z_x{H0)!+h<9>K&EtiV#i|Z1rd!J`bB7W~h{Hr=VE-3n9LwH2y zbcH_BHe+7%dTVria+zqNXg3t36;JH@sKaG9=*b`67TCgaN#%?1+;?Uc#qjjhRR7jM zR9m4#3o1m@hvp!$sPz0SrpKD(4D8Y2Z|Nsz{=4}_XO4O(2XcA)gOPAf_f7cG=4Wec zPiIkYi_RZ?IPWY5b3xidI zs{1n~Co2&W*bhFzGbZ+Cdncc;w@-P0ew$l9sfiw$SJrzZ?3BNXdBnfund1xu!yLyP zi|+2jMftbDU#o2)3TBgZ-$&|EaVfxwz6}$sl?+J<#&j&+n{Lt=_IOnJp;t|&aFUu8 zu`mNMF%DlH*)PLw#XeK@Fd-PDuE3S_YSq+81v!9_HE9?O#7wHcN^sUDs6ZAuy4tt1Nhe^&{aKz)u9i;@ZRW|X&RVCG zCaF$>wpEiHBE1Fvb2Iij2~7`4Kg+#~>d0rq70Ya{(xeJ$lp^WdAe8qAG6`c}_XTWz zbhRdPXP|m3N1GImQgTT89=(Vbhp{o%=jSR9^f#)~x}WjpAu)#_3)3p3`$Js$Xi)*+ zEHC>AbUQ(9>_cQCvQ+|p5qNM3$6Z8F3+ z5Zl5rHt(!39vl%7L~;j$oH8Rany>JLtQ?S|IwRLuWkD~#X+%KuBPzcP75Rk8UY-%8 zn2B<4^g6p=lec6HpbZEJ?YRwmL>icPKK)H5#5(k3VTrM^!~ZxVeV$YPZNmwY&J!MfxjE{GQg#}b()<~|lC z1ep7;f;!Rz58lT$rxk;iQVX9$qN3I|5EBi97OJiiCufLXS>@yp6eQ|eaU(|^C6D%s z-}EsHiVE|WMy4wx%t?qhs>Tla_}|>c{R4O3fZWA-Oszyagj1$w2}Q+(BN+-^S)+$( zHcWvk)-hN$DfWaw!HtX4tRv5Amn`*`Ff8}!+X&V`ueEK;Y)B$^g%?rmf$0TOGyICO z5;OBrj>7c%0SRYlDwxkvac*uZrUczkl|^>)L6Kor%)zq7@qV&2&t>bMkuWfqU7k$? zWC8mX-oB<(&sXJ^M(IxXB6dbXjxUfQX0~_;>knVelYyoEKeZJr-*LVaOg?!PQ80wC zn90EeSm8q!h4T%}WdW}$6sCx4e^;<^D^p(Say&v<2fSWwia zSXtv2e2ark3d(Vpsl-?QW;yw{K>GKN1n!^!U7sRzEqC(ISF`uKNAJJ6CoLGtu1b^8 z8@9AH-&AWJ5V6lmgV?uLr{CO-Efs#YU-}PwV&bQuc;zRN}aJ$l@7EL#(cy z{ka}ve_OZ{7W^#*gG9+!%oV=ZTaeTzt9VBt*sySqGya%Mgj%LF?ery<4-@;Ogq?Lz z?_`85a?0^drjpX5F8+}$kNny7fBugPxHh`xI$Sw0vJ%Am7ovr-`Z10@k34QT=O+E+ zlJRy>VyeUa1W_*Hz8fFXkmoycjZ@Ag&A`b>#F@;r^>L zve}fr0*emPEpv;)Foi?%m8`8{vsK`|94VwIaf`d53MKunG^s$szeJ+oSS-|8mr5QIk?oz~ zI^(FMSyFiCR_3G?3jd-Ek8scQBOWpi#aT8e!aI16*Vg_$IAMfP=kv_WRz47{-Gn%i| z@#@JD?lg@)(r+FrGqQs>J7Lpe^)gZ|Sq0o9%R-0`WvpxPycWniS2MXJ>KDZj#y+Ce z>R--ac+r^|{_&4kLk~$8$${LF4{yU)O`J_zc2d@dUBEr`Lk@w8qMzSvhZB&&&sc5<`pg9%s*^zIwYO%-jz`i(H?_RAdx86q8Mi#0 zl#4{<*mf(d#?_0y>zF~j->tglOVDtsKZLmQKNq-1O*z);m3r|0MGA^}UOJTJpIhR| z5XFGcx^LidQltt?=S*&zd-^_i5}=x+I^})OhYmeih5B~x4{z}}Kgi_jDO8*g#nG@@ zBM&UMH}B>@Z78j9(p}kYoFwWe5PS%6YFIA&$THMn*J~V;@5G)3^QX8Dw8@a7%ouw4 z4ckUq%FQ#Y0IDDC=g~C7Tpu-RoN;lE_p?>Xs;dHpe#5N1^XS2CM{z-qF`Q1!ot@m* ziSd?A@FIsO0*Cv}JLcb?qQU`des~sFC|ZiFg3UegzL(}8c{IBB zBE43>^c;mb*62w`XbM;(#4mMp)$-=3*q^F)@h7>jvT0@s-;y;f(qFl6$mCuf?s`%S&4GSy%j;ynD^= zN(^Twq>sHLvdN~UaJS_Y92xVyso|oXI?l7!Met6)(N(dPBrMC_;y38JRH3e22jTI8 zwY7yUZLLt>5`r7)L^B}}!*X9WEcbs=Y?T`GqGr7@ooet`&78A7Q1%1u?6BlJ-`QO0 zXaxZsxL1g_B1ho86a5cql<;eWynqP0!Uvzd&)kmRJ!^o=7UYJ9sYU-)IEXD-Fy6(J zkz6aM=52p*kP4%YNirXmQUpZxGR4^~6nb9LNuuy+rKY_-rL zW#f;BF`Up7VbG9$B1YQ8<{rIuJuj8c_(%qI%dJya@n9a z7b3`>ItWKvXTEPvQsC;_hk0|mf4(o$?jU_52z8(=ut_cvIjbYWt(%GN=25Y~R7Ne` zmTTr_8a;Esb(Nx}sMv*vtgif;q>XR|!tu2fW|g@oF7n|04QWmT%<$IBGu4!n3?p5o zhxk#H8pTEZRW(_|6VJ&3mN*b6`|Dl|R~Y+fYZ-aXcV1*VdKTdo2L!l6YnWa6tght) zVJZE)`Rz? zApFSErg`&ePf%j$a6@iHWsY^RzgtpqTVKnQJ=1>AT946Tyz(ppw2O5*n&A7H`xgo> z_z-6*OSUfP}Qln8zQZKA&^h3iQCmTV1>ZC{DUObao znUh~M>11jbNXvWmP5h57UVmE4tn7lT_^26D*gBi^M|8c#kxogK#V}Me{!_Jp=~JEpWIKo7>};BZXGlBaYGP7P|UT|&l!k7N+ZlKE6YA_ zC~P#|+{C!CL}bK&vwSeM`en0^Y+VupR-=kF`M0$vo1tD#lmii6wGz0Y?PY0`lMk}j zmEXVV7jXsL92|fk@2m%m6so?{;QTDOQ?7qTj^5*#n_rfFY*qt%rA1$$Nm!$a_pI$w z<(5Uvr9#e(PgGKja|MDTx_O2&sBH+iB;pJ6_sw@I+_8)k(R+5!M%IK&QsB{c%}H{5 zuX(g(?v}JM119Q^oPtQ9q9y&(BT(CMj759XFgSB^MK^m8oaj6Y1JcANd&O$S zs_3;32$E@Kn?{@THM=Ls0q?gh9QLGNrBSqCo965rUbfodsSl+?i*YOCC7iqOdjld? zf4b4&(YxM6jg0FV(sdcfe01uVLw#ANCAii<3}l~Rfo2wOCySOlOWk%lIstdy z^AW3#z2S~u6!)&D4|Uz~q`s5(#Hf)T8CMYK@$WF($yZXwhyeW*=-?Xb4OcI%&nU|P zRC!y(?^KF9_g>YeXAi^7TMr}ivsaYy!KOaP`@dF>{_kQil@GMqQ?T_JUaoiZ&=<{~ z+uxl$Et&2`jI&2U5BnRcoQM{p7q>D7sGD`9M(TX?nWJ97otq z-^g)2*Pt@7vZ;9x4o?7DjFiV??^$oJFo4>|fDI4qzv5SgBe-{esshxPP#as$)8e&- zK3(sPLVW&{6bs{`8SC&vlPGhd1=&Y#Ib@2T-^>!z;bBF1L}cQS`5~ouTJ1Ann83{AO%pqc zLNopxYNNZ3p6-_?GS8B6Rm!$=lKCe*3*y)naUR=ToX8ZvG(3I#;#PK>JuIncH?Dz6 z!#loPHXd_fG1s@|AM)p#g$T4r&T)V|lx)btmhmI-<;ba(zI&h$$2U@#q#se+#PBd| zobmLu^l__#HI3bkfvC#EGeemw^jw!L>lhJQm#2+TV}eDw1K#97B%~iDXXId8iT6Y| z4ecm<12J?5Dk3=P&GQ^xEo7(Mk&3@2LlrxUnFu@@k3tJ6UQXzS3l_%olU*uC9r-Iu z+XIZ=X^K^xh?1J4L?e?DJ~^4HLO}un04y}Jo5woyjBN_d*VXIwnQ&zSDa29u!7fCH8rdSIeZ)~jcsB6ePDH`QlB3N=^}Csd^Zp5s+O0K_cPm6tU(5(+VqZtk-gI$>^?2-~t5?aL zrhxpwv)L&%)7h~zQx&;{VMIMYX*py1wx`@H;)9e1kxIv^u}dwt)vI3pUoWlAYrs?u z+ER0(xyKYmiC*3AKR?v5_Nakygyp_#&o7B#AofUl#6&I5Q&AhvN$_q;Niy?F)Av!` zSnTJcd$z$))9}5vW4?b)YKJ$F&wOM{&{SS%YV3920ACO}j+K{Vra z5sscIA#oPW=}Ox1o4WMDhbg$&Ju&~q?Fo0RX0rS4C!M%(#;h~Rf@omxLq>d2joGKo zl|czqK@0z(J`w|P5B~E_&HX&>ydmP{DBt~Zj zOpuf69^8J$&T7c3AvqmOD|RFwc@_!4*n!t(jGTsp1T9|(- z|1dxC791u|4J>Hoq?Ix3OQS9@v}{&!=+cVAwa+%kwoV@GImkPvO;nLlXvQ-Ar@C~D zsPyZqo;OQ**B8RodZ+KLjN-|?(A=hqftYi?E;%5&JL3&Lrx$$S(@j*Trm@#nx*28Pl3Hgzu-U%w6}<<95?4n>$=xut z(RpdtWQ4sFg~4*5I`u@ZRgONik1?ljkBuTk63OPMjiGkW^7lP3h3;zOV9I#4KO!y| zu4*y?^1`#0b0rwxN#)0&A$0I^QsGUI=>NEYa^{g{Z@@F=WGUvDRRmTm9}OSomkk;t znn>x7eD#PPJfUmC)^&22m_HX}n|Un8uZ)2y64a3EoJZ425KD9$vQy;v#sNY=Qr-OS zzfRQk5tzRyUlh+p&=OqPZ&; zRRim55vtW4`vEmF0~_6p9lXuqZ`D%W?e8#TsUxTgEvdczBk@`01SEuzvHZd~=H)j% zK;Q$9(!;WB4kqQp_k^ocSS&i%Y-fD!(2I!YHusIWPeJ&?zJdaL0H}+2_I9bU&sOOP z3bOUnIn`6U z0zP>#X^Pezx4RO~dwqExYceYkU!hCc7NV^SWQLYSvEXq*xU8hY{BmogC?;6pt1D2~ z=*tY6)>-#@w=G%a^B+})4bMH`#n&~EdUm6c!3rgIq-W6kssqwJZ@_i@yO6?sA;7|- zq`iX$_R~-Ov8ek>&12?|9@{qbqsfPcxDZJ=OZYgm`DhI6qi8bZ-xBP`}KJ| zLxV*0+bbz7;e+zgvp9AN@HR-K#tB#?^=dnN8?-Ep{XYJ_xHh1N^i8qnLDO+m=fv7| zD?wT{r2ee+B=1XeZ`pT1{Sevnv{bmXafB94 zSsR_qZpJ-a4e(J-er1qTnNC`4-gnk-5;u(%`eGkFy^YEc^*xSgcA_))SI(EYVPS=# z+!(rCRC}+@0z!GyEQ%EEFY_Am7r}w^D8aXgf=&xpO(d*gMo)-5z2MA{j;B8=L5TA1 zIlT**d!Y{}#1wdpKPZVwm2=8Pxuo>|a`b#m#ZKQFRy!6w(}m82WH3?;H_C�SWmy z-kI7ha)-(0EarcFYf`KcTe6(jqemU)`K#LZR$dMZFpTA`$)1roPW$QwLb_EuY`cG) zWd;xKtLY3yu7BE)`k24B?-Jgg!{C+xVanadFT`s|MA|o!S=gZfRW)!+D&Q4+H%BYXqeROa^yns$V?^3N?EMdazEwibQY7J+ zPP5OTezL+a^xpDe&bQU7FLnrOeX?6gbrrh&Y|KsVt2z881kIglTxODW*G%ziwXxo! zJz7#Xy>Cbkw$2BYWvY?fm(l9F0e%G*?+fh?xhGb}SXtzpGPP`am%bm`T3a5qP3#Cm z$+S8C1%Y!BS!&fg<>>|X06ZjYc)5QRC+TD?$JBcIrradV6hG4Tb+?2=ktwhF-p3{e z2ziX4uiE4Y3tI>47l@Sck#ehW-%R&hq=^?p?eQhowI z`~{CqybyFVyubTLC(8zE=@eTu@aZZh@~G6?4&ti1;7=TD=+v5&Jc>evLp$=o_LGJB z_8}5yT-V+H%7yxSwxMFATa;1ust(?TA^oY^7&_{Z<~*CbAQ?FC(I+~eR!bQWUEBzA zT#wZPh_jE<`pv@C?d2%un_`WGQUgtQ0#0)3#E4RlweI7ErS4DhnkVjEaT86r0<=R} zh(t$lWLZRw)=X9v#|6({^KyLeF6LQq1hJ_WL8#p{+F207ZZd8F=RF?^+=z$}KM8nI zBaNUMmdf#+Dn6fdp{$A=`$ji0&28SRmbAKbj2nt*9j@%sT-1*s4(`vt_-4_TI(tAsy14eVSl6+8CuPoST%*9GWueh+slQWoAu(& z-(3ugsGFX$HKW+?%nj-0O`^R<#rH}6u1<4fH%!f41ODUqMx{lR+}|!6 zy&w0LAQW~jIz+?r=Zu|L>%~mCbIr{Y?ioNZRa@ryx3q`5toh%YbxjkXmb)2s+K zY)Pc@&h6^(>2e=BN}!van3l<4`DHorM;@?s*2MwW6j5@8#y|8(So6h>;QU7BuqfT1 zatS@K6uUw;4maXP-#nA@c?we;T4{~;qV5mexomP^NjbxaLQr0p4OuK2>vwLfeVn+> zEb>Vi`#nL)c_aFLV0b_6NgZx1{a%`rj-YPP6-@h;)IQ|vy)UT}ZEBbG!0fsLsq-vu z;`JP0_=c+kPKtNHp03_CnTSMwHwP3kU&Vri2ECtAMaWd-fAMN@Fbi4Gdk{lWcr;IDBI=zRPs zDo)yF@7$+_%X!2cij)Si#y;xJ19R&KnvVy~Ej}EVG^_vdi|bZ`a()S>MRDug{IEF2 zosh(O|F_i{*IYxxONtWQkmy5NUTFU;Xy4(ulv|BTiJM=9MgM{==vA>vXAPx8+b6RhBFxH&#Rc)$?FTyix zu9}$^cceQoN}2_Pd-G-GPfW#|vuIL+)Od}vJhmXTgKEMn9|pfc2fM%WflE@7~bRrlu}%k;ZwVcC7jB3GAf z9$b@z%dYX=RU8OEYz*#4A7d2JXvurp?$7P{?v%adskq2!^jIx0<3F?m$c1$?VufLZ z7{c)R5Y7k;F4 zIS5QhjLp2T@oS{*4C{@{*Pt`C%NMz<=4>`^1==>zK0P0`mO`#rXkZ?kyEykQb|uX~ z(Il$PnQnIQM!NvzP zF6%|uTTKSXFt1*%Oq9uI>QY{IiBW~9+QreC?R7WW4AoQV_hcx?pqgHO zu)iY;DKXT>VH^waItX$)2(O?0@-rvYN-!&PMdVil*BrDyp#)4njR`y~3$Vz}=Ay>F z(gMc9hMXJbmp#wenZ#r;_>EiV^;QjRbeepeH$a`WHzFXR@q<>r4O#UpTj#Hz0|<3* zGsz7@P`^lJ`y`2jSbai1=3X{rF+Gl83pY`WnBEuq^GvY2wgD|tsbJ$vL2w)j9kYu? zATQ3X%=0lTG08X=?$034q7>cRY4o&<;XSjHs6I9~gU@v96rvK!we+kQoJpuR_;YyQ zFb_dG0?5l_uRT(45XwDDVPtHU(RnxTiiN-l4pwCF*~?vPzSi63B&d1H%UZyC+Qp{Y zrzaGd0ib;ydy*jA%Wc^>9Iepn%;3tI??y8eo!|_5O}V_Ko|zTXBKm9QZXeoK*wQvm zJ&V)g=y_sy_nFf-?vKa;$uD1JV-)-sM?s3xCCdy#GqwuwQ!~MuiaY!B)|VC0%DKwx z97?}&QwY`T`Tl+JWEp;OrlLIeO1kguCT3SHuvuk{%v|1if5*UWf0cBZWLgB7m@)J? zP(ET7CFXmF4X89)t2(8N5*2668jtkO38b%x=N?m>o?Dh%yRE%^wahlNU$7+5d*luw zF~BTo;4#S1!Xv__G-E-_R4@Z=H=hg(W7~7?rcS~i*R@_s4-L_XAEqbNT_KsbXzCg+ z%j||CSb50n)fa;e)fS$iVz~HyE{kG#5nYA7J#ab zrZ-CMVcR0_bx(@S(6QK9+NNLk)YCQPxvf$A`h=AM!B3}llzoeJg}10|YESM`0pzR7 zDd6yxGoqoac`cxfp0V)ldKGggXlo1p=iTyHz^)56?XPo@<)1IWa>KaM08yY+RH_8Y^ht zeWB+c7W;MepzKO6FD~QM!(UH(I}G}}jT312|LZs^_|Jm*fcVm&af5>I3nyu2wfqzI ze|^i0&%7bT0@0%(kWmkol)G7YE(11O;burUZZm?~gdPEz!7&|90^8?uSJ9}r(tduW z1UZU*%p8(cQTwM*kf{~CC#?~FQqFwWkkWBEF)isY3QhF90?>2BnM2cF$2=NfO*AU< zZ&MMpzOywhx^W#wRdIZNSHE2JT=5J5)H4tBrly8tFtblsth?d7pGy2OUw3vBnPcY+ z1;*xDriEFr^$TvnPiDFQOhq}i%!HzlV5aa7>+Gx*P0NLjl|@ z`W>#?qJ#@oM|u&MF@hEIckYBXC3^7#s?|3aOaG4xFnsfU?g8Tr=8tWUi&JOk%^gcl z)Q?bJy_iPv{($`y>j};e-jl&aZFuLVv;pxXn>%f-wiziS=zM(+MhG9bGUUw>=SP<} zjR{Ay*HCp48@>$y@=WHr@qR&k*r{xu6v*%I+Ypk0n|+RKN*~yXja?m|f^S{cplF?e zrWY64EMZNN3}y_(=A!E#EUVi5Z7e2dg4aj?v1*>6A~M%p=NL1&t=3LpflyJo^G@<- zgDu2l#JR#^u~gtdd=@k@g7&J;i~_)bae`IN)Qm|XVJ3;ZGahwf8ywZ=A#Oi|b%gN$ zT|>~+d+X@Isc6sYD*xsuk0ZKRuH%oViuT!fvro?Vjc~4SwSYwtY6PMIV?Y~b z>XXVbu*MI~EB~!kWb4BSZR5KEEJkkU(A4AvBZ0{)vaC?^*HWhzu}4k?F~OUE;L3QH zWCm3L=TRvV$%|3L8}5B==&i{7fpXPD&*Ql;W?NG0m>@*AB|2v4DjrahIKHjCK|Glq z3NtS|s<_2zH<@1Bh+3vt_XxnIn+8oWU)Pi?5>;dn&(FMhYHAzBOpt0DYO7wmm?pqb zynjpvJBYz{nW1tlflNg`r86r8Y9p`( zGn=J4y$7s1jvYF93H7ItHReA0EqOj(YnK|eGn*HPbckl0B#Ya9qZvW+o0K!@6cA?|@JT8M@?8TTjusJI9hEG=7U#WP=p4si5 zbBSO7lRGh?FEDl3B1G&x12w;|0^zIgD8r&9h86$vYFA6^g8TQuC9~6iRbuY<_Is}q zHjMQY_Y&#qq+wvu<7wtyqKKs(k(Cu(BHMXob-gLUG;UpWQ2nf$=Bw$}Ps=%*GTJc4 zrnk|+S3;xFkFOlhp|2=*TVP?ySLWr)UxKrDLpXOE7;wX8`TMG#kc>!rXPT(dt4 z^V&gSEN*o!Y?Li+Kq#y)_n?i^p>b+3IAp(A&NThfiaxt0lm2e#lW;GhC1(C1%eBJG zz8mTnigg#VM{TArr0;6Du4N0o*t_!0D1IAb6q(=(QC;YmoUAZO%t9l$TIOXb>};V? zxZ6RSyYaC|-(u@R>Dk`1+GKLouEHW#kK-efHy9g_wtyKB)>(J)$HmTcG&f`C!yz2` zNIg=B2T=yKNP`rFC3JrFngey*g6KPBz>ZJ7oSIS71TaXsJ3YV|@W++o*_oXm&8MHj zO}=tYpGTTZFgqCo!8IR{R^cqkBc3~@J+upP8M{-`fJjD(Rs*gb8MyP1wZho^t=Rlw z_g~-?eRJ-2HM1j8zT1|$d*<)m#siR|*f5rs3-4zaihQbVJr*BBiqck@*!!3WrwjG< z1jHDK5umu--}Qd{ts)5^amUiY2ezlJwh z)wA{xH)Ue(2N)(~BOSm#>h1=)|ENQg=J{7Jkz{G7$u-Q3CoPCPg{2 ziGoyF+Q3bc;+f?GdUB5PiFU16v1Y8x#6zslA@HQ4Z&v|b=GI-W*o$u}21t5Xiq1kH}*(6%0edw+ZY44Y!-WWSV9GYIw(v`p|n;)$H?3;yxV5s%B z+;0{}Q14Xg{$<8SCVT}T=rhkrT>Cz^yX+!2;pxOv084i8L6qzN87H#jG%MHOByIhk~cY8jc zBr;h|(!6#nQ-S``(R1V3RbKSN)QYnt0XF5aN~e>U4ED(ce8;9@K9-qSg$Kv>*Kr!L zU2(%R7dN85BI4&BS0?~{Q>AV~zxLHiO-FC&`f^ZoNctyPmRKdek|SIQ$#pse#HN(j z5v;y92+kPsukFiSamu#bqh&BD;CWc#{=X?b#Kn)S0Iim~{F%qsG(C>=V623C_}U;` zFO7Y2%)e5WwG#71L2t*#wl6%VQ_^0Zc6PZS-IBY|n6unHWN(2vO)Xe7*k`7PQwYan z8)Xf49{W?dcPykL9~TCwTN%aHowCq;a=oZ`y&Yi(EOOKDWQrEMG7nzm97~r=D-*33 zK%s7OVb9XT%2JD*K~ibNJgawm%09#=N5W?t_oi&@=I3BGcaIQ6N@$AWGg!Uig*(PC z=dVS8)8!%iz!QloCoYpG3u`4DG>4S!3GN^5RzzqvcL|9eDF^SX!zibJ{4)c31$)%L zuw|uMdo<>fq>4jzVYUUi^IoKI<@ty!Vl*FevZ>v{C=k^fwFctBU4j|OIGEO&^Su5F zG`VH$h3cb*7+VnQE0Vk4rR&@LwbxluDY7JfaQ6B^OcDU&_FKkiUyJ`sCWSMCyVgLs z3g^oRIKX~53C6<17b?EhE+$R}ouY7-vR^8kU`*lKsI7TP;=#E7<*b7%v^wrKB|z{e z;c(~Dui-uim1VLcH!x@IPrO;A%$w|yy*Sq%u9HUoQRVA9Wp|_JWwju#bK`oIRJ&pe_ngcb-!LkDH=P@L zB7Wj)3%;HjbND>!{72@^nq+W|J-43*#02A#9hkHL-V_7wu9}oft&0=$af&11cKXQo z|51)6dd~M=tIFK9JN@1FWY+HM|Ls1urqSaW-v-_s<>v`6mE&4_sWdYipLy+90a*L% zY@V_z%1^t7bh!QCSx@v800%C={GvEL z!*|R9y*hf%q!rLKfJEC-sK;~9QVQ;XTAC*1kGY!qfiu9#kZ6ARhZ89SiCW3mJ}pfL z^(?>V?nROdVdhg=)ib<-lX5=V z+DGs7t}|3%1;t`S4I9RGS<62Rk@FG1HBi-Zf*|amm zm;rOIS?fT};wGuF|GjPJWT{O$TI*up>S+fE8;owj6s1Dpb1IZe5>(HCdgq=FtaPe(%C3Za70=O$ABmBJAyw9r*gKx+P2`INON?vJiZ0$AX$vqp<6IcgZ4-XpqiG5Q5A*I=)Z80(M`wf0G+ z@wU8J?jSOXy#^}DOitZ6Q^HePX{y@%%x1= zL>I-r$7ttsF8DTFU9`Kdr14n$Kme^}~wSlJ23DFw)Hm{fml!z^YoD(z~groATv zP=(;C$sNJyCkoBGhBAw!w~A}`(y9^j;r78N z#u!1b7#;8I@OMhcRteZ>6fk&u7QxC-*Trd*4j7jx-Aj>MXTpz1P;4%-i4L1#>5zJR zG!1pf%M+C@G(D2h(a414*43+!xT5*V%n7Er-ff^kT1h(rx*EFc0eJJVRdoyWXae`p zHXtlbK2^2(KbQ#O(eGDte##ikvzVqWC{F)PN!iN$(UYd#qk_J0Qzz%Rvq>9UE4!qlmO#7>2I*PrP3>XMK_0%kaP@boKM!B+PseimdAGKKVZ`0CM+2BBtOAFXUVVG4Cp) zsxF9kCKs$$tAlg5cJ8EQ&6xm{e^s?FbKw+F@J&@^9GGPn3tztC%x>TD#7seV+T`C&L)H|8%JF=Nm*IcfR#j%8Ddzvj#y>Ug*HG^u*oFh{<-Je9NkV!7k0$KbR3 zTMR>DJADJOO*xtc&C?rB-?+nR^bJLTZO$&c($l7OHIwv!Sm)Y-oRsxlvE9t#~yBtQ>?S@l~HnzdRRk_+$W;c|*?P{TeEJ z`K@V!Ggv2gXf!ePbFWKg4p@K|8&Fh{a<;5YB6h2z?>3Br>d5#{S;Yt#P zvBsc4*Afa8Hc`9FLHV7I3(lD)F~PC#%kn4oSK?CEJ2vBVZ+nK034tFO%)YyFqbh@T z2FsuZA~k)TrnYwJ>XDLe;>t*o3stbqx^zM)t+@{>ukU za_kzvZp*j`BE6SBAr|e2v!vDK>Xj|NP}|2o{iux^z>*Z>R?_T~`NAXs;YtE>jbr~h zlBk|0qWX?-Un2uMcoo=22inxKZ3#6k%IuBA#7t396^Q<_U;>sKqkBmgNp}>o+@YfF<_1=qRW)jVK!V z;LN2e+W-vNX&r4c9$_nJFW>xr^qTcGYCYKtKnlaRs^Tz={;zz(Y@D`{l$*rO`cBKx z3~9gg_${nEDq6HlUjleG-UI#g=o)<&GjO;OBMH*E+eePg`(x_+GYf!Hk@?mYGe7p9 z+j5u>1PW2ttO5YAR+HSsM?CHwrAb=S;Y}PTzudV^pll{k1)J>#7L9?xYT^1aU$nMm zlIVL?zETS+#y22D=88n5aA%z-aVk*-E(9kU8F0$a9rvv|Vg}ffKOV!~g!78m=K>bL zrB;}&>CD5LJh1&g9jnv<$yi5rlG|Y_ zw*mfSkVJ$K*%Kh_ zSqNl<0Kd2QdG7B|{^EH^c+Y#z`FuY|lJVk!X_>OfdEK%qa*OCWXlcVE?WKh@;weD5 zisT6Z3R0Of9>{&3VXod9E|SDC+@JPUL^kcpUC2u_W9a!^{#u0M89WzczYlM!q6CG* z8@&pAfOlL(6)p2W_3&2lswKnS25pOOsyHCryLiMmWc#Mt{gw=<_l>p5CoVwt+eUy! z89Xr?$bYZ~!$3212qTv>*!gT+ztZ6t)XW6TO20jroY@Yq@*bORV5fg40iu>cPEE5F z==>UiRaL8O)*^}Nko;&oo^0pZBOf&bL-}dAu zKx4*}h^5t&wX8Wnw9q&_T^!b&4k`D=kvBF>_s<+J8)I!vc^P6_bt5~xJxxg`>KQ{? z;}fqRFrr(RFjlU1LPhNTPkI>P6|#X8{{FzDd7OmfR?pU}W+9&JZ6qvWZhtzT!V$Er zYG66Ld!df{%m4nLt``vYrGBIP%GZO2SHJ6m*jp<+?8|uVkN>p#M&YJ6Ectp6yO*Cv z^k!zh;S`0lw)2A`SqpS9yJzp#;V+$Yr9If2Wy9XL4AsVqK=lCEDuG{*1Bg80|02_4-)Mg=dx;Zt{eG%L`tfw)Zoj7O&06a# zkkJJ%-k(UyHxXH1BAa4Qr9Uk=56pNHz2`IQI@$6!UzF_mN?&-9{_Y>y$TS}_KfI&> z+O*pF^a&NVMl(E%b>8FDy!_r{-*+Ylzu`-KtMZ?RAK*+*LiF27S&(N2p28bSY5ZyP-*JWX(Z@7&}(?UCgUjacFVWyA8j*T>uW%P zven4GdR*Rd^X6x8x}!+K_gI46bd;D?E5O4%!nk0ha|XAr+e1ZoIw{g;6$ zY@O{D{adw_Wpa{`K3fq1$hJA?l&f^tN|C3&JepV?p!rIE5v&tDV`|4!}_1y!3yO zc9La<=Dyx+xl?#mTDB+?#2xu7RirHmOOo7f*e=4YF*S}t(ZT2^{3YCNBn{^FdAJNP zLvMd%Z0}ofiAic?);DF$!71yY)T*7LWzJFH;>S9*wKRCz zDhRc^ki0FQd+bEK#krU>`#}-yD6BqZ+}BDJcWcbNCVjwOF!Jvs^9BIQtvi|W%DCPQ z9M&W5JeH3nCAX{&xNa13y|XdQT#j-5E3dIMZsp@wgX3>+)mM}&?=N2|Kso`(GCjg; z&ToGhp|IQh3gQjL!+BlN*PI(pZj|HCzJjhZn;+B<8rs_Q1Z;Ow%PvlyDYYq|Tdr8+ zNUz~({0Dw0=dO|3&T>EIps;u0>hxmXgQ24Zp|X+qJYWKAniryVjS91hZVc)v<^&Tj zrWZC8Hb^S(1W^Ryox$Any4J+w^@OQ3BnY+K%TwBmytaNC+o-Zk@l@Mv_5b1PSO^dD zaD5hW?%%Mv2YI-P8Uf+`l)2)kQ~W^pr_@f2^FqWHL6Etwp9b-ryl&NgSy+xMP&X~C z%3?mQ%?;7gikwiA-x@W+8+c)v&uOgbzECBN0EWYDT{DucwZ%Yh`$!tIimX{SJzrmO z679^N>Jc{i;9Fl(cQ-;erhB5AC1!AQ%E1T_Qt8a-ySIp~X_(uIqbEm23Y1SDSR;%q zJc(psyI;Y3E7pRz5r1Aax%gE?bAxBFXqjgP&DOqb+7MHK$Fz6(r-&(dwzm+Q8aIkYp{;b! z&r@ODPWCAk{uyoM{tC-u`Fc#(>L~o+!RDn%F0zItIbJs#y!$;md)&i69VlMOM)st# z(ch0{j-x*?nRB&q48oj?W6ek{_)EIU{F&Fdu}|hzcdr_2KQf0ZJ_u@kOar@AtVd0_ z2cx?O{j8M38!r8?;Z<#Xob%jC_Z5?Z;)}U@|7?clgTpeSPyh2JEbe`z{5A|p5ig!^ zeS6(Ua5rVo`ZTBPD#8M{MT8-uS00Ip#bfL*Gomd z1iZ$W{iLo06iL!7SPIGP&3Y)xkXP^*_Aap~in8#S8pV?I1cg&{deAkN!P@lTr|mI} z?Jn-)CXC!ecaTxv$eb>vxSB>>wyg>iN_|=xzM8L7?`}AN55!YM=l@TWpJZFMI*@9w z38#|>37CntLe-9BoEV!QXbbsiB|VAG_T$zd5zn8E-vbOS%;OW>I z$?9)0dC%eD!VyQ3v#jS|Av{NT^65SOuF;{HfmD8e+;{sfa{iGcXMs?#DjguqcX_>- zX7`GBoTrT99xeQ~g$UD@?Pjjo-AJr*4l*R8Q}ev07~d|Z>~RKh{XF;FjD=1J%jc?5 z%g9lCy6+9atp{WC-EoB8UhVSd#`jUey14IdemmPmY{$jj%HYS<9*Mi`@2h`7+vDTnSqGuHX4uxf_p~dchnO<0lhw7sJ!a3M8Ro`KQE|NEkM8DZmxrd2ZwGq)AK7P4T&8)ru_SH{Q(7*OHU=~`ROm~`OFHGG_^+d zr4MR(gLf?0BZ*C`xrV!#4%~A;kz3F*F3?5a8saKsr=ajY9t~kby3w37NKYNZ80`wO zf%tgqfd58(iU%-9Jg_q3!<_|z_RxLF$Uo5DdG*ylnzKi1x}eSP8dvR@ntHoF9flwyqSee7E9UOCENa$#h+~6+>9K^Ek8#1K^zZ-PPx) zgq4r-9Rw&|IF@v79aI_2u!_ne0}Vt&x$Kl5nz5D)S`W{4?Qy@6y;f>W^{$)EMW*OUgDWYjlZ>wNcWO8Ccg`}_N^$9~M}FltY{ zl~H5q$kbtg@br(ySs-IOe>-2nh0Cs^Cr+LJzgd6>s_$_fkoRQJ46Y2!Z*zhCiIE7G zq`hxzSng|o-Gh9Fbxrlx#zXIiGhCPXXTd2uRngLF1bAygyoF~qp9#}1 z{mvn!*#%!m>f+ujC8M>8eE86VY^75@#r;;2BCT2Saiam?+R~tsRe;z}^7`q`&q&?HcYRhVsyBdg#`Wik8K&Cy*4hQu8?xMNxv*CSP z7}wnm%M*q&CQk*Rn@s5xexS@dnZ`9al0@5`86e~ZS7mN50`YstD0d1MR>C0ktjaDy z&F&Mq_GY!95|(R_YukIF)pEAs|8||Z?p>hL* zNNbQ}*Se6)ShMcL_0)yNo+lP8%u%k0>+v}ggId2e90;^2qdyy_TEsXX{f|_1*Q}Lb3KrHf?Lh$mwuR3+^^3x4|z+oK|2oqZC4B1?iMSEkARUQhan4 z>K2u=dcofi(c(qWLj>J1-7<0Mx+7B0FD@a1iOQh@1%6Dpmm@Q?U8gLr_%6Qh^e2n1 za>sIZ=eV_O-LKLoOkanCU+ir<7e8>{1$R2t`)m>9zc8!+T<3slQf1I(ZYX7Ml&d}L zt<7wg*DzM!N$s#6e^>YB&t1sgs$z<6mJQ8PRERkAp8*otJ+8TX#@q*zRUipi<-{t? zXRp75Wg(*Dle?F~xSySWfonIk12c8vJ}{-^Ma}w$Sxu<=%IJxSHRh=H5;w0O61oS% zgKIoS8qn?|!UgHabk7Ksq9_99(Jo1)j*iJw|52GbdN&x9zC|71c{~YE@mShHFXnT- zNv?5_gdewNb{6Y9@=NoR^#kg)we!^3*P~JRdEK-TBoDuOiW<(+v9#03#qolc4~Z@w zOy7g_dMAv5-`oO}>|AUVct=E!q#3IHVdy=cc5;y3vpgYPIZ&i4l%hC^&-$+dxid@m zgOm*2{aqTGqxv6Fi}XmzFY9YvI}huf6qMOVHIElI@6KoLsJ7|kaaBOh&Z^XlhJX5& z6}dObab3g<1(eM0gEvgbyUtqH8l;**T7*SCCyn{KXXs0Pfdk(31BLw@?OMt12i(oc zxy>GV3i~&AC4O%A73^oPE?T_@B7p3NQ1*QiKX2}02E~(N?Kt%{-i$xLyLQQGS#`FC zN?m$PWCMP9FDlhyp&Xe#B}cy~NjfQ8SpO!ttgQ5(&oyo5s+?sDjQ_qH%1(QdMQ^ur zM49Aai^3hu3Mkutb|jVTQb9$0%C^kbhY>`+^$0d^;XX~md2pmfn7UJWYm%z9JAHRf zW%5>kcs{mXaFrWKp!1Fe^DQGT(+(y@mVQ3&6l~m9 zV%t=0clFJ2Z(sPZV0ZdB*p+W-R<|JPlE9~Lw7l^c`?*}XK3f@4bd`S%8&*=?!o$)S z7aAHo6TvWmnpi9`1_dUm*7B!k5VVe#LEzo4<9$g$bJ%W+BqpY+b(Ztp`7rY4Kgk2( z#pZuvRcS!dQ-UkXJi@uPNF`vv7U$Osxx^xWpdg{<_M>Y%Sy|2!p<5Vh=JmL^?)%$) z4_7{fu=A0$^PZeWBU)yN?p~6I@JaGUxDELENMq0m+lA&&A&lKQ6k*JqCXQguY&|Jg zo0^$+_8=bT6HOrZN$0ej$IXb_RdYSwOOW|t0e3rm z>?1DFr*5CFv^-dEes&}c&oO(0Nbg0aco)UL+gkVp-{s+i!&RgAmSM8WR#cS)RUk$GJ zpZzj+G|n7wjv6UZ8ce2_51Y$=&K;-HCpVeAIA)Q*7B;SKR>JE zSgjkhY~*mn9=uy=9hGLkuZ~QfpPVJBu2PADRk!Q3^d0T9SY=QhG+Xcvyz^_4lYRgr zIBQW)QFv*qr2U{dUIqrK@6pazN3i~&Qy|@%CMm-Apm+a8&-~&O=@3Qwh#Lx=me~gl zy`R0JAg}u5r5{94r}u>@+H)_j?FHbH7b>&QiF=|HvbrZf+&JK7AvVUP5eL*<-+!>h zjEyYz>7Pu!4?If^u5ew@{)>nbEdJH-9%SL<^=y{*VNK)#A6wmHbNF**Cgl9b7`D2= z$ZEz{IW=8UdboL!YpprJ9efY-%+iYwr!cM=KvmS8Z+%jnJ$kHPxh` zuE-eu??F+8S@rp!DaqzJWb7}$J$l7C6cz2gvHgQ-yzu)c7S9t^4_lYRA!enBe~C1= zA8hc>mJ;wlmuFj-3R#;^Lft$?Up~pqOpR4=s)G|fk*@l}EdBRiO zdgCMHjc*IkKH)m11=LeYL?uQqZTQS~TA3o0ik)Bkmu|-yaL(&`-Gs8Qw|-)>!rPJT z8oIbUVdLwXEMKlniI`JWIDC<*DV!%LTj5n#iLkn_4X5Y~K}^n|@HmK(p-=K9kE+=F zE8C@t{pY7ja|})XPSo9d*ZZ9Za(fQ;B#;^Xw4f(N-BlJ6me-RlHrxNu5jEA}SufUFfW@-eqE7^)vf_%nB3^&~AJK#QBYpBTq~mwBcvY z@7?b7>H|{HTUcZkXjg?8qzmfJ#M?82eirI31~O@0q+1Pc&C3@O$4l$Hri)mu$Nfo^ zO6mtr{2!QumaNEcU?IdXM-!Ij)3-3#udFVj1<5sPV+k<$b+VS%VwTho>z|9*6Ox%* z0VgQBH%KajRc_0-h2qN+*PPCChKI(*G~Q#FchE|OWaq>pUdbC$2qhENuRJO)JKayO zv)jiN5{~8i`))6W5=;E(}ZCf=EB6T0wf$%Wk8Ry!$hmBGszag}( zaJR4BFV}P5Ax9^cz4m~ozsC0UOOU?%vM(5}jI_84$$0tWg~4#+R}7#eGDBN*>u2lK z5ru0pQ677JCs|7ov>D0f@a&#v|6+chV$-r^^J5Tn{d{9c z^TXg4S%f0-X@g*MVSVuTh$alPoapXSwcJP^h{&COXB|4&uniqYh|TD&of*#0E4INC zL}zuAekWm%^BvMXY5#S8X{8L!cD?W2vQxd)_c=rJI;&&*Cs8>8Z;{89Z;fL<6}8?A zg)fF+&#&BoRuLA{i=LGqpD)Q82fnwh=ypVH@L@YX{zS{JWOJ)`^xa|!cLY(W>JnH`;1aCmn_^5zXe>F@NiPb;sA8l<7FpDqiw~OZC0? zGo8$#-VNpI5YEU2`xHz>Zm21YJqtv1#QoF6Lvc+1pr}@MVkmJAh`g$yOHoOK>iTQs zq{L|D_)D{TdYJQioU5w*QuXdA>Y4M+`#0afy_d@Y6kFGS0*hP__SeH4QE^QGxiI_m z6T`Y1r*W(TEQ#xPE)`*Glul?u#YJd%gpY?Asq5YK6V#kG2SlpCStT5AlJG9qc!sTv zjMTkyY!2CSLOH7=Cy-9e$?~wK1pqG)i&V}+F(S(a<5f)By=tqp3n(OaklK&&XQ5*p zL>*fC16F&?A*#JctBbzwoEfDrwyqw%p!lfR9hjY9&icce(mU+R63=yMHUE`zrgeLo z727=e;wYr5oIqIneb^hIB4y2SZHGJXTv{SA`*}xAxX#Hcnn$Nv`CK+6_6?7YX)gPC zw`VTZ`%#*qVN7C}e)AK>7X`U4HC|2)(U7NFcx2b%v4bhB(XKiNrDvxMF%>Q?UfU6A-~*{q&g+-PrmQC-5mbrVezXU4 zH(AQc?Ejr*felKcJe1?JTjuYm&==-{hQ8YUyMXDt|93fmQ^F9Sy0@oc^Sk$h&2sij zb;T_CheQ07_j~Xp&lfcy#I_o{wVz;iI#;6qDS6}NHlU|!DMXW70bv->ERBZvF zvE$-?9OYV`qBC&|^<36ekEe^3KQpzzojDNRh}!)W?J7>1*Nd0pABFk?RpzqFPUd{I znAoJbLbeXxP?h{ zv4KNfmv6NmS$PodP6#jNc>LM&sP#p(OM~O#bA5kg|KBWt9Nzf;O=&jL&6H@3ma*S!NAL>E=5o&kkGb4X)X?L2ndJUH9$J+Ec& zD{Jo7v$x)&@pm}e@C*AtNIx~7X+z=HcvG93JzkJ!Bl(~48V=rM*^qSKj9zDH-$=b! z%Wv6@v)6q!jrB5MH^wHeC2$dPyi#$*lfNr8I@;lNxFaluDY8eICFxP*cdAOZvRvxV z))wy(k0?I~fGa2C=hL2xWqEY$Y!*&-ymZC4KOfm#{h=c2M5*@)*STFCLj>f=I19E3 zN!^O3y*ajz!ttpWu+3${=0UXVcT97xb2Rq3S@e~R2IhMo32m{GYkVK1h>AaiwC_q5 zJ7<1a=<9;J)O!LznYUwi2i>CJJP6z&f<|TK#iD-&l0X zK%6~;LD_9wEVx=6F-;yEV|3(3Pb|OldifN+*Usu*JuR|_PZsvjc8->MlLWVl0}yOi zcRXPlCC|uvYOik(29-U%7KlzC?iS1iTX1jk(x6~josL;7Ou2lEHv8!Bi$m268j@Il;3hrh$=ht$I^{!!r#vYwh?`tH6H7c z?OVbT!s7LZ?9f~X__nZVtG~fz>?7k-P#?pMj*%d2HgiQ?1S`?S*C%p{D>Q!uIKsQM znICed7<=(RJD+^cEy#@K%6w+hnSR@icykK_m)vczOg*+ZaGYDiJLc~YbnulC)MD=* zwI@9D4@lMCPsnOY*DY$1cT!oRPP}FjgA-TBoVAD0(+55q!g-}uQhh(KYI{>Du&jll z((IsRa|Z)NtDUCoeaX8{+A{!V(!Bzsx@*JnwX=H&69EG+GgCREUNZ3*<0TNZyCa=3lY{fPHD;L zjL`y2LBpd{A;^{2@6fFL6XwazZM~x#{OAGM_-7CXDd{wIh&h{)xD>o>MslgQawweATj+pX7S)D#wyhVd=BG^Xkh0QWnZ}xrq2G0loV)Gzkj~IcvXc z(PUsxnAPY2kqEpxd*@*F1V%=&&YuB)0v7|EsGnVQPQ@=Sf;IcS}55Z~9PFR`D#w~BmZ$xaw_ZkNTfT)eC1-gyFz7(|A zTQK|3Y&LUr3`OKr6qctufI}`f0=C?+N<`7Ta#&s}h4;x-4{Iryi z^%6$f*qW(ENWFr>Sv!DzfrQ>XvV5JLx&Uk-jB>Iahvs&tEINr%@<7K6NXcCU<3WM$>8BA?wSWSbQ((wmoBHu{~2Fm+)TK(zg5q%-tXWKu33<^gvF(o_yX)(uUtYHqu z-IG=ei8dz64|v`e<_e3K{EV$o0uNSZ>U!DSrzJstJI37&wKAldDHc zDDb~0Pkt#&6%?mjqaEy1zo8g;5fnom^UB~;*(v(*OI&>pKH>z-ZNAH~Mz`3b05(7M z?3}?EcFb!dN8i1lk&5N7zk@I_pO|l>Zo$)+k(8THx4os)m``5y59M1XxHz<2i9(DO z`-EvNeZt5HknRC_Gq2UJLGT$%>+W0s(hlk=8_$;?=2IeWe1Ly8td1R)?l?P+xP2KL z)LsJPnN1d7S{@MlBXU3m%TqpWn(D6=E$Vt9gT}woi z^ForlMTspC;MGL?FMs0(4}PT`Jx9myjncwY za)fczzEuc0v9mE0-@Xn}Ti-J`5Jzqt462 z4lVtqiyPe`r|x%8QQTv3aqX2(R^k}p*bg=H71z`}+7!d91=qVl_iMr6HGLyp!hDG0 zqXIaSchs6IiKX5A<>#v;l}vdWh)l`&dn{%$-O%H7Yy`ym|w*>fMa zuK)HhufsHiADGOqepQhVpd(KK>Y1Jx@8U##Il27{dQoUsp~hxGA;yrAuk3H=bne73 z!GlZ-EC0PfB%UEN9oGg%>WHqYHLfBiJjxvqI>z*0gn7%HN{P=@B!cWe)%!b{Zujz$ z$#fuI5_;}lx0gDzV}e^5P-7NQ6L$0cL!v;LhZ*mgxDzoC1|YhA{#ZyN(Ml}zoBqh?NK)LC-@YU^(~6XxR83kij1vFBs!CM;JAFa@ z$7x8jA=BPH(c~CQ8fb&m38y|iaaR~z*gcq7z4sayg0QP{r-WPDUQJJ1ICn#a#_QLs zn@2x8gWkK>zFht;8reD-pBFqo)>u!#!y$8_J4JcHhrmN1$YbhTPUj4uUZKRcJ-yl) zxBLE~PvSJVA%Bshs$L7g5*B`pfHTK?uR^u7wa-kaHN6+J;xe(?Z(LQ=-tt6eV3!Q4 zC&eufEk(~ts-G|6-p)0GIC)p@C<6zXt+Sd~|ih;$- z&vkLMCPvN5_Ky4$H8b~vX(2$q(lM%Qa#Q8LHt0_;12Zu{j-i}0pS?O!?*}?C0m0WB zOFk9Q_k|TdE>B%RU9P^IELa`*4D;%Z-K%Kja4Yy2bYkpR6hAutbj0HA+P0U3nP|BpA zn{1y|YceH+qRmI9``f?4SR&PZ2w5z5``R#@L++F&(Fi*#ElWY+QW@e;xUl`v+N_-R zK;7n-4~gWI@_?ZKb-gzfw9FCFTO=lR1-uu0^DFY+t?X#8SQKi=!i;WfS57l_RdJl6#G3G?gZxrOl@;)J>Wa z3~4ex(S;d@O3M~+2Kyd72-|(iOZf&?%9JR0e>L{^Ul)!j6K^FXNyCjhWU#rh=D8Gx zU2>sKvF~B65&Oz9L4O7@Fw`RQ8OcI`g6iNHY>Y(g$&xS3eFuzGu7`_B6|@d zhNc{KgfC+c@)ZU&6q%vJE?p6m4a@c5gAcfiNJiy2pU+-;I*Wm!Ov&Hg?Q|Tn%^Ha< zxzMQ(-R_OrJJ^#SLApzyNIyYmDburkQ+pPVl%O@^jhPVDm85x1D)A+8wV3(A`%VPD zNNyKgS`}5hat~y>@=Cc%Dt4L{GaCdar%K zQ^W$2rSbPZld>RkbZ5Q>WPG)tUi+7U7)Qj#yA1(N2;hZe`v-mVXh?S_<1YZ1zxP^6 zJSpXUY3#eTQ)>>*Dw`PpX$=9$b*l&!_ z0JW0Q<)$@WMfa>z?}nnd_K@64sbojs-R)1^s}O?wrfON5>XjDI+)OU-GkQwikC%OC zNZCCBe42CyxcIuJz6vTOP?WA`qApkKl@r+lPzdy;4l(^M!WKm=8WCb?q{@Fkz8Yf{n9TI2@64?+N5w~3xKX`)PQo(XQcZ@@~VA0kAbE8ZWt$p$J+5ejbTvIwLIf%a~EDdum z2KG_$Gvt11e@nTQXD#t|KIcT|s}uRB%)JqYu;rhvQ`d~AvehxR z@XS2CLc)^<)8=B*I+3ULLBj*< z)Hj1Fo0$z0dG_yao}E68;|p$Ry6G9cQ+UE}1%QI%1w9IFvpn!%QU|X+g~1-~N}8DB zh!hvZ)0B3*sP_}^jEq=UoT|brqDLD2fRfz0Lpu&=ag@EGEoP-&6xPAo20wgOo_RHO zSMu8fdJAyqr>}Rz-!6zibRBLOQr!PE|2ntoPm$lv9fH5T;iC(=0Vr$1!-IT=d^;AA;zD#P%LER4uGsNA%pFJk}=!ki3XZ>e*j5FX3#Wnmm z1OpQIceTq8h`NS+N-ozP=7X_}5z9?luY0#@znJS0UwhxWR{>@8d2IwOZ6*wSLidAZ z*xA?$$MK;9%K0=|A7rdLYxx9so1)y5^60EskL?R0r_!kGv9!KPHzH3#FQAk1P)OA+Kr z;reh8UU!A7J!sua&&uRvbVAvCMOwwd+iT*dx#&#EOK%z4JKE#mcCIhVYzH{57659? zxrwZG_mW>Kb=UCF9Cek#Jqf?I4MS11AU-PSP>JUX&25-fH|Ok1q%cUEE-86V?;q*} z@mYLpl%L|Mzqda{HR#)W=Ktp=-=!v8t`*V4SPDz(6!6r1jwTz@MrNr~`)44;k)9a# zy>0K+rae>Bx(ZKsR74GCuCwG+Y1R=W;U@{TMPusUSP?xCTN^dpiJ_uROnq59+olDf z#p!O-R+&!Je94WK+F^detpq36bNy2emklok9ndT_$u%gWmXc;tRCsP!9q2@Et3wYK zYW`izd}2&_b;lnPmf`<2*mg{Gmvb!{I0@xG@}ECp=noG9c0jy(vZiYe zF`Fhifka4QUAyjKp03m*Gn2juQfGA=#yXwY zd%qNb_D*;&G*9}oAT3=>;v>InNv|`wS%;N-08FT-=4^i1Q`4 z^vtOn+i@`h@8>T}~1O&L==kH<{*1+!Z% zuqz&m{h%2+kLvi_?}X*Rtl8GGzJY!dwjBqTPWO=(~?C^2Ji}&f!GeM!;llz$`Zy zse6w_>e|ui#J^WwJNK44-*PIr)FS7*K2Fh&a7vueOc_*4SD#jlzgC-2xU|hvg1|;H z!S|h)CxLt_AZNxf&RuM+QH~zBcl+F?Z_5GQ{2GidWEBUX(hY7o9l0YPx@|PWf;{zb#};Kj05n@dw?C|J;V;jP z!BSXrt&dyN##!siwIQV9-kSa@MzyYQ&Wld4;6=F}L!M&M)-F1*I?&2Kz=GM82yvW^$h<#~8vQQlc^QjjaS)Gg$R8snLv=XG?5+Ci=W^WR zEV>g%yT3>7Ocv_T`U~7Dv#Fs$^HD)3;00N&vUXRWe#t~-9>54B_7zCWx2_LQQxS!< zE6O0Ngz^|E$DA$y&|i2ekb4|c;|dK)N_cvP-dX0^x`nB$lfgOD5h@vquwk73FTq(a zT+#aK0Jd(*EqtmtZy^5OJgrZ?yPhzgxRWOl1YUmD))BU^{4VbvC}aeF{LB_0a!Tcd&?eOx?V9P8Z2>F%uR ztE=%kkzq7^x7{muKK5qssN{wdzE$mft}OI$)a2nQuxV6vc_K@#Mq*hnS3fd0^(vFO zFcSNJgv|_#;3U2!)K#ZT3bWe$Aw^d%X(A;`T95dg>O{V8o@uy3=bP%A%h|%w*d^I% z|7qXuknR7TOY2$u!=*mV)k?avLYGZ!u18#seZHDL)C^>b5^uFq!8y)t3$mCUXQLZF zo5hEcEWV*V>RAna?V_>lCQldtAIcvH{Tr?p)vvHz{mhJnCg{d1&dNDrHzN0yDfVf z^TYkFYNRM_yANW!n{19e*gcimO>)Vv-Y*(uqhJPs{OLJSXlqB5UJ}jQLtYt|? zh(BBaf;mH2x+Gb#KH$epUH_hsdunM3?cM?A_$MTd7VgIy>nCS;wOJqs-^E`r?$i2# z`2Rq<5+9a=J)?zvg*&fZ+%pn4c?cVoP`o4_PylKBBA!{w(Or)S&b<6N--0B)h4iks zV$DpMiV0?>;qY`^n&vr>(y67z0Vh$WdW`QzhLER7Nwzx!;Zm8~9;cA`mJ3!EBr8hJ5o zwA&4e$=TT*H2UvDmzsG+lq!kU3>B!=`Y&(BoFnQeCWYFnC~I^b-w-w z=|g|?b6~89@tt-9htw2!_+~C%3z$2if6yn%SEv6@Hvh(`KEFrXKWSxBbcMCRth~8^ z;&7%oUqxiA*{z3fT5u5Cl@{+B{q6dUHKOtAVB&L9Nz$k*<_fs0EPvkTFB$7ji1xdE zXX;q48L^4}Ab_(LMV0LU?rt{|I3#MH=U?!0=QK7|j^Law8du|7>!>{}{)A6x5b50e zOXmu^5cZzqWz50ueBg}wrX@J6?4EL}a#xokwR&`H=LZEwM>bVIk25#r+u)x?t03)6Lv(5n;BCX>scrc7^`l_5nEUmQU z#l;iLKd|!M$?P2hZ+YfV*J{0)0d`$B!8P$Zzyx3aJPmJ!^eCMsP ztY*?&&7wTV)#tto*^YDOzaE6_UcUAhiVw*8R_Z=h}kq{E>w7-;Eq{F?x?EVz3 z4~OJDlVCB8Te_V#l)7k)To8r2*$eo6MoL)nE?&7iyP2~>tSf9R4{;x z?dc;6K$X+U{+U%JjDI$CPEI$=XOTv{VaOGMlk?>(9W--dO%Reiz|I`U#P>$i(P^8$Vj_6w0jk3%ra)-&}Z@>jDBkgy_MVy``M>uqf4BsAJY(J}GmF zh>m}hB(Oa=bve^z8uC}cU%?YiO2g8}>DbH7-rnfsg0)%MD>A9jB_#K-Ts z^nkX#6-N9hQm^W?;3Xa}Ovg@=|FY!@>$!;+ZO{Rvo}W|#GQ1n+(hnDFO9}+OJJVU< zoy$f!whKHFq@AI`cRKp2o6<`}hRy(U-@IMgwB2J~w*XEAU0(wSMiOGz&!W{aGt=SE zElgzc!-B4!4-R~oZ~6~-^5x_Ub4MyqeDbe=7tuf7_}S_0`=HZLKUjLa{ywPTIj8dC zXh~<$N2yJ*!Yd!W{pVxP;E3KU0e6bteL4LZxjZ$#I_#@^wI9xW@xvLbQ!4PS;a4l$ z^Gq5-Fqu9-KVLj+?^!jYCG#^PS3Fnta?GX;&?bY5VeOGLbe`BZ zHn)}+0LgqzmwT1lk#}d>5|Qdlt=E92a*o`1)G{PI92&Gd7J{{}URCwc4QD?OkoLag z*0{EdGYL4x`Ub5gl;Aop-YlLE=SCjBQ7FWj_2Bh(|K+dbfuYrWek1swQ*ueym~#O$ z+qipE)Z?ca+d7xjk~Wjjk6O42qL2k}@5qBzIJ52}D41Dq3G}0jJsa=ddR44Tw&jr= zRGWB%8GD8oUL!vz)n^8sME4-pE*BIP3obP-|6V!s2RftPvxWR`?rcH?ogGZ+uMD8-o?sFi4^D3c z%?tAO#%rQj%$F?8)Hzt+yP_yL%N%x2_mp|7!NZ-o?j!Xqjq7hoz}K_)pz(D`Z<<7Q z!Vgsc#FO+|h-YM-oc9i7td2eL+M@k+uI0m;o1yL$ArWomt}@7Oia+jK__Q>bcaX?C zXzXAOBL;}8zS7yVBI4h|tR^33DGKaC7>c5(+|Vz(b;g$HDzZGkU*Ek`{~&yBE1+jN z{`lYP+tqv<{Tnm*S);A5#$2k=;qQW@cMj#VCu0xe>| zkmR%uY8k0Th$19VTc#3Zg-jHbR1s38h^$0Ji4c|rWMnZ!L`HxRBO?is5fUJSBxJDO zocDd7KjC+O?%#D?_xJh^a%^oIcB%qU(5k}B-nG82EC8PH&OjT+s|wi4m#qw_zoOBw zG5RAn*3tr=ozseC4FrCVq#zyjAtO9$k<+O*IgVCrj2f)9tVjR51i!jGjp@ui$VqF6 z2gknGv6Fwuq){4MIAP^Gg5FVecWoO_&d^_nbtA?H=TE}pH=kQ_N-P0ykhmg@d(J{s-_3e% z=Y~ivHy;2224?Hlk*U5?Tg5+EGJ}CZ%_!FtW-c51=xkG*K-@mP_MGfUU0WAp@Jlu# zg0H5Xti>4NEPaNEWwR?CFJE^yl{#&7k7TY&kr{Z69zUjjw2E|`^4y*vXSNK&3F|`m zhLkgxKTAWc%jjn2;O3884h`71>T>AXF*xUVs(_Yh$mq`7UUUDG{#0{850)6Zm3Xnb zT|b>txPBkST*@#t0sggNT8AWW6hhZO%r0g2ah=5*%B_M~FQno1Z00r@nY2O9+yeJ4 znPpNWR-oWm_S}FtW!lK}BYq$ACC2CqRT!!SY?a}z z4M%2ZE8=Jj1{i*hIep)y^cKhpSz^LE(Z0(@_2qG6}Mr&yPd%g-iu<&kLz$zTv zQ04^B{NiT3LNca1gEWC0ACT)^oD2GvvIK6-vN%L`#J2OtMR_5(eaT^4l*B~1wE8z^ z%r_PGxkUJ8Fs*-qi8EwRw%$J6D=Jko`CqQRczdQCsKbukHdnCj?lL34N&jd{Hg?dY z)bhlTN}r)X*R-aYG#IvPB&%!xs(MCv#)<1o7fAvL!6`K~LGeMn)wE&Z2s_eLn+#T= zUZcw$GUYv%!sWEPxz2`~Qam_r#XNa3J-_yT>4-N2r zxAFsd+{L+Cjs4*8dX_mPY|+(;{{c^24LpPL^8S~=xOy*kg3zX@o*fWSbH_tn@==|B z{F;ofl#qt-tn*knwHoFUp{WU6|BF;HAg`_Ma%xoUZ;kF2GGax zSqGifUu3=x{%XU?x=Nrst@%}2PJ>&WPU5vLdW*NsUQi`Gbo1esI5*#lqSKC6`X*7v zzc1Tk=W#E0>z0Q%+Ua$l<%@n_B2MR<4 zKNYrW_-ZmXB(H?D1RlqY6jo->viCr=ms+{IVeaBlr^J$b=kxqu@h!L^m7?tIS17j7 zwQ9lKx~KWe-=$>4oYw*52Qp7N_-4{0Uet5>($~;#?`Ie3f4`# zQgW#OVn^N$SGxBB!`B`=f|x3#7=AKr|U6|Y+JDAbizx))~3}}y|g|?=n!D_#B*pSN*HJMpy@)J0z~Tc z{Z^Bs)fs7Gzr-=PbO#moB+*rH zdL*4C{X+ckvBj>gk#djz(bNXk(8u=CW`{|J(}=Bib8=rA^)qYYT?wyM!eg!JtEv|Q z-f23<_6_g$7qjAVE*Y1yo2VjpZx=925-R>j&Os;iq{2YIMRzonTiBk^&oH9nS5pnz z?UAd7%tFd`(2twyFU0yUo!HCPkKLtis}YeiOOV*#?c`pDuHKvI$?tn>6%w_N8lnB$ z9MiPWAWp#xwzcM#q1Esk+Sd)6+Y=EaAve}xv*;lk#ISbo*V~(j=6XRFaep}Nn0FIs{!r@2B z8>{9BSyj^+Ow}?wt(vxv36`o>FtE+f{T<@&Do)h}mmaL$eO2-Hj>a1REar4}_QV=HiD| z%IKsHq3d3`s0%Kb&9yXJ3k0tJJlo5qb?D{PohOIoT_fXSP*(xO_efFGQuid?^!y0Y z!a1MqGj6)-wgV=vPa?`m&H*y*8;Q;T05_So+I0YJy7*we&+>J70g>TpUmzG)t?M88 zceZOfJz+71$fOnfqVwc(3K+(Ydl?}pjI9v0BMr{J(hQ?BrceEx1~F0H^B_>Dy4;e} zDpWt5rEiVdlGih*c+Ph<1Eokk4Ux1_0o}~%aW{sqNaCmj=Bk&{SAjRU2e0e&(2P0M zv8$U|N1|9N!ZtT!0qjTYTE)0yy4ATb{EVE6PHGjVJ8nyW!ZduJ4QtLYW_B7;Z`gq^ zm@ca4Xij~(3c`Cy(YHuV5!7g?w%N$;$=~V(ic@;bj)^k_8^rd`fo<(TrowPfyxHHE zxnAgedP|f*P2QN^R?kR^ZvU~JD>l4Si_oI!LHp(Z!vYMz&n9r}ToiX(5py@cbGDc2 zAk}KV1mmtmjX(Px)eQfhEEuOzs=LNM0NeR-(JVC9Rq?`@K;8IdaY(X(Tn^or%_qkl zgIpd8sW*4cD?iq?OV8{y8-gk=_#1y!hFCUxC-TxsJ}R z!pUo-A@(x0NEGwJf*!ig`^kMB4y#?Qrmh_GCZKU}&L!6ppPRy=sMhG2tHZ3}Zv+;~ zXdI6_tfNL(?zMv@VU-)5N?%^v8&^jd5u$xQ&{uCxonQFLnK&mi6T7^bA!jXYNS0Wq zhNCuu1d)mN-Z%ZK*du0tp8R|9>AvDbIjBKx{zrAA0{m)v@s*i^V7=m^vHD1#fAZ#? zi%40h?{yLXYO+sLw$rZHvxD9QPvi1#Sb&eQCeF?0@_P7CY<YHVRam^ReDumDt#TX6xd{J_4( zvX!T~DKEHWxI9p{OqtUfMe)w5JKfhJVhS3*w+zbs7)ICMXPmV`k3H{cMg3mSCoZ1_ z+qWyROUJU(fTak?oabv7UYPCz? zvok!M+EFHA6l-hKSaUUfc=rwk$ZW*fo#xbXY|zaH$$GaNTU;sPr++VSEQp?0?{V$3 z$Y1|wQrBy%Gqgv!<$`BI%O%da8gLlXHZ$}R2k{y@*rx_r7hc1L={ zIt7N%O)briy-x@7!>0e6Gg1y(TXqG-cJseI}zkm5;k*Rnm466od$J z*QBH3y`-#M!^wh=H)nH2C+$wO#Acw$3jxk(&t2pxi=!DW7lIL;s~^4QmxqR@U6ck! zXXR*1kHlYy8-qnyU%RXHlg%P@i)eOccu6;Q_3k&H?pu z_|D_tw2yqiQL%UMh}ndu>$glVxR($OdiwC`0V)IBIT|r^fO;~S%-IS;Vb|^!Ov|6| zNN{@+%W#SO1s(Jxk~ih+>Ih~?xlZW2A+|~QoNTd=N8>7=6mI2>Z!hF-pnuRqx920t zKC4ZQwx0$bcr5tNcCG`WZin2iolbTsK^koQLbum?BknFvK}#KC;Fblgmn z`n@}LK|RLZR;gz;QKu1@?Y;SyP}xc!Dsy6h%w;QW^Vgx3bWBx>5-u6!=|Jhu62bm3Nb;_K9 zO%pa=ZA*OmcMB2e=W|I z4ww5jXdl7E?_V%X>?Hc>p-d^7*sV4@SNuK8-O9t|HyDRwob-gADYAI;1dL-kQUWzN zxqGwcaZs6oyfx<3S47xe&qo=C?EqULcZisUCiceb*@8^*nQhbYrp&iZ_3CYduus2X z@Ne)I8z$`#!p+qM*R3Hk$OM4QAL05U)+_aZOo<V1-096eRwK1%B6s9W2XL7k9)AG_a{!IQO?nsWm#Lp zPJQ~V6&xA0e%mf$NV^S|#niuCTmOHji9Q?7oS|8amxk1D^;wF|22))&P!r01b?@VC zQOkBjsy>&BcS7h9QjYcI@a|v)L9jT%qC(jc+{cM}0F%-#pepC&yO+2%%BPuiI>r{W zdu?*o$o`$PXj_2P--k=FX<=Et(K|#3TkS6i$C9eZFY3?ZO^fpQLeP9}p46+w*~)-OEhG{n4i3TtNyp=po?}vA4GpI=$A|J$eDQ>2iGVr2EESWE)?72+Hew zl9(?Vddglp7qe$F`j@>>wwn<%<^Wi`ZVkReNux1gP5phg`TBZGXRXT#W9-@B#68}i zd->>%Aj8~@DkIg*dfkfF4cc1i3sGL{Wa7r0HgCE9!_7s>&Q{RSe=Pd&jmc{1iS!Qq z%bpN-5jb{0Hcx>l(uzESgCG?#x)ad(xrEM-++w)nW!gtG%YYc$(~uS2`}_6*R5}Wm zeE*n>d&f~|fm`j<5VX4hCn+^v2ZVob+jl+0|HS@%LQ=}?L(JfQ>S2}SYvIPmohbtE zmja~muw?;7vCws~G^@Tn>Qhwrkv>;nVIeto(iXpQwvytzz#0oC=Yy!An07b11#@%cL5r`xUAh9^W) z9@PW!)`HS^ZzA?oD+{x4928)_z|4 z-hybhoo5-7>Q&nDp^M67#w}gbc{C|_R6S_D5XSoD+b)7ZXC@^!>glS9e(gGPQj4XV1g+ zjEy;e_O9i4qE|^s%zO1Pthe~ui>vEWkyldOheJ_1+KSuY(m&&c4I6H;vO-8&&b>a` z#`~))T$>feD7O+KZ|9XgyT)WhXT&djyLWSL-tZXRK+N(1sbilo;z=|hQl!--!=Bgg zY8~4xDki35b7(exR5*Bg_HdZmy1`GR@EUhv-LFPxfOQLA1lObJuP&oL7FwpYdG0IQ zQZJpDtD-h6f3_<|;C%j)HI#1Do^&33POqn-W)mT3Ff&*~^sdj>B6Y0KBGzqKaowX~ zn6_Ok6ZODCjX{<9T>pG$cPnKfiF4Kyax->jsu=eyp_ep~b#s^WYOX@F1X+5!ZN0c9 z6_e5j4tvJ`HmtbH&hcTcG;k}T{Zb?27q0gDPVK&GZ@f^kM?Rj%sm&ZGU z5+%4XBV|FlnX;4V-eL5KjLsJoFui+2qOv7N3i|yqY`cu+l&N)RFB;xb+hz?V&dga= z>w(RFXS`ayPSmpcoHr_Hj+q8BTWc8FUgMFe?Xwk`XhH=gQ|) z`x8x_ZTdgR88e57B~C?&9*T6QdMr7TL18z7-D&61H7^v)X};ock-G!LP^Nc8VI~~% z-3{H0e6hM0?M&6|gh3VSC!Eo~hH8}0mNCM{5xEgZP1EJNZ>^OUZLTX%BUB-rjA`{> zB%NF*(uDm__)!wj>QZ(n|9O{nw&Gmv)_s+f2v~z%?0xEqV+j`&ijP4#z z!`Y_cVj~=SmA{wL+GP66e4ATUiaW#gvZ4*T#80^sr#bR$8qUG*Xi z$5aX+ve^?DN8`{+{dJFayrc5?IksONDfnACBt6-w06PS0SEY2T=Fjk*731X|So-?y ziOv7)gz=pEFb6~I1U65c_Ysx?lH$DtEyiB{R5(i{R>|7O_;%zf&GDM(Mr;87@>dA> zS7|%;S}<0z$8r$-&ZYI7;?F{fC4LAY_a2B7;|;-kUKZQAN9mJ2`h9dCjUndLroc0u zi;V8-2HzRwXGQ5A(NB-Kx5}%Jⅆ$p2wLUXkqTw8Z9)t9`&XGl9WOwT~3vRewAj= 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 t4Ng^AdMb(kg1v5n^Tx{b`7O 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